diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index 23c4fa8257..490ef2a445 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Exercise the shipped delivery view with synthetic status and read-only API fixtures. +// Exercise owner contract readback and unavailable operation controls with synthetic sources. import assert from "node:assert/strict"; import {mkdir} from "node:fs/promises"; import { execFileSync, spawn } from "node:child_process"; @@ -39,9 +39,12 @@ try { const writes = []; let response = structuredClone(snapshot); let responseStatus = 200; + let authorityStatus = 200; await page.addInitScript(language => localStorage.setItem("loopx-pw-locale", language), locale); await page.route("**/api/**", route => { - if (route.request().method() !== "GET") writes.push(route.request().url()); + // Model-option discovery is an existing read-only POST. Every effect + // endpoint, including the new acceptance operation, remains in this ledger. + if (route.request().method() !== "GET" && new URL(route.request().url()).pathname !== "/api/chat/agent-model-options") writes.push(route.request().url()); return route.fulfill({ status: 404, json: { error: "Unavailable in read-only fixture" } }); }); await page.route(url => url.pathname === "/status.json", route => route.fulfill({ json: payload })); @@ -50,6 +53,11 @@ try { assert.equal(new URL(route.request().url()).searchParams.get("goal_id"), "release-demo"); return route.fulfill({ status: responseStatus, json: response }); }); + await page.route("**/api/actions?*", route => route.fulfill({json: {ok: true, schema_version: "loopx_chat_action_list_v1", proposals: []}})); + await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({status: authorityStatus, json: { + ok: true, goal_id: response.acceptance.goal_id, provider_revision: "fixture:current", + goal_acceptance_contract: response.acceptance.goal_acceptance_contract ?? {enabled: false}, + }})); await page.goto(`http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`, { waitUntil: "networkidle" }); const navigation = page.getByRole("button", { name: locale === "en" ? "Open Goal navigation" : "打开 Goal 导航" }); if (await navigation.isVisible()) await navigation.click(); @@ -82,12 +90,25 @@ try { const section = review.locator(".delivery-acceptance-contract"); const refresh = review.getByRole("button", { name: locale === "en" ? "Refresh snapshot" : "刷新快照", exact: true }); const exportButton = review.getByRole("button", { name: locale === "en" ? "Export delivery snapshot" : "导出交付快照", exact: true }); + const openAdvanced = async () => { + const advanced = section.locator(".delivery-check-advanced"); + if (await advanced.isVisible() && await advanced.getAttribute("open") === null) { + await advanced.locator(":scope > summary").focus(); + await page.keyboard.press("Enter"); + assert.notEqual(await advanced.getAttribute("open"), null, "Optional diagnostics are keyboard accessible"); + } + }; const refreshSnapshot = async value => { response = { ...snapshot, acceptance: { ...acceptance, goal_acceptance_contract: value } }; const read = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?")); await refresh.click(); await read; await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled); + // The native check reader is independent of the delivery-map snapshot. + // Reload verifies both read owners; polling/recovery is covered by the + // real SQLite/HTTP/CLI journey. + if (responseStatus === 200) await page.reload({waitUntil: "networkidle"}); + await openAdvanced(); }; await review.locator(".delivery-chain").waitFor(); const baseline = await review.innerText(); @@ -98,35 +119,28 @@ try { assert.equal(await review.innerText(), baseline, "Absent/off contract must preserve the existing view"); } await refreshSnapshot(contract); - assert.equal(await section.getAttribute("open"), null, "Contract starts collapsed"); - const summary = section.locator(":scope > summary"); - await summary.focus(); - await page.keyboard.press("Enter"); - assert.notEqual(await section.getAttribute("open"), null, "Disclosure is keyboard accessible"); const text = await section.innerText(); for (const value of [contract.objective, contract.digest, "release-demo", "todo_confirmed", "todo_unbound", "todo_stale", "recovery"]) assert.ok(text.includes(value)); for (const value of locale === "en" ? ["Task association confirmed", "Task association missing", "Task association stale", "Task associations require confirmation", "Outside the current task gate"] : ["任务关联已确认", "任务关联缺失", "任务关联已过期", "任务关联需要确认", "不属于当前任务门禁范围"]) assert.ok(text.includes(value), value); - assert.equal(await section.locator("button,input,select,textarea").count(), 0, "Readback must offer no mutation controls"); - await section.locator("details > summary").last().click(); - await section.getByText(locale === "en" ? /The local Goal owner/ : /本地 Goal 所有者/).waitFor(); + await section.getByText(locale === "en" ? /Import an existing JSON contract/ : /导入现有 JSON 合同/).waitFor(); for (const [status, en, zh] of [["unverified", "Artifact checks not verified", "产物检查未验证"], ["failed", "Artifact checks failed", "产物检查失败"], ["stale", "Artifact checks stale", "产物检查已过期"], ["partial", "Task checks passed; Goal-wide verification unknown", "任务检查通过;Goal 整体验证未知"], ["accepted", "Artifact checks passed", "产物检查通过"]]) { await refreshSnapshot(verifiedContract(status)); - await section.getByText(locale === "en" ? en : zh, { exact: true }).waitFor(); - assert.ok((await section.innerText()).includes(locale === "en" ? "neither automatically approves or completes the Goal" : "均不会自动批准或完成 Goal")); + await section.getByText(locale === "en" ? en : zh, { exact: true }).first().waitFor(); + assert.ok((await section.innerText()).includes(locale === "en" ? "independent review" : "独立复核")); } await refreshSnapshot({ ...contract, scope: { kind: "selected_work", todo_ids: contract.tasks.map(task => task.todo_id) } }); await section.getByText(locale === "en" ? "Only explicitly selected tasks" : "仅明确选定的任务", { exact: true }).waitFor(); - await section.locator("dl").first().scrollIntoViewIfNeeded(); + await section.locator(".delivery-check-advanced dl").scrollIntoViewIfNeeded(); await page.screenshot({ path: process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR ? `${process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR}/${locale}.png` : `/tmp/loopx-acceptance-scope-${locale}.png` }); const next = { ...verifiedContract("stale"), revision: 8, digest: "b".repeat(64) }; await refreshSnapshot(next); await section.getByText(next.digest, { exact: true }).waitFor(); assert.equal(await section.getByText(contract.digest, { exact: true }).count(), 0); - assert.match(await section.locator("dl").first().innerText(), /8/); + assert.match(await section.locator(".delivery-check-advanced dl").innerText(), /8/); await section.locator("details > summary").first().click(); await section.getByText("c".repeat(64), { exact: true }).waitFor(); - assert.match(await section.locator("dl").last().innerText(), /6/); + assert.match(await section.locator(".delivery-check-result dl").innerText(), /6/); assert.equal(await section.evaluate(element => element.scrollWidth > element.clientWidth + 2), false, "Long digests must wrap at narrow widths"); const downloadPromise = page.waitForEvent("download"); await exportButton.click(); @@ -136,16 +150,20 @@ try { for await (const chunk of stream) markdown += chunk.toString(); assert.ok(markdown.includes(next.digest) && markdown.includes("8") && markdown.includes(contract.objective)); responseStatus = 503; + authorityStatus = 503; await refreshSnapshot(next); - await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor(); + await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor(); + assert.equal(await section.getByRole("button", {name: locale === "en" ? "Check delivery" : "检查交付", exact: true}).count(), 0, + "An unavailable current authority must not offer a command from retained state"); assert.equal(await exportButton.isDisabled(), true, "Failed refresh must not export retained contract as current"); responseStatus = 200; + authorityStatus = 200; response = { ...snapshot, acceptance: { ...acceptance, goal_id: "other-goal", goal_acceptance_contract: next } }; const mismatch = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?")); await refresh.click(); await mismatch; await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled); - await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor(); + await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor(); assert.equal(await section.getByText("other-goal", { exact: true }).count(), 0); await refreshSnapshot({ enabled: false }); assert.equal(await section.count(), 0, "Disabling removes the previous enabled readback"); diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs index c6acf089e3..04015e5eab 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs @@ -47,7 +47,15 @@ assert.notEqual(next.acceptance.goal_acceptance_contract.digest, contract.digest assert.deepEqual(contract, before, "Rendering must not mutate the supplied contract"); console.log("Goal acceptance contract API/export: off parity, association/verification distinction, source/revision and negative cases passed"); -const {projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js"); +const {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js"); +for (const [observation, expected] of Object.entries({current: "accepted", stale: "stale", failed: "failed", recorded: "unverified", unavailable: "unverified"})) { + const value = structuredClone(verifiedContract("accepted")); + value.criteria[0].oracle = {state: observation}; + const before = structuredClone(value); + assert.equal(goalAcceptanceDisplayStatus(value), expected, "Historical success cannot override current file observations"); + assert.equal(projectGoalAcceptanceCriteria(value)[0].state, expected); + assert.deepEqual(value, before, "Display freshness cannot mutate canonical acceptance"); +} for (const status of ["stale", "held", "partial"]) { const value = verifiedContract(status); value.verification.contract_revision = value.revision; diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs index 49447a3e05..83bb04e95f 100644 --- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -133,6 +133,26 @@ try { await verificationGap.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"}); await page.setViewportSize({width: 1512, height: 980}); + // A local pinned check is useful evidence, but cannot authenticate an + // independent reviewer. Keep the existing gap alongside the optional receipt. + validation = {...currentValidation, oracle_checks: [{criterion_id: "report-format", source_id: "report-checker", + source_digest: "sha256:" + "8".repeat(64), checked_at: checkTime, + input_versions: [], output_versions: outputVersions}]}; + await page.route("**/api/chat/sessions/*/loopx", checkedRead); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + const correction = evidence.getByRole("region", {name: "纠偏证据路径", exact: true}); + await correction.getByText("负责人指定的版本固定检查器", {exact: true}).waitFor(); + await verificationGap.waitFor(); + await correction.getByText("检查在声明文件的隔离副本上执行;不代表远程复核者身份或异议已解决。", {exact: true}).waitFor(); + await selectedOutput().waitFor(); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + await page.unroute("**/api/chat/sessions/*/loopx", checkedRead); // A completed source can retain its output bytes while its dependency // chain becomes unavailable. Reading an adoption candidate must withdraw // the earlier correction trace and must never offer a confirmation. diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 28de57040f..fe40fe4bdf 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1,3 +1,4 @@ +import type {OracleEvidence} from "./goal-acceptance-observation"; import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js"; import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; import { parseTurnStep, type TurnStep } from "./turn-steps.js"; @@ -410,6 +411,7 @@ export class ChatApiError extends Error { } export const typedActionKindSchema = z.enum([ + "goal.acceptance.check", "goal.create", "goal.update", "goal.lifecycle", @@ -1174,7 +1176,7 @@ export type DelegationReadback = { artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string; check_count: number; pinned_file_count: number; checked_at?: string; - output_versions?: Array<{ref: string; sha256: string}>}; + output_versions?: Array<{ref: string; sha256: string}>; oracle_checks?: Array}; task_failure?: {stage: "task_postcondition"; status: "failed" | "inconclusive" | "unavailable"; recovery_kind: "repair_required" | "replan_required"; summary: string; validator_kind: string; turn_key: string; resume_mode: "revalidate_cached_result"}; diff --git a/apps/presentation/dashboard/src/data/delivery-review.ts b/apps/presentation/dashboard/src/data/delivery-review.ts index bf886b7cd7..3eb32199f2 100644 --- a/apps/presentation/dashboard/src/data/delivery-review.ts +++ b/apps/presentation/dashboard/src/data/delivery-review.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; +import { goalAcceptanceDisplayStatus, goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; const refsSchema = z.record(z.string(), z.array(z.string())); const nodeSchema = z.object({ @@ -211,7 +211,7 @@ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels: if (task.reason) rows.push(` ${line(task.reason)}`); if (task.applicable === false) rows.push(` ${copy.notApplicable}`); } - rows.push("", `### ${copy.verification}`, copy.verificationState[contract.status]); + rows.push("", `### ${copy.verification}`, copy.verificationState[goalAcceptanceDisplayStatus(contract)]); if (contract.held_todo_ids.length) rows.push(`${copy.heldTasks}: ${line(contract.held_todo_ids.join(", "))}`); rows.push("", `### ${copy.receipt}`); const receipt = contract.verification; diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts new file mode 100644 index 0000000000..de4f91e505 --- /dev/null +++ b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts @@ -0,0 +1,41 @@ +import {z} from "zod"; +import {goalAcceptanceContractSchema} from "./goal-acceptance-observation"; +import type {TypedActionProposal} from "./chat"; +const responseSchema = z.object({ok: z.literal(true), goal_id: z.string(), provider_revision: z.string(), + goal_acceptance_contract: goalAcceptanceContractSchema, checks_passed: z.boolean().optional()}); +export type AcceptanceReadback = z.infer; +export async function acceptanceRequest(goalId: string, operation?: Record, signal?: AbortSignal): Promise { + const url = "/api/chat/goal-acceptance"; + const response = await fetch(operation ? url : `${url}?goal_id=${encodeURIComponent(goalId)}`, operation + ? {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({...operation, goal_id: goalId}), signal} : {signal}); + if (!response.ok) throw new Error("Acceptance unavailable"); + const result = responseSchema.parse(await response.json()); + if (result.goal_id !== goalId) throw new Error("Acceptance source changed"); + return result; +} + +// Read model only. The existing typed acceptance owner validates the complete +// document and its command/file bindings again at apply time. +const preparedDocument = z.object({objective: z.string().min(1), + scope: z.discriminatedUnion("kind", [z.object({kind: z.literal("all_advancement")}), + z.object({kind: z.literal("selected_work"), todo_ids: z.array(z.string()).min(1)})]), + criteria: z.array(z.object({id: z.string(), description: z.string(), + validation_argv: z.array(z.string()).min(1), + oracle: z.object({output_refs: z.array(z.string())}).passthrough().optional(), + }).passthrough()).min(1), +}).passthrough(); +export function preparedAcceptancePlan(proposals: TypedActionProposal[], goalId: string) { + // Never resurrect an older draft after the newest plan was applied, cancelled + // or invalidated. Ambiguous creation order requires a fresh Agent proposal. + const matching = proposals.filter(proposal => proposal.action_kind === "goal.acceptance.check" + && proposal.context.kind === "goal" && proposal.context.goal_id === goalId + && proposal.normalized_parameters.goal_id === goalId) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + const proposal = matching[0]; + if (!proposal || matching[1]?.created_at === proposal.created_at) return null; + if (proposal.permission_classification !== "local_command_execution" + || !["preview_ready", "deferred"].includes(proposal.status) || proposal.gate || proposal.error + || !proposal.available_transitions.includes("apply")) return null; + const parsed = preparedDocument.safeParse(proposal.normalized_parameters.document); + return parsed.success ? {proposal, document: parsed.data} : null; +} diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts index 118c0101e7..c2be3ac4ce 100644 --- a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts +++ b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts @@ -2,7 +2,11 @@ import { z } from "zod"; const nullableText = z.string().nullable(); // Readback only: the Goal owner supplies association and verification states. -const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ +export const oracleEvidenceSchema = z.object({source_id: z.string(), source_digest: z.string(), checked_at: z.string(), + input_versions: z.array(z.object({ref: z.string(), sha256: z.string()})), + output_versions: z.array(z.object({ref: z.string(), sha256: z.string()}))}); +export type OracleEvidence = z.infer; +export const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ z.object({ enabled: z.literal(false) }), z.object({ enabled: z.literal(true), revision: z.number().int().positive(), digest: z.string().min(1), objective: z.string(), @@ -12,7 +16,11 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ ]).optional(), non_goals: z.array(z.string()), held_todo_ids: z.array(z.string()), status: z.enum(["unverified", "stale", "failed", "partial", "accepted", "held"]), - criteria: z.array(z.object({ id: z.string(), description: z.string() })), + criteria: z.array(z.object({ id: z.string(), description: z.string(), oracle: z.object({ + kind: z.literal("owner_pinned_oracle"), source_id: z.string(), source_digest: z.string(), + input_refs: z.array(z.string()), output_refs: z.array(z.string()), + state: z.enum(["unverified", "recorded", "current", "stale", "failed", "unavailable"]), + }).optional() })), tasks: z.array(z.object({ todo_id: z.string(), state: z.enum(["ready", "unbound", "stale"]), criterion_ids: z.array(z.string()), reason: z.string().optional(), @@ -21,7 +29,7 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ verification: z.object({ operation_id: z.string(), contract_revision: z.number().int().positive(), contract_digest: z.string(), todo_id: z.string().nullable(), - results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable() })), + results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable(), oracle_evidence: oracleEvidenceSchema.optional() })), }).nullable(), }), ]); @@ -67,6 +75,21 @@ export function projectGoalAcceptanceCriteria(contract: Extract) { + if (contract.status !== "accepted" && contract.status !== "partial") return contract.status; + const criteria = projectGoalAcceptanceCriteria(contract); + if (criteria.some(item => item.state === "failed")) return "failed"; + if (criteria.some(item => item.state === "stale")) return "stale"; + if (criteria.some(item => item.state === "unverified")) return "unverified"; + return contract.status; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts index 4e77581b33..575c450d83 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts @@ -2,7 +2,7 @@ import type { ReviewExportLabels } from "../../data/delivery-review.js"; export const goalAcceptanceContractCopy = { en: { - title: "Goal acceptance contract", boundary: "Read-only owner contract. Task association and artifact checks are separate; neither automatically approves or completes the Goal.", + title: "Delivery check", boundary: "Artifact checks do not establish independent review, adoption or Goal completion.", scope: "Contract coverage", allWork: "All advancement work (including future tasks)", selectedWork: "Only explicitly selected tasks", source: "Goal source", revision: "Contract revision", digest: "Contract digest", objective: "Objective", criteria: "Acceptance criteria", nonGoals: "Outside scope", tasks: "Task associations", verification: "Artifact verification", unknown: "Unknown", noTasks: "No task associations reported. Coverage is unknown.", @@ -11,10 +11,10 @@ export const goalAcceptanceContractCopy = { verificationState: { unverified: "Artifact checks not verified", accepted: "Artifact checks passed", failed: "Artifact checks failed", stale: "Artifact checks stale", partial: "Task checks passed; Goal-wide verification unknown", held: "Task associations require confirmation" }, notApplicable: "Outside the current task gate", heldTasks: "Tasks held", receipt: "Recorded artifact checks", receiptNote: "Recorded results use the revision below. The current contract status above accounts for stale checks and task holds.", operation: "Verification reference", verificationScope: "Verification scope", allCriteria: "All contract criteria", passed: "Passed", failed: "Failed", exitCode: "Exit code", - help: "Setup and readback", guide: "Owner setup guide (v0)", guidance: "The local Goal owner configures this contract through the CLI using configure --document and the inspected --expected-provider-revision. Changes and verification require --execute. Inspect before changing the contract; refresh this snapshot afterward. Disable with the current provider revision to hide this section.", + help: "Setup and readback", guide: "Check setup guide (v0)", guidance: "The Agent can prepare a plan for review without running it. Existing technical contracts can also be imported here or managed through CLI configure/verify/disable. Saving does not run commands. Disabling retains historical evidence.", }, "zh-CN": { - title: "Goal 验收合同", boundary: "只读的所有者合同。任务关联与产物检查是独立事实,均不会自动批准或完成 Goal。", + title: "交付检查", boundary: "产物检查不等于独立复核、实际采用或 Goal 完成。", scope: "合同覆盖范围", allWork: "全部推进任务(包括后续新增任务)", selectedWork: "仅明确选定的任务", source: "Goal 来源", revision: "合同版本", digest: "合同摘要", objective: "目标", criteria: "验收条件", nonGoals: "范围之外", tasks: "任务关联", verification: "产物验证", unknown: "未知", noTasks: "未提供任务关联,覆盖范围未知。", @@ -23,7 +23,7 @@ export const goalAcceptanceContractCopy = { verificationState: { unverified: "产物检查未验证", accepted: "产物检查通过", failed: "产物检查失败", stale: "产物检查已过期", partial: "任务检查通过;Goal 整体验证未知", held: "任务关联需要确认" }, notApplicable: "不属于当前任务门禁范围", heldTasks: "受阻任务", receipt: "已记录的产物检查", receiptNote: "记录对应下方版本。上方当前合同状态已考虑检查过期和任务阻塞。", operation: "验证引用", verificationScope: "验证范围", allCriteria: "全部合同条件", passed: "通过", failed: "失败", exitCode: "退出码", - help: "配置与读回", guide: "所有者配置指南(v0)", guidance: "本地 Goal 所有者通过 CLI 的 configure --document 配置合同,并提供 inspect 读到的 --expected-provider-revision。变更和验证都需要 --execute。变更前先检查合同,操作后刷新此快照;使用当前 provider revision 执行 disable 可隐藏本区块。", + help: "配置与读回", guide: "检查配置指南(v0)", guidance: "Agent 可先准备方案供你核对,准备不会运行检查。已有技术合同也可在这里导入,或通过 CLI configure/verify/disable 管理。保存不执行命令,停用保留历史依据。", }, }; export type GoalAcceptanceContractCopy = typeof goalAcceptanceContractCopy.en; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css index 4e3382fa7a..7794116358 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css @@ -4,11 +4,11 @@ .delivery-review h3 { font-size: var(--text-lead); line-height: var(--leading-lead); font-weight: 600; } .delivery-review h4 { font-size: var(--text-body); line-height: var(--leading-body); } .delivery-review p { line-height: 1.6; overflow-wrap: anywhere; } -.delivery-review button, .delivery-review select, .delivery-review input { color: inherit; font: inherit; } -.delivery-review button, .delivery-review select { border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); min-height: 44px; padding: 8px 12px; cursor: pointer; } -.delivery-review button:disabled { opacity: .5; cursor: not-allowed; } +:is(.delivery-review, .delivery-acceptance-contract) :is(button, select, input) { color: inherit; font: inherit; } +:is(.delivery-review, .delivery-acceptance-contract) :is(button, select), .delivery-acceptance-controls input::file-selector-button { border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); min-height: 44px; padding: 8px 12px; cursor: pointer; } +:is(.delivery-review, .delivery-acceptance-contract) button:disabled, .delivery-acceptance-controls input:disabled::file-selector-button { opacity: .5; cursor: not-allowed; } .delivery-review button[aria-pressed="true"] { border-color: var(--pw-text); background: var(--pw-hover, var(--pw-card)); } -.delivery-review :is(button, input, select, summary, [tabindex]):focus-visible { outline: 2px solid var(--color-link, #0070f3); outline-offset: 3px; } +:is(.delivery-review, .delivery-acceptance-contract) :is(button, input, select, summary, [tabindex]):focus-visible { outline: 2px solid var(--color-link, #0070f3); outline-offset: 3px; } .delivery-review-toolbar, .delivery-chain-toolbar { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .delivery-review-toolbar > div, .delivery-chain-toolbar > div, .delivery-source-actions { display: flex; gap: 8px; flex-wrap: wrap; } .delivery-review-toolbar button, .delivery-source-actions button { display: inline-flex; gap: 8px; align-items: center; } @@ -67,6 +67,18 @@ .delivery-relations p { padding-top: 8px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } .delivery-empty { padding: 24px; } .delivery-acceptance-contract { min-width: 0; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); } +.delivery-acceptance-contract > .delivery-acceptance-content { padding: 20px; } +.delivery-check-heading { display: flex; align-items: start; justify-content: space-between; gap: 16px; } +.delivery-check-heading h3 { margin: 0 0 4px; } +.delivery-check-heading p, .delivery-check-boundary { color: var(--pw-muted); } +.delivery-acceptance-contract .delivery-check-primary { background: var(--pw-text); color: var(--pw-card); flex-shrink: 0; } +.delivery-check-result > ul { display: grid; gap: 12px; } +.delivery-check-result li > span + span { display: block; color: var(--pw-muted); font-size: var(--text-caption); } +.delivery-check-plan, .delivery-check-result { display: grid; gap: 12px; } +@media (max-width: 640px) { + .delivery-check-heading { flex-direction: column; } + .delivery-check-primary { width: 100%; } +} .delivery-acceptance-contract summary { min-height: 44px; padding: 12px 16px; cursor: pointer; font-weight: 500; } .delivery-acceptance-content { display: grid; gap: 16px; padding: 0 16px 16px; font-size: var(--text-body); line-height: var(--leading-body); } .delivery-acceptance-content ul { margin: 0; padding-left: 20px; } @@ -95,3 +107,10 @@ .delivery-notice summary { cursor: pointer; min-height: 24px; } + +.delivery-acceptance-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.delivery-acceptance-controls label, .delivery-acceptance-controls details { flex-basis: 100%; min-width: 0; } +.delivery-acceptance-controls input { display: block; max-width: 100%; margin-block-start: 8px; } +.delivery-acceptance-controls pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 20rem; overflow: auto; } +.delivery-oracle-evidence { margin-block: 12px; overflow-wrap: anywhere; } +.delivery-oracle-evidence code { overflow-wrap: anywhere; word-break: break-word; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx index dc6ebbc22a..67c7980c4c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx @@ -57,7 +57,7 @@ export function DeliveryReview(props: DeliveryReviewProps) { return ; } -export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review, hideWorkMap = false }: DeliveryReviewProps & { +export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review, active, hideWorkMap = false }: DeliveryReviewProps & { review: DeliveryReviewRead; hideWorkMap?: boolean; }) { const { locale } = useWorkspaceI18n(); @@ -154,7 +154,7 @@ export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review :

{copy.noGraph}

} } - + ; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx index f833b74ed2..28b983ae37 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx @@ -1,55 +1,151 @@ -import type { GoalAcceptanceContract } from "../../data/goal-acceptance-observation"; -import type { GoalAcceptanceContractCopy } from "./delivery-review-copy"; +import {useEffect, useRef, useState} from "react"; +import {useQuery, useQueryClient} from "@tanstack/react-query"; +import {acceptanceRequest, preparedAcceptancePlan} from "../../data/goal-acceptance-actions"; +import {applyTypedAction} from "../../data/chat"; +import {useTypedActionReadback} from "../../data/use-typed-action-readback"; +import {useWorkspaceI18n} from "./i18n"; +import {GoalOracleEvidence} from "./goal-oracle-evidence"; +import {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria, type GoalAcceptanceContract} from "../../data/goal-acceptance-observation"; +import type {GoalAcceptanceContractCopy} from "./delivery-review-copy"; -/** Present owner-projected states without deriving acceptance or task readiness. */ -export function GoalAcceptanceContractSection({ goalId, contract, copy, current }: { - goalId: string; contract?: GoalAcceptanceContract; copy: GoalAcceptanceContractCopy; current: boolean; +/** The Agent prepares; the owner decides the described effect in context. + * Proposal receipts are historical. Only native readback supplies check state. */ +export function GoalAcceptanceContractSection({goalId, contract: retained, copy, current, canManage = current, management = false}: { + goalId: string; contract?: GoalAcceptanceContract; copy: GoalAcceptanceContractCopy; current: boolean; canManage?: boolean; management?: boolean; }) { - if (contract?.enabled !== true) return null; - const inspectCommand = `loopx --format json goal-acceptance inspect --goal-id '${goalId.replace(/'/g, "'\\''")}'`; - return
- {copy.title} + const zh = useWorkspaceI18n().locale === "zh-CN"; + const client = useQueryClient(); + const queryKey = ["goal-acceptance-readback", goalId]; + const read = useQuery({queryKey, queryFn: ({signal}) => acceptanceRequest(goalId, undefined, signal), + enabled: canManage, retry: false, refetchOnWindowFocus: "always", + refetchInterval: 15_000, refetchIntervalInBackground: false}); + const actions = useTypedActionReadback(!canManage, goalId); + const plan = actions.isError ? null : preparedAcceptancePlan(actions.data ?? [], goalId); + const fresh = canManage && !read.isError && read.data?.goal_id === goalId; + const contract = fresh ? read.data!.goal_acceptance_contract : retained; + const enabled = contract?.enabled === true; + const criteria = enabled ? projectGoalAcceptanceCriteria(contract) : []; + const [document, setDocument] = useState | null>(null); + const [filename, setFilename] = useState(""); + const [busy, setBusy] = useState(false), [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const generation = useRef(0), flight = useRef(false); + const source = useRef(goalId); source.current = goalId; + useEffect(() => { + generation.current++; flight.current = false; + setDocument(null); setFilename(""); setError(""); setNotice(""); setBusy(false); + return () => {generation.current++;}; + }, [goalId, canManage]); + async function operate(action: "adopt" | "configure" | "verify" | "disable") { + if (!canManage || flight.current || !fresh || action === "adopt" && !plan || action === "configure" && !document) return; + const token = ++generation.current, target = goalId; + flight.current = true; setBusy(true); setError(""); setNotice(""); + try { + await client.cancelQueries({queryKey, exact: true}); + if (source.current !== target || token !== generation.current) return; + if (action === "adopt") { + const result = await applyTypedAction(plan!.proposal.proposal_id); + await actions.acceptProposal(result.proposal); + if (result.proposal.status !== "applied") throw new Error("The prepared plan changed"); + } else { + await acceptanceRequest(target, {action, expected_provider_revision: read.data!.provider_revision, + ...(action !== "verify" ? {operation_id: `acceptance-ui:${crypto.randomUUID()}`} : {}), + ...(action === "configure" ? {document} : {})}); + } + // Never turn a stored action receipt into current acceptance. + const observed = await acceptanceRequest(target); + if (source.current !== target || token !== generation.current) return; + client.setQueryData(queryKey, observed); + if (action === "configure") {setDocument(null); setFilename("");} + setNotice(action === "disable" ? (zh ? "已停用检查,历史记录仍保留。" : "Checks disabled; history is retained.") + : action === "configure" ? (zh ? "已保存,尚未运行检查。" : "Saved; checks have not run.") + : ""); + } catch { + if (source.current === target && token === generation.current) { + setError(zh ? "操作结果尚未确认。先刷新结果;不会自动重跑本机命令。" : "The result is unconfirmed. Refresh the result first; local commands will not be retried automatically."); + // A read-only reconciliation may recover a committed native result. + await read.refetch(); + await actions.refetch(); + } + } finally {if (token === generation.current) {flight.current = false; setBusy(false);}} + } + if (!management && !enabled && !plan) return null; + const readOnly = !canManage; + return
-

{copy.boundary}

- {!current ?

{copy.retained}

: null} -
-
{copy.source}
{goalId}
-
{copy.scope}
{contract.scope?.kind === "selected_work" ? copy.selectedWork : copy.allWork}
-
{copy.revision}
{contract.revision}
-
{copy.digest}
{contract.digest}
-
-

{copy.objective}

{contract.objective || copy.unknown}

- {contract.non_goals.length ? <>

{copy.nonGoals}

    {contract.non_goals.map((item, index) =>
  • {item}
  • )}
: null} -

{copy.criteria}

- {contract.criteria.length ?
    {contract.criteria.map(criterion =>
  • {criterion.id} · {criterion.description}
  • )}
:

{copy.noCriteria}

} -

{copy.tasks}

- {contract.tasks.length ?
    {contract.tasks.map(task =>
  • -

    {task.todo_id} · {copy.taskState[task.state]}

    -

    {copy.criteria}: {task.criterion_ids.length ? task.criterion_ids.join(", ") : copy.unknown}

    - {task.applicable === false ?

    {copy.notApplicable}

    : null} - {task.reason ?

    {task.reason}

    : null} -
  • )}
:

{copy.noTasks}

} -

{copy.verification}

-

{copy.verificationState[contract.status]}

- {contract.held_todo_ids.length ?

{copy.heldTasks}: {contract.held_todo_ids.join(", ")}

: null} -
{copy.receipt} - {!contract.verification ?

{copy.unknown}

: <> -

{copy.receiptNote}

-
-
{copy.operation}
{contract.verification.operation_id}
-
{copy.revision}
{contract.verification.contract_revision}
-
{copy.digest}
{contract.verification.contract_digest}
-
{copy.verificationScope}
{contract.verification.todo_id ?? copy.allCriteria}
-
-
    {contract.verification.results.map(result =>
  • - {result.criterion_id} · {result.passed ? copy.passed : copy.failed} · {copy.exitCode}: {result.exit_code ?? copy.unknown} -
  • )}
- } -
-
{copy.help}

{copy.guidance}

-

{inspectCommand}

loopx goal-acceptance --help

- {copy.guide} -
+

{copy.title}

+

{plan ? plan.document.objective : enabled ? contract.objective : (zh ? "Agent 准备检查方案后,可在这里检查交付物。" : "Check the delivery here once the Agent prepares a plan.")}

+ {canManage && fresh && (plan || enabled) ? : null} +
+ {plan ?
+

{plan.document.scope.kind === "all_advancement" ? copy.allWork + : (zh ? `仅覆盖方案选定的 ${plan.document.scope.todo_ids.length} 项任务` : `Covers only ${plan.document.scope.todo_ids.length} selected tasks`)}

+
    {plan.document.criteria.map(item =>
  • {item.description}
  • )}
+ {plan.document.criteria.some(item => item.oracle?.output_refs.length) ?

{zh ? "交付物" : "Delivery"}: {Array.from(new Set(plan.document.criteria.flatMap(item => item.oracle?.output_refs ?? []))).join(" · ")}

: null} +

{zh ? "将保存这份方案、更新相关任务的验收关联,并在本机运行以下检查。准备方案本身不会执行命令。" : "This saves the plan, updates its task associations and runs the following checks locally. Preparation itself executes no commands."}

+
{zh ? "检查内容与命令" : "Checks and commands"} + {plan.document.criteria.map(item =>

{item.description}

{item.validation_argv.map(arg => JSON.stringify(arg)).join(" ")} + {item.oracle?.output_refs.map(ref =>

{zh ? "交付物" : "Delivery"}: {ref}

)}
)} +
+
: null} + {enabled ?
+

{fresh ? copy.verificationState[goalAcceptanceDisplayStatus(contract)] : copy.retained}

+
    {criteria.map(item =>
  • {item.description} + {item.oracle ? {({current: zh ? "当前版本已核对" : "Current versions checked", recorded: zh ? "历史结果,当前版本未核对" : "Recorded; current versions not checked", stale: zh ? "来源或版本已变化,请重新检查" : "Source or versions changed; check again", failed: zh ? "检查失败;修复交付物后可再次检查" : "Checks failed; repair the delivery before checking again", unverified: zh ? "尚未检查" : "Not checked", unavailable: zh ? "证据不可用" : "Evidence unavailable"})[fresh ? item.oracle.state : "recorded"]} + : {fresh ? copy.verificationState[item.state] : copy.retained}} +
  • )}
+

{copy.boundary}

+
{zh ? "检查记录与版本" : "Check record and versions"} + {!contract.verification ?

{zh ? "尚无检查记录。" : "No check record yet."}

: <> +

{copy.receiptNote}

+
+
{copy.operation}
{contract.verification.operation_id}
+
{copy.revision}
{contract.verification.contract_revision}
+
{copy.digest}
{contract.verification.contract_digest}
+
{copy.verificationScope}
{contract.verification.todo_id ?? copy.allCriteria}
+
+ {contract.verification.results.map(result =>
+

{contract.criteria.find(item => item.id === result.criterion_id)?.description ?? result.criterion_id} · {result.passed ? copy.passed : copy.failed}

+

{copy.exitCode}: {result.exit_code ?? copy.unknown}

+ {result.oracle_evidence ? : null} +
)} + } +
+
: null} + {readOnly ?

{zh ? "当前来源只读;返回本机后可检查交付。" : "This source is read-only. Return to the local workspace to check."}

: null} + {read.isError || actions.isError || error ?

{error || (zh ? "检查方案或结果暂时不可用。刷新只读取信息。" : "The plan or result is unavailable. Refresh only reads information.")}

+
: null} + {notice ?

{notice}

: null} + {management || enabled ?
{zh ? "高级配置与诊断" : "Advanced configuration and diagnostics"} +

{copy.guidance}

+
+ + + {document ?
{zh ? "待保存配置" : "Configuration to save"} · {filename}
{JSON.stringify(document, null, 2)}
+

{zh ? "保存会更新所列任务的关联,不会运行检查。" : "Saving updates the listed task associations and does not run checks."}

: null} + + +
+
{copy.source}
{goalId}
+ {enabled ? <>
{copy.scope}
{contract.scope?.kind === "selected_work" ? copy.selectedWork : copy.allWork}
+
{copy.revision}
{contract.revision}
{copy.digest}
{contract.digest}
: null} +
+ {enabled ? <>

{contract.non_goals.join(" · ")}

{copy.tasks}

+
    {contract.tasks.map(task =>
  • {task.todo_id} · {copy.taskState[task.state]} +

    {copy.criteria}: {task.criterion_ids.join(", ") || copy.unknown}

    + {task.applicable === false ?

    {copy.notApplicable}

    : null} + {task.reason ?

    {task.reason}

    : null}
  • )}
+ {contract.held_todo_ids.length ?

{copy.heldTasks}: {contract.held_todo_ids.join(", ")}

: null} : null} + {copy.guide} +
: null}
-
; + ; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx new file mode 100644 index 0000000000..c839175b64 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx @@ -0,0 +1,15 @@ +import type {OracleEvidence} from "../../data/goal-acceptance-observation"; +export function GoalOracleEvidence({evidence, zh}: {evidence: OracleEvidence; zh: boolean}) { + return
+

{zh ? "负责人指定的版本固定检查器" : "Owner-pinned checker"} · {evidence.source_id}

+

+

{zh ? "检查在声明文件的隔离副本上执行;不代表远程复核者身份或异议已解决。" : "Checks ran on copies of the declared files. This does not authenticate a remote reviewer or resolve an objection."}

+
{zh ? "查看来源与实际检查版本" : "Source and tested versions"} +

{zh ? "来源摘要" : "Source digest"} · {evidence.source_digest}

+ {(["input", "output"] as const).map(role =>
+

{role === "input" ? (zh ? "输入版本" : "Input versions") : (zh ? "输出版本" : "Output versions")}

+
    {evidence[`${role}_versions`].map(row =>
  • {row.ref} · {row.sha256}
  • )}
+
)} +
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx index 8f4724555c..85a8603f4f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -9,7 +9,7 @@ import type {GoalWorkspaceDigest} from "../../../../../../loopx/control_plane/pr import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; import type { WorkspaceActionPreview, WorkspaceActionPreviewRequest, WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceGoalTab, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model"; import { useWorkspaceI18n } from "./i18n"; -import {projectGoalAcceptanceCriteria} from "../../data/goal-acceptance-observation"; +import {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} from "../../data/goal-acceptance-observation"; import "./goal-overview.css"; export const goalOverviewAttentionId = "goal-overview-attention"; @@ -62,7 +62,7 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD

{contract ? copy.direction : copy.current}

- {contract ? {acceptanceCopy.verificationState[contract.status]} : null} + {contract ? {acceptanceCopy.verificationState[goalAcceptanceDisplayStatus(contract)]} : null}
  • 03 · {zh ? "修订产物 · 当前验收有效" : "Revised output · currently accepted"}{result.agent_id} {zh ? "与原始版本的正文对照见下方" : "Compare with the original below"} + {result.validation?.oracle_checks?.map(check => + )} {zh ? "独立验收 · 证据未提供" : "Independent verification · evidence not provided"} {zh ? "当前读回未提供独立验收者与指定版本回执。" : "This readback does not provide an independent verifier and an exact-version receipt."}
  • 04 · {episode.downstream ? (zh ? "后续结果 · 当前验收与采用记录有效" : "Downstream result · acceptance and adoption current") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx index 3e62d9ff04..aa85b7f6ff 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -1,3 +1,4 @@ +import {GoalOracleEvidence} from "./goal-oracle-evidence"; import {GoalTeamLineage} from "./goal-team-lineage"; import {GoalTeamEpisode} from "./goal-team-episode"; import {GoalTeamComparison} from "./goal-team-comparison"; @@ -111,12 +112,12 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres } return

    {zh ? "执行证据" : "Execution evidence"}

    -
    - {busy ?

    {zh ? "正在核验绑定、验收与文件…" : "Checking bindings, acceptance and files…"}

    : null} + + {busy ?

    {zh ? "正在运行已配置的检查并核对产物版本…" : "Running configured checks and observing artifact versions…"}

    : null} {error ?

    {zh ? "无法核验,已清除上次证据。" : "Cannot verify; previous evidence cleared."} {error}

    : null} {result ? <>

    {result.agent_id} · {delegationStateLabel(result, zh)}{" · "}{observedAt}

    -

    {zh ? "按需读取的当前观察,不是持续在线状态;验收不代表协调员已采用。" : "An on-demand observation, not continuous liveness; acceptance does not establish coordinator adoption."}

    +

    {zh ? "本次观察会运行已配置的检查,不代表持续在线;验收不代表协调员已采用。" : "This observation runs the configured checks, not continuous liveness; acceptance does not establish coordinator adoption."}

    {hasCorrectionPath ?
    {zh ? "版本与采用关系详情" : "Version and adoption details"} @@ -148,6 +149,7 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres

    {zh ? "检查前后保持一致的产物版本" : "Output versions unchanged across these checks"}

    {checkedOutputs!.map(row =>

    {row.ref}{" · "}{row.sha256}

    )} :

    {zh ? "未提供与当前产物匹配的检查时间及版本记录。" : "Check time and version records matching this report are unavailable."}

    } + {result.validation.oracle_checks?.map(check => )} {result.validation.basis_sha256} :

    {zh ? "此运行时未提供验收依据标识。" : "This runtime did not provide the validation basis identity."}

    }
    : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index eddda1027d..8e9abc4017 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1357,6 +1357,7 @@ const en = { "proposal.kind.goal.create": "New Goal", "proposal.kind.goal.update": "Goal update", "proposal.kind.goal.lifecycle": "Goal lifecycle", + "proposal.kind.goal.acceptance.check": "Delivery check", "proposal.kind.todo.create": "New Task", "proposal.kind.todo.update": "Task update", "proposal.kind.agent.bind": "Agent binding", @@ -2841,6 +2842,7 @@ const zhCN: Record = { "proposal.kind.goal.create": "新 Goal", "proposal.kind.goal.update": "更新 Goal", "proposal.kind.goal.lifecycle": "Goal 启停", + "proposal.kind.goal.acceptance.check": "交付检查", "proposal.kind.todo.create": "新任务", "proposal.kind.todo.update": "更新任务", "proposal.kind.agent.bind": "绑定 Agent", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 3611611929..e110e29c3a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -321,6 +321,7 @@ export type WorkspaceActionPreview = { | "goal.create" | "goal.update" | "goal.lifecycle" + | "goal.acceptance.check" | "todo.create" | "todo.update" | "agent.bind" diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 85df233936..b5eadf9896 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1296,6 +1296,8 @@ export function PersonalWorkspacePage({ const stored = actionReadback.data.filter(proposal => selectedGoalId || proposal.context.kind === "manager" || (proposal.action_kind === "operation.execute" && knownGoals.has(String(proposal.normalized_parameters.goal_id)))); const restoreable = stored + // Acceptance plans are reviewed beside the delivery they check. + .filter((proposal) => proposal.action_kind !== "goal.acceptance.check") .filter((proposal) => ["preview_ready", "gated", "deferred", "applying"].includes(proposal.status) || compileActionReviewPlan(proposal).retryOriginal === true || (proposal.action_kind === "team.plan" && proposal.status === "applied") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index a166d462b1..7886491a2a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -1,3 +1,5 @@ +import {GoalAcceptanceContractSection} from "./goal-acceptance-contract"; +import {goalAcceptanceContractCopy} from "./delivery-review-copy"; import { useEffect, useRef, useState } from "react"; import { ArrowLeft, Bot, Check, Clock3, KeyRound, Languages, Palette, ServerCog, Settings2 } from "lucide-react"; @@ -233,6 +235,7 @@ export function WorkspaceSettingsPage({ }} /> + : null} {tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ? : null} {tab === "cadence" && selectedGoal ? : null} diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md index 5d65447e80..e2289ac128 100644 --- a/docs/architecture/rfcs/live-team-workspace-v0.md +++ b/docs/architecture/rfcs/live-team-workspace-v0.md @@ -401,6 +401,33 @@ Implementation checkpoint ([#5587](https://github.com/loopx-project/loopx/pull/5 This remains a presentation/readback slice. `responds_to` does not certify an objection. Current native acceptance validates the canonical task and configured validators, but its readback does not expose an independent verifier identity and exact-version receipt. The revision therefore shows that missing evidence explicitly, even beside a valid requester adoption. The evidence reader separately exposes the current validation source, definition digest and check/file-pin counts from the existing typed validation plan. CLI and HTTP readback carry the same path-free observation after the original checks pass; failed validation or changed pins withdraw it with the report. This rule identity is not a persisted success or independent-verifier receipt. The frontend must consume such evidence from the existing acceptance owner when available; it cannot infer it from the reviewer name, relationship or artifact hash. +The acceptance owner also supports an explicit optional owner-pinned oracle: +the trusted local host executes its pinned source against copies of declared +inputs/outputs and records their exact versions. Current Team evidence consumes +only matching source/contract/work and output observations; stale or absent +receipts remain unavailable. Existing Goal settings and acceptance details offer +real save/check/readback/disable operations. This source identity is an +owner-approved checker, not an authenticated remote verifier; snapshots do not +attest the interpreter or unpinned imports. Ordinary validators retain their +previous behavior. This closes the bounded configured-source/version producer +and local operator-journey gap; it does not qualify independent authorship, +semantic resolution, the real correction/adoption episode or full L1. See the +[operation and rollback guide](../../reference/goal-acceptance-observations.md#owner-pinned-oracle-and-exact-versions). + +The proposed local journey now lets a registered Agent prepare that exact check +plan through the existing typed-action store. Preparation changes neither the +contract nor the outputs. The owner sees its objective, coverage, outputs and +local command effect beside the delivery, then adopts and checks in one action; +JSON import and revision diagnostics remain optional. This is one additional +owner decision after preparation, not proof that every original request takes +one interaction. Proposal replay and lost responses use existing native receipts; +an uncertain execution never automatically reruns commands. Fresh readback +withdraws historical success when source/output evidence changes, and an applied +new plan does not revive an older draft. File/SQLite execution, isolated +PostgreSQL acceptance ownership and packaged SQLite/HTTP/CLI recovery validate +this author proposal. Scripted Agent preparation does not qualify live model +preparation, installed App adoption, independent authorship or full L1. + The current host-check observation additionally names its UTC completion time and the declared output versions that remained unchanged before and after those checks. A changed output withdraws even the first return; the evidence detail displays matching records and leaves older or mismatched records unavailable. Stability reuses the acceptance owner's task work digest plus current claim/lifecycle and selected rules/pins/workspace: unrelated canonical commits and observation metadata do not revoke a stable check, while changed work, authorization or validators still reject it. This replaces whole-Goal revision comparison only on this read path; mutation CAS remains intact. The delegation TS owner and read-only Chat/CLI projection add no verifier configuration or independent semantic verdict, and do not close Section 9's independent exact-version acceptance gap. Packaged desktop, 390px, keyboard/reduced-motion and pagination checks cover these returns and recovery. An isolated production SQLite/HTTP/CLI fixture also rejects changed output, withdraws its list acceptance and restores the original version without launching additional work. [Public-safe fixture views](../../reference/local-delegation.md#inspect-accepted-evidence-and-return) make these states reviewable. These checks do not establish a real objection, independent semantic acceptance, installed native behavior or L1 completion. The existing real correction episode must still be exercised through the packaged UI and independently read back with Section 9's missing/lost-observation cases. diff --git a/docs/development/design.md b/docs/development/design.md index 02214919a9..de6a453625 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -48,6 +48,23 @@ Judge the whole viewport, including information repeated across components. Before implementation, inventory the visible elements and their value. Keep conversation, results, and decisions prominent; consolidate routine activity; offer one-step access to relevant controls and details without a chain of pages. +Start from the owner's original task and minimize the whole job they must do. +Agent preparation should absorb discovery, identifiers, formatting and execution +detail within existing authority. The owner supplies missing intent and real +decisions. Compare a smaller lawful route before justifying individual controls: +a sequence of locally useful fields can still assign the wrong work to the user. +Reuse prior scoped authorization; explain any remaining command effect at its +decision point. Advanced configuration must remain optional for an ordinary task +when an Agent can prepare the same reviewable plan. +Count mandatory interaction rounds from the original intent to a usable result, +including preparation outside the visible page. One round is one user statement, +choice or confirmation required to continue; merging buttons does not merge +decisions. Most ordinary capabilities should complete in one round. With intent +and scoped authorization already present, aim for zero additional rounds. +Two or more rounds need a concrete missing fact, consequential choice or authority +boundary for each extra round. High value may justify deeper collaboration, but +does not itself justify mandatory friction. Keep voluntary exploration separate, +and record navigation, copying, waiting and recovery effort alongside round count. Do not collapse failures, missing authority, uncertain observations, or actions requiring judgment into a reassuring success summary. Read models select facts; they must not invent execution, acceptance, or completion from prose or counts. diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index c15c03f92d..1ec466480a 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -32,11 +32,25 @@ frontend-only decisions or a setting for a capability with no persistent policy. Carry the same task through these steps: 1. **Compare the journey.** Write the current and proposed user steps in the - existing before/after evidence. Count required navigation, information the - user must repeat, questions, confirmations and recovery effort. Explain the - information, authority or unavoidable prerequisite supplied by each required - step; remove a step that supplies none. This is a comparison, not a universal - click limit. Keep necessary scoped consent and explicit effect confirmation; + existing before/after evidence, anchored to the original request rather than + a configuration task invented from the proposed implementation. Compare + no change and a smaller lawful route, including preparation the Agent can + already perform. Separate missing intent or owner-only authorization from + lookup, identifiers, formatting and other preparation. Count total navigation, + repeated information, questions, confirmations and recovery effort. Justifying + each control individually cannot establish that this whole human workflow is + necessary. Default to one required interaction from the original intent to a usable + result, or zero additional interactions when the intent and authority already + exist. Account for every required statement, choice or confirmation, including + preparation outside the UI; one final button does not make a multi-round task + one-round. Justify each additional round by a missing fact, consequential + choice or authority boundary, not feature value alone. Record navigation, + copying, waiting and recovery separately; voluntary refinement does not raise + the ordinary-path budget. These are design defaults, not a click-count gate. + Remove work the Agent can prepare under existing authority and + reuse prior scoped authorization. This is a comparison, not a universal click + limit or a ban on legitimate technical configuration. Keep necessary scoped + consent and explicit effect confirmation; make optional refinement optional. A complete conversational draft should not require a second form just to repeat the same facts. 2. **Choose the owner and composition.** Reuse the existing typed state and @@ -61,7 +75,9 @@ Carry the same task through these steps: browser, installed App and real model each prove different things. 5. **Review the task before the diff narrative.** The review capability's `problem_context.outcome_impact.user_experience` references the same journey - and validation, then asks what remains wrong even if every test passes. + and validation. Independently check that the author's task matches the original + request and challenge the full human workload against the smaller lawful + route, then ask what remains wrong even if every test passes. Reused evidence needs its revision and unchanged assumptions. A material unsupported claim remains unproven. A necessary confirmation can be accepted with its authority basis; green CI cannot justify redundant confirmation. diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index e0c9d83730..90f5329794 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -81,9 +81,49 @@ promote a provider or fall back to legacy Markdown when canonical reads fail. The owner makes three explicit decisions: the objective and its acceptance criteria, which existing advancement tasks serve those criteria, and whether to enable this governance. The existing Goal authority owns the contract; the -Dashboard only reads it. No new capability editor or provider is introduced. +Dashboard presents its checked result and uses the same owner for authorized changes. No new capability editor or provider is introduced. -From the Goal's delivery workspace, inspect the current provider revision: +For an ordinary delivery, the Agent prepares the technical plan from the stated +outcome and existing task/file context. The local owner sees its objective, +criteria, coverage and command effect beside the delivery, then uses **Use plan +and check** once. A configured contract needs only **Check delivery**. The +advanced JSON import remains available for technical migration; it is not a +prerequisite for this path. + +Agent preparation uses the existing typed action preview store: + +```bash +loopx --format json goal-acceptance prepare --goal-id example-goal \ + --agent-id example-agent --document acceptance.json \ + --operation-id prepare-delivery-check --execute +``` + +Here `--execute` persists only the reviewable proposal. Omit it for a read-only +validation preview. Neither form configures the Goal, approves governance or runs +commands. The local owner's action applies the described configuration and +executes its bounded checks; this is the existing trusted local owner surface, +not an authenticated remote owner decision. Use runtime command names and safe +relative file references in proposals, rather than machine-specific paths. +The original operation identity and native receipts recover a lost response. +Concurrent or repeated apply does not rerun a consumed intent; if a started check +has no matching native receipt, the result stays uncertain until explicitly +reconciled. Readback never runs checks. A stored successful receipt cannot +certify changed files, independent reviewer identity, adoption or Goal completion. +A later finished/cancelled plan does not resurrect an older draft. + +Count the complete task: with an Agent-prepared plan already delivered, the owner +has one required decision. If the original request still lacks acceptance intent +or authority, that missing decision is an additional round; preparing a proposal +alone does not establish a one-round cold-start or live-model experience. +No-plan/default-off behavior remains unchanged, and no Lark action is introduced. + +中文:普通路径由 Agent 根据目标与既有上下文准备方案,负责人在交付处核对要求、覆盖范围和 +执行影响,一次“采用并检查”;已有配置直接“检查交付”。JSON 导入仅保留为高级迁移入口。 +`prepare --execute` 只保存可审阅方案,不修改 Goal 规则、不执行检查。丢失回执先对账, +不能自动重跑;历史通过不能认证变化后的版本。交互次数从原始意图算起,前置缺失的意图或 +授权不能从计数中省略。此入口沿用可信本机所有者边界,不证明远程身份、独立复核或实际采用。 + +For advanced CLI configuration, inspect the current provider revision: ```bash loopx --format json goal-acceptance inspect --goal-id example-goal @@ -126,8 +166,8 @@ owner-approved scope change. Unselected work retains its ordinary validation, claims, leases, permissions and continuation checks. Selected work cannot edit role/class/status to escape its contract. Changes to unrelated work do not stale a selected-work verification; -changes to selected work do. The dashboard's existing read-only acceptance detail, -Markdown export and CLI expose coverage without granting new configuration power. +changes to selected work do. The dashboard's acceptance detail, Markdown export and CLI expose coverage. +Configuration continues through the same owner-authorized CAS transition. No Lark configuration operation is introduced. **恢复与范围 / Recovery and coverage.** Missing/stale associations trigger scoped @@ -450,3 +490,117 @@ TS 校验整个候选工作声明的摘要必须等于 owner 当初确认的摘 必须提供真实的原文本/等待条件。若不知道原声明、改动涉及其他字段,或后续兼容修订 使完整摘要无法精确匹配,应请 owner 审核并显式重新绑定。不能猜测旧值、伪造完成, 也不能先取得 stale 工作的租约来绕过这条边界。 + + +## Owner-pinned oracle and exact versions + +In **Goal settings → Task ownership → Goal acceptance contract**, the local owner +can import a complete approved JSON document, review its scope and command, +save it, run the configured checks, read back the result, or disable the contract. +Existing configured contracts offer the same operations in **Delivery & evidence**. +Saving does not run a command. Reading does not run checks. Failed or uncertain +operations retain a readback/retry path; an uncertain check is never replayed +automatically. These actions use the existing acceptance owner and provider CAS. +Agent callers still cannot configure the owner's contract or submit a verdict. + +An optional criterion `oracle` declares an **owner-pinned checker**, rather than +inferring independent acceptance from a worker's name, report or artifact hash: + +```json +{ + "kind": "owner_pinned_oracle", + "source_id": "export-checker", + "input_refs": ["expected.txt"], + "output_refs": ["result.txt"] +} +``` + +Add this object to a criterion in the existing document format. That criterion +must explicitly pin the invoked checker in `validation_files`, with its actual +SHA-256, and invoke that file directly or as Python's immediate script argument. +The supported interpreter names are `python`, `python3`, and versioned +`python3.`, optionally ending in `.exe`; absolute host paths to those +interpreters are allowed. Relative interpreter wrappers must themselves be +pinned and directly invoked; they cannot borrow Python's script-argument rule. +For example, use +`validation_argv: ["python3", "checks/export.py"]` with a pin for +`checks/export.py`. Interpreter options before the script (including `-c`, `-m` +and `--`) are unsupported. Arbitrary commands such as `true checks/export.py` +or `cat checks/export.py` cannot attest execution of that pinned source and are +rejected before configuration is written. Script arguments after the file are +allowed. Other runtimes can use a directly executed pinned entry point with a +relative path containing `/` (for example `checks/export`), not a bare name +resolved through `PATH`. This grammar does not authenticate the local interpreter +binary, which remains part of the trusted host environment. +Declare every input/output file whose version the receipt is meant to cover; +there must be at least one output. Source, input and output roles are distinct. +All refs are bounded repository-relative files; symlinks and unavailable files +fail closed. Ordinary criteria without `oracle` retain their previous execution, +canonical document bytes and result shape. + +The host copies the pinned source and declared inputs/outputs into a temporary +workspace, verifies their bytes, executes the approved command there, and checks +those copies and the original workspace again before returning evidence. The +successful criterion result contains `oracle_evidence`: source identity and +digest, UTC check time, and exact input/output SHA-256 versions. A changed or +missing source/output, failed check or timeout does not produce a success +receipt. Completion still has its existing fresh checks and transaction; an +ordinary completion receipt is not substituted for this explicit oracle record. + +This is file-snapshot isolation, **not an OS sandbox**. The local interpreter, +environment, external services and unpinned imports remain trusted host inputs. +The owner must approve the checker's meaning, provenance and any effects before +running it. The source label is not an authenticated remote reviewer's identity; +file hashes do not prove independent authorship. The checker record does not +resolve an objection, establish requester adoption, or complete a Goal. + +`goal-acceptance inspect` observes current files without executing checks. It +shows `current` only when the current contract/work, pinned source and declared +versions match the stored receipt. Changed bytes show `stale`; unavailable files +or missing receipts cannot become current success. Raw canonical/status snapshots +without host file observations show `recorded`, which does not certify current +bytes. Delegation evidence includes the checker only when the acceptance owner +observed it current and its tested outputs cover that execution's exact outputs. +Requester adoption remains a separate record. + +The same local-owner journey is available through the existing CLI: + +```sh +loopx --format json goal-acceptance inspect --goal-id GOAL +loopx --format json goal-acceptance configure --goal-id GOAL --document owner-contract.json --expected-provider-revision REVISION --execute +loopx --format json goal-acceptance verify --goal-id GOAL --execute +loopx --format json goal-acceptance inspect --goal-id GOAL +loopx --format json goal-acceptance disable --goal-id GOAL --expected-provider-revision CURRENT_REVISION --execute +``` + +Use revision tokens from fresh inspect results. Disable withdraws the current +contract and current oracle projection while retaining historical evidence; +removing every `oracle` through an explicit owner reconfiguration restores +ordinary criterion behavior and withdraws the current oracle receipt. The immutable +operation journal retains its historical evidence; ordinary checks must run again. +Before downgrading to a runtime that predates this optional field, use the newer +runtime to configure that ordinary document with a fresh revision, inspect the +result, then optionally disable. Disable alone retains the newer document and is +not a backward-format migration. This enables no worker, model, transport, budget +or permission. +Local HTTP operations share the same owner through `/api/chat/goal-acceptance`; +remote/missing authority and stale revisions remain explicit failures. + +The packaged regression runs Agent preparation and the in-place owner decision +against a disposable real SQLite/HTTP/CLI source, including changed output, +failure/recovery, optional configuration, disable, 390px and keyboard/reduced-motion: + +```sh +uv run --extra test python scripts/chat_bundle.py build --install +LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE="$PWD/apps/presentation/dashboard/node_modules/playwright" uv run --extra test node examples/personal-workspace-browser/goal-acceptance-oracle.mjs +``` + +Workspace discovery in this regression is synthetic; it does not establish +installed-App adoption or the live L1 correction episode. + + +Synthetic packaged views of the actual local-owner interaction (not an installed +App or live team): prepared plan on [desktop](../../examples/personal-workspace-browser/oracle-controls-desktop.png) +and [390px](../../examples/personal-workspace-browser/oracle-controls-mobile.png); +current result on [desktop](../../examples/personal-workspace-browser/oracle-current-desktop.png) +and [390px](../../examples/personal-workspace-browser/oracle-current-mobile.png). diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 532990052f..580e2f4719 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -1377,3 +1377,16 @@ plan/inspect 的 `operation_callback_managed_wake_configured` 仅证明配置, 移除启动配置或原 requester grant 在下一 callback 生效,已开始工作须走原停止/ 对账流程;丢失 ACK 不自动重启。Dashboard 沿用共享 frame 展示状态,但尚不能编辑 此 grant;前端配置与真实原来源结果送达仍为部分交付,不能据合成测试称交易闭环。 + + +### Owner-pinned checker evidence + +Delegation evidence can include the acceptance owner's optional +[owner-pinned oracle record](goal-acceptance-observations.md#owner-pinned-oracle-and-exact-versions). +It names the source, check time and tested input/output versions only when the +current owner contract and files still match and every returned output is covered +at its exact version. Unconfigured, missing, changed or failed evidence stays +unavailable; a current worker check or requester adoption cannot manufacture it. +The Team evidence detail and correction trace show the same CLI/HTTP observation. +This record attests a configured checker on declared snapshots; it does not +identify a remote human/Agent verifier or prove that an objection was resolved. diff --git a/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py b/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py new file mode 100644 index 0000000000..fa7fbc166b --- /dev/null +++ b/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py @@ -0,0 +1,111 @@ +"""Disposable real SQLite acceptance owner for the packaged browser journey.""" + +import hashlib +import json +from pathlib import Path +import sys + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) +from loopx.control_plane.testing.canary_harness import write_fixture_registry + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests" / "control_plane")) +from canonical_authority_fixture import initialize_canonical_authority + +root = Path(sys.argv[1]) +project = root / "project" +project.mkdir() +state = project / "state.md" +state.write_text( + "---\nstatus: active\n---\n# Synthetic acceptance workspace\n## Agent Todo\n" +) +registry, runtime = root / "registry.json", root / "runtime" +write_fixture_registry( + project=project, + registry_path=registry, + runtime_root=runtime, + goal_id="multi-agent-projection", + domain="acceptance", + adapter_kind="generic_project_goal_v0", + state_file=str(state), + registered_agents=["agent-a"], +) +todo = { + "schema_version": "todo_item_v0", + "todo_id": "todo_work", + "role": "agent", + "text": "Produce the expected export", + "status": "open", + "done": False, + "task_class": "advancement_task", + "action_kind": "implement", + "index": 1, + "source_section": "Agent Todo", + "archive_state": "active", + "claimed_by": "agent-a", +} +initialize_canonical_authority( + runtime, + "multi-agent-projection", + build_todo_runtime_shadow_projection( + goal_id="multi-agent-projection", todos=[todo], handoff_mode="soft_claim" + ), + state_path=state, + provider="sqlite", +) +checker = project / "check.py" +checker.write_text( + "from pathlib import Path\nassert Path('result.txt').read_text() == Path('expected.txt').read_text()\n" +) +(project / "expected.txt").write_text("usable export") +(project / "result.txt").write_text("usable export") +document = { + "scope": {"kind": "selected_work", "todo_ids": ["todo_work"]}, + "objective": "Validate the exact export version", + "non_goals": [ + "This synthetic fixture does not run a model or certify live adoption" + ], + "criteria": [ + { + "id": "export", + "description": "The export matches the expected input", + "validation_argv": ["python3", "check.py"], + "validation_timeout_seconds": 5, + "validation_files": [ + { + "path": "check.py", + "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(), + } + ], + "oracle": { + "kind": "owner_pinned_oracle", + "source_id": "export-oracle", + "input_refs": ["expected.txt"], + "output_refs": ["result.txt"], + }, + } + ], + "bindings": [{"todo_id": "todo_work", "criterion_ids": ["export"]}], +} +(root / "owner-contract.json").write_text(json.dumps(document)) +server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) +server.registry_path, server.runtime_root = registry, runtime +server.runtime_root_override, server.verbose = str(runtime), False +server.selected_goal_id, server.scan_roots, server.limit = None, [], 20 +server.goal_subagent_configuration_enabled = False +server.action_store = ChatActionStore(runtime / "chat" / "actions") +server.action_service = ChatActionService(store=server.action_store, registry_path=registry) +print( + json.dumps( + {"port": server.server_port, "registry": str(registry), "runtime": str(runtime)} + ), + flush=True, +) +try: + server.serve_forever() +finally: + server.server_close() diff --git a/examples/personal-workspace-browser/goal-acceptance-oracle.mjs b/examples/personal-workspace-browser/goal-acceptance-oracle.mjs new file mode 100644 index 0000000000..69994e5ea8 --- /dev/null +++ b/examples/personal-workspace-browser/goal-acceptance-oracle.mjs @@ -0,0 +1,119 @@ +// Packaged settings and evidence with a real disposable SQLite/HTTP/CLI owner. +import assert from "node:assert/strict"; +import {spawn, execFileSync} from "node:child_process"; +import {createInterface} from "node:readline"; +import {mkdtemp, mkdir, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../dashboard-browser-smoke-support.mjs"; +import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs"; +import {outputDir, packaged, port, repoRoot, startServer} from "./fixture.mjs"; + +const root = await mkdtemp(resolve(tmpdir(), "loopx-oracle-browser-")); +const child = spawn(resolveTestPython(), ["-u", "examples/personal-workspace-browser/goal-acceptance-oracle-backend.py", root], {cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"]}); +let diagnostic = ""; +child.stderr.on("data", chunk => diagnostic = (diagnostic + chunk).slice(-4000)); +const lines = createInterface({input: child.stdout}); +let browser, server, context; +try { + const authority = await new Promise((accept, reject) => { + const timer = setTimeout(() => reject(Error(`Oracle fixture did not start: ${diagnostic}`)), 20000); + child.once("exit", () => {clearTimeout(timer); reject(Error(`Oracle fixture exited: ${diagnostic}`));}); + lines.once("line", line => {clearTimeout(timer); accept(JSON.parse(line));}); + }); + const backend = `http://127.0.0.1:${authority.port}`; + server = await startServer(); + const url = `http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`; + await waitForHttp(url); + browser = await launchBrowser(loadPlaywright().chromium); + context = await openWorkspacePage(browser, url, {beforeGoto: async (_api, page) => { + await page.addInitScript(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + await page.route("**/api/chat/goal-acceptance**", async route => route.fulfill({response: await fetchWorkspaceBackend(route, backend)})); + await page.route("**/api/actions**", async route => route.fulfill({response: await fetchWorkspaceBackend(route, backend)})); + await page.route("**/api/chat/delivery-review**", async route => route.fulfill({response: await fetchWorkspaceBackend(route, backend)})); + }}); + const {page} = context; + const cli = (...args) => JSON.parse(execFileSync(resolveTestPython(), ["-m", "loopx.cli", "--format", "json", "--registry", authority.registry, "--runtime-root", authority.runtime, + "goal-acceptance", ...args, "--goal-id", "multi-agent-projection"], {cwd: repoRoot, encoding: "utf8"})); + const read = () => cli("inspect"); + assert.equal(read().goal_acceptance_contract.enabled, false); + await page.locator(".personal-goal-link", {hasText: "Multi Agent Projection"}).click(); + await page.getByRole("button", {name: "概览", exact: true}).click(); + // The Agent does the technical preparation. The owner does not enter settings, + // inspect revisions, upload JSON or confirm the same facts in a second form. + cli("prepare", "--agent-id", "agent-a", "--document", resolve(root, "owner-contract.json"), + "--operation-id", "older-export-plan", "--execute"); + const prepared = cli("prepare", "--agent-id", "agent-a", "--document", resolve(root, "owner-contract.json"), + "--operation-id", "prepare-export-check", "--execute"); + assert.equal(prepared.executed, false); + assert.equal(read().goal_acceptance_contract.enabled, false, "preparation is not owner approval"); + const panel = page.locator(".delivery-acceptance-contract"); + const adopt = panel.getByRole("button", {name: "采用并检查", exact: true}); + await adopt.waitFor(); + await adopt.scrollIntoViewIfNeeded(); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "oracle-plan-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await adopt.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "oracle-plan-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 982}); + let applyCount = 0; + page.on("request", req => {if (req.method() === "POST" && /\/api\/actions\/[^/]+\/apply$/.test(new URL(req.url()).pathname)) applyCount++;}); + await adopt.focus(); await page.keyboard.press("Enter"); + await panel.getByText("当前版本已核对", {exact: true}).waitFor(); + assert.equal(applyCount, 1, "one owner action adopts and checks the prepared plan"); + assert.equal(read().goal_acceptance_contract.criteria[0].oracle.state, "current"); + assert.equal(await panel.locator('input[type="file"]').isVisible(), false, "technical import is optional"); + await panel.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "oracle-current-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await page.emulateMedia({reducedMotion: "reduce"}); + await panel.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "oracle-current-mobile.png"), animations: "disabled"}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + const run = panel.getByRole("button", {name: "检查交付", exact: true}); + await writeFile(resolve(root, "project/result.txt"), "new version without verification"); + // Read-only observation invalidates the old green state without rerunning. + await panel.getByText("来源或版本已变化,请重新检查", {exact: true}).waitFor({timeout: 25000}); + assert.equal(await panel.getByText("产物检查通过", {exact: true}).count(), 0); + const oldOperation = read().goal_acceptance_contract.verification.operation_id; + assert.equal(applyCount, 1); + await run.click(); + await panel.getByText("检查失败;修复交付物后可再次检查", {exact: true}).waitFor(); + assert.notEqual(read().goal_acceptance_contract.verification.operation_id, oldOperation); + await writeFile(resolve(root, "project/result.txt"), "usable export"); + await run.click(); await panel.getByText("当前版本已核对", {exact: true}).waitFor(); + // Advanced migration remains available; a rejected configuration cannot erase + // the working contract. Recovery reads, rather than automatically executes. + await panel.getByText("高级配置与诊断", {exact: true}).click(); + const initial = read(); + const invalid = JSON.parse(await readFile(resolve(root, "owner-contract.json"), "utf8")); + invalid.criteria[0].validation_argv = ["/usr/bin/true", "check.py"]; + await writeFile(resolve(root, "invalid-contract.json"), JSON.stringify(invalid)); + await panel.locator('input[type="file"]').setInputFiles(resolve(root, "invalid-contract.json")); + const rejectedSave = page.waitForResponse(response => response.url().includes("/api/chat/goal-acceptance") && response.request().method() === "POST"); + await panel.getByRole("button", {name: "保存配置", exact: true}).click(); + assert.equal((await rejectedSave).status(), 409); + await panel.getByRole("alert").waitFor(); + assert.deepEqual(read(), initial); + await panel.getByRole("alert").getByRole("button", {name: "刷新结果", exact: true}).click(); + await panel.getByRole("button", {name: "停用检查", exact: true}).click(); + await panel.waitFor({state: "hidden"}); + assert.equal(read().goal_acceptance_contract.enabled, false); + const expected = "Failed to load resource: the server responded with a status of 409 (Conflict)"; + const at = context.errors.indexOf(expected); if (at >= 0) context.errors.splice(at, 1); + assert.deepEqual(context.errors.filter(value => !value.includes("404")), []); + console.log(JSON.stringify({status: "PASS", boundary: "Packaged UI with actual disposable SQLite/HTTP/CLI; Agent preparation is scripted, not live model or installed App evidence", checks: ["automatic prepared-plan discovery", "one contextual owner action", "no mandatory JSON/revision form", "exact native readback", "changed output clears green without execution", "failure/recovery", "advanced import rejection", "disable/default-off", "390px", "keyboard/reduced-motion"]})); +} catch (error) { + if (context?.page) { + await mkdir(outputDir, {recursive: true}); + await context.page.screenshot({path: resolve(outputDir, "oracle-failure.png"), animations: "disabled"}); + console.error(JSON.stringify({errors: context.errors, body: (await context.page.locator("body").innerText()).slice(-6000)})); + } + throw error; +} finally { + await context?.close(); await browser?.close(); server?.kill("SIGTERM"); + lines.close(); child.kill("SIGTERM"); + await rm(root, {recursive: true, force: true}); +} diff --git a/examples/personal-workspace-browser/oracle-controls-desktop.png b/examples/personal-workspace-browser/oracle-controls-desktop.png new file mode 100644 index 0000000000..77728f841a Binary files /dev/null and b/examples/personal-workspace-browser/oracle-controls-desktop.png differ diff --git a/examples/personal-workspace-browser/oracle-controls-mobile.png b/examples/personal-workspace-browser/oracle-controls-mobile.png new file mode 100644 index 0000000000..f036bc0982 Binary files /dev/null and b/examples/personal-workspace-browser/oracle-controls-mobile.png differ diff --git a/examples/personal-workspace-browser/oracle-current-desktop.png b/examples/personal-workspace-browser/oracle-current-desktop.png new file mode 100644 index 0000000000..5fe584601e Binary files /dev/null and b/examples/personal-workspace-browser/oracle-current-desktop.png differ diff --git a/examples/personal-workspace-browser/oracle-current-mobile.png b/examples/personal-workspace-browser/oracle-current-mobile.png new file mode 100644 index 0000000000..24764acede Binary files /dev/null and b/examples/personal-workspace-browser/oracle-current-mobile.png differ diff --git a/examples/personal-workspace-browser/showcase-workspace.mjs b/examples/personal-workspace-browser/showcase-workspace.mjs index 2a8ebbfa8d..47f64a0866 100644 --- a/examples/personal-workspace-browser/showcase-workspace.mjs +++ b/examples/personal-workspace-browser/showcase-workspace.mjs @@ -95,6 +95,13 @@ function deliverySnapshot() { /** Installs the showcase status, usage and delivery reads. `state` stays live: change it, then refresh. */ export async function installShowcaseWorkspace(api, page, state) { + // The check panel refreshes its owning read independently of the map. This + // visual fixture supplies that same synthetic contract; real execution is + // qualified by goal-acceptance-oracle.mjs against SQLite/HTTP/CLI. + await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({ + contentType: "application/json", json: {ok: true, goal_id: goalId, + provider_revision: "showcase:3", goal_acceptance_contract: acceptance.goal_acceptance_contract}, + })); await page.route("**/api/chat/delivery-review?*", (route) => state.deliveryFails ? route.fulfill({ status: 503, contentType: "application/json", json: { ok: false, error: "temporarily unavailable" } }) : route.fulfill({ contentType: "application/json", json: deliverySnapshot() })); diff --git a/examples/personal-workspace-browser/team-evidence.mjs b/examples/personal-workspace-browser/team-evidence.mjs index 509f83d925..c6a2646daf 100644 --- a/examples/personal-workspace-browser/team-evidence.mjs +++ b/examples/personal-workspace-browser/team-evidence.mjs @@ -158,7 +158,7 @@ export const teamEvidenceScenario = { await evidence.getByText(/验收不代表协调员已采用/).waitFor(); await evidence.getByText("尚无请求方采用记录。", {exact: true}).waitFor(); mode.fixtureAdoptionState = "current"; - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await evidence.getByText("已记录采用 · 后续结果验收有效", {exact: true}).waitFor(); await evidence.getByRole("button", {name: "查看后续结果", exact: true}).click(); await evidence.getByLabel("证据内容: synthesis.json").waitFor(); @@ -204,20 +204,20 @@ export const teamEvidenceScenario = { await evidence.getByRole("button", {name: "accepted-analysis", exact: true}).first().click(); await content.waitFor(); mode.fixtureAdoptionState = "unavailable"; - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await evidence.getByText("采用证据已失效或无法核验", {exact: true}).waitFor(); assert.equal(await evidence.getByText("已记录采用 · 后续结果验收有效", {exact: true}).count(), 0); mode.fixtureCorrectionEpisode = true; - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); const episode = evidence.getByRole("region", {name: "纠偏证据路径"}); await episode.getByRole("button", {name: "核验关联执行", exact: true}).click(); await episode.getByText(/采用证据无法核验/).waitFor(); delete mode.fixtureAdoptionState; - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await episode.getByRole("button", {name: "核验关联执行", exact: true}).click(); await episode.getByText(/尚无请求方采用/).waitFor(); mode.fixtureAdoptionState = "current"; - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); assert.equal(await episode.getByText("independent-reviewer").count(), 0, "The path is not inferred before a deliberate read"); await episode.getByRole("button", {name: "核验关联执行", exact: true}).click(); await episode.getByText("independent-reviewer", {exact: true}).waitFor(); @@ -243,7 +243,7 @@ export const teamEvidenceScenario = { assert.equal(await evidence.getByText("已记录采用 · 后续结果验收有效", {exact: true}).count(), 0, "Rejected core evidence cannot retain accepted adoption details"); await page.unroute("**/api/chat/sessions/*/loopx", rejectedSource); - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await episode.getByRole("button", {name: "核验关联执行", exact: true}).click(); await episode.getByText("independent-reviewer", {exact: true}).waitFor(); let revisionRechecks = 0; @@ -265,14 +265,14 @@ export const teamEvidenceScenario = { assert.equal(await content.count(), 0, "A changed revision withdraws the earlier report"); assert.equal(await evidence.getByText("newer revision").count(), 0); await page.unroute("**/api/chat/sessions/*/loopx", changedRevision); - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await content.waitFor(); assert.equal(api.turnRequests.length, 0, "Evidence reading must not start a model"); await page.screenshot({path: resolve(outputDir, "team-evidence-desktop.png"), animations: "disabled"}); // Reading a consumer is not adoption. Only the explicit owner decision // writes; a lost post-commit acknowledgement reconciles without rewriting. delete mode.fixtureAdoptionState; - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await content.waitFor(); await evidence.locator(".goal-team-lineage-detail > summary").click(); const adoption = evidence.locator(".goal-team-adoption"); @@ -384,7 +384,7 @@ export const teamEvidenceScenario = { } return route.fallback(); }); - await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByRole("button", {name: "重新检查交付", exact: true}).click(); await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor(); assert.equal(await content.count(), 0); assert.equal(await episode.count(), 0, "Lost observation removes the correction path"); diff --git a/loopx/capabilities/pr_review_queue/review_contract.py b/loopx/capabilities/pr_review_queue/review_contract.py index dcc18b4edc..9b0646839a 100644 --- a/loopx/capabilities/pr_review_queue/review_contract.py +++ b/loopx/capabilities/pr_review_queue/review_contract.py @@ -152,12 +152,24 @@ "For user_experience, compare the real affected CLI, UI or messaging journey: setup and " "repeated intervention, truthful state/readback, actionable failure, correction/cancel and " "recovery. Inspect existing companion surfaces; a backend success is not a usable journey. " - "For an affected user journey, establish the ordinary task and expected result before " - "reading the implementation path. In existing before_after and walkthrough evidence, " + "For an affected user journey, derive the ordinary task and expected result from the " + "original request or accepted product contract, independently of the author's proposed " + "workflow. Do not substitute configuring the feature for the user's actual task. " + "Challenge whether the entire human job is necessary: compare no change and a smaller " + "lawful alternative, including work an Agent can prepare with existing context and authority, " + "before justifying individual controls. In existing before_after and walkthrough evidence, " "compare required navigation, re-entry of known information, clarification, confirmation " "and recovery steps with the shortest valid path through the existing authority boundary. " - "Explain what new information or authority each mandatory step supplies; a form that " - "works can still be redundant. Optional editing is not a mandatory decision. Do not " + "Account for total human preparation, not only visible clicks. Reuse known information " + "and prior scoped authorization; explain only genuinely missing intent or authority. " + "Compare required interaction rounds from original intent to usable result: one round " + "is the ordinary design default, zero additional rounds when intent and authorization " + "already exist. Count required statements, choices and confirmations, including off-screen " + "preparation. Each extra round needs a missing fact, consequential decision or authority " + "boundary; high feature value alone is insufficient. Account separately for navigation, " + "copying, waiting, voluntary exploration and recovery; this is not a universal click gate. " + "A local rationale for every control does not establish that the whole workflow is needed. " + "Optional technical editing remains valid when it serves an actual user task. Do not " "remove necessary scoped consent or correctness checks to minimize clicks. " "For visible changes inspect the rendered whole viewport with representative populated " "data, not only component presence: target/owner, hierarchy, truthful freshness/order " diff --git a/loopx/chat_action_normalization.py b/loopx/chat_action_normalization.py index a6acab1b6d..9a99646087 100644 --- a/loopx/chat_action_normalization.py +++ b/loopx/chat_action_normalization.py @@ -19,6 +19,15 @@ class ChatActionNormalizationMixin: """Normalize typed action inputs without owning effects or persistence.""" + def _goal(self, goal_id: str) -> dict[str, Any]: + raise NotImplementedError + + @staticmethod + def _allowed_parameters( + parameters: Mapping[str, Any], *, allowed: set[str], + ) -> dict[str, Any]: + raise NotImplementedError + def _normalize( self, action_kind: str, parameters: Mapping[str, Any] ) -> dict[str, Any]: @@ -32,6 +41,18 @@ def _normalize( _text, ) + if action_kind == "goal.acceptance.check": + values = self._allowed_parameters(parameters, allowed={"goal_id", "document", "prepared_by"}) + goal_id = _opaque(values.get("goal_id"), field="goal_id") + goal = self._goal(goal_id) + document = values.get("document") + if not isinstance(document, Mapping): + raise ValueError("a prepared check requires an acceptance document") + prepared_by = values.get("prepared_by") + if prepared_by is not None: + if prepared_by not in registered_agent_ids_for_goal(goal): + raise ValueError("the preparing Agent must be registered for this Goal") + return {"goal_id": goal_id, "document": dict(document), "prepared_by": prepared_by} if action_kind == "operation.execute": values = self._allowed_parameters( parameters, diff --git a/loopx/chat_action_store.py b/loopx/chat_action_store.py index bf2a7f98af..86694ea885 100644 --- a/loopx/chat_action_store.py +++ b/loopx/chat_action_store.py @@ -21,6 +21,7 @@ CHAT_ACTION_PROPOSAL_SCHEMA_VERSION = "loopx_chat_action_proposal_v1" ACTION_KINDS = { + "goal.acceptance.check", "goal.create", "goal.update", "goal.lifecycle", diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 455a27e2cd..6b97a3bf34 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -18,6 +18,7 @@ from .chat_monitor_actions import ChatMonitorActionMixin from .chat_store import ChatSessionStore from .chat_todo_actions import ChatTodoActionMixin +from .control_plane.goals.acceptance_actions import GoalAcceptanceActionMixin from .configure_goal import configure_goal from .control_plane.goals.configure_goal_service import ( bind_goal_agent_with_global_sync, @@ -39,6 +40,7 @@ CHAT_ACTION_RESPONSE_SCHEMA_VERSION = "loopx_chat_action_response_v1" SUPPORTED_ACTION_KINDS = { + "goal.acceptance.check", "todo.create", "todo.update", "run.correct", @@ -214,6 +216,7 @@ def _monitor_text(parameters: Mapping[str, Any]) -> str | None: class ChatActionService( + GoalAcceptanceActionMixin, ChatActionNormalizationMixin, ChatGoalLifecycleActionMixin, ChatMonitorActionMixin, @@ -1194,7 +1197,13 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: normalized["workspace_ref"] = workspace_binding["workspace_ref"] else: eligibility = None - if action_kind == "operation.execute": + if action_kind == "goal.acceptance.check": + if context.get("kind") != "goal" or context.get("goal_id") != normalized["goal_id"]: + raise ValueError("check plan context must match its target Goal") + fingerprint, _ = self._acceptance_preview(normalized) + evidence = ["The existing typed acceptance owner validated this plan without changing configuration or executing commands."] + permission = "local_command_execution" + elif action_kind == "operation.execute": fingerprint = _digest(normalized) evidence = [ "The provider-neutral operation envelope and immutable digests validated.", @@ -1449,6 +1458,8 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No parameters = proposal.get("normalized_parameters") if not isinstance(parameters, dict): raise ValueError("typed Chat action proposal is malformed") + if action_kind == "goal.acceptance.check": + return self._apply_acceptance_check(proposal_id) if action_kind == "goal.create": return self._apply_goal_create(proposal_id, proposal, parameters) if action_kind == "goal.lifecycle": diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 42ea426056..488818eb06 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1,5 +1,7 @@ from __future__ import annotations +from .control_plane.goals.acceptance_http import GoalAcceptanceRequestMixin + import json import mimetypes import re @@ -481,6 +483,7 @@ def server_close(self) -> None: class ChatRequestHandler( + GoalAcceptanceRequestMixin, ZCodeGoalRequestMixin, PrivateConversationRequestMixin, CompletedTodoRequestMixin, @@ -1533,6 +1536,7 @@ def do_GET(self) -> None: **self._configuration_get_routes(), DEFAULT_CHAT_STATUS_PATH: self._status, "/api/chat/delivery-review": self._delivery_review, + "/api/chat/goal-acceptance": self._goal_acceptance_inspect, SSH_HOST_CATALOG_PATH: self._ssh_hosts, } if path in get_dispatch: @@ -1573,6 +1577,7 @@ def do_POST(self) -> None: CHAT_ATTACH_SESSION_PATH: self._attach_session, CHAT_PROJECTION_MESSAGES_PATH: self._record_projection_exchange, CHAT_ACTION_PREVIEW_PATH: self._action_preview, + "/api/chat/goal-acceptance": self._goal_acceptance_update, CHAT_TODO_DRY_RUN_PATH: lambda: self._todo(apply=False), CHAT_TODO_APPLY_PATH: lambda: self._todo(apply=True), CHAT_GOAL_CHANNEL_SETUP_PATH: self._goal_channel_setup, diff --git a/loopx/cli_commands/goal_acceptance.py b/loopx/cli_commands/goal_acceptance.py index 6212f783d7..130e0e92f5 100644 --- a/loopx/cli_commands/goal_acceptance.py +++ b/loopx/cli_commands/goal_acceptance.py @@ -21,16 +21,16 @@ def register_goal_acceptance_command(subparsers: Any, add_format: Any) -> None: help="Configure, inspect or verify a versioned Goal acceptance basis.", ) add_format(parser) - parser.add_argument("action", choices=("inspect", "configure", "verify", "disable")) + parser.add_argument("action", choices=("inspect", "prepare", "configure", "verify", "disable")) parser.add_argument("--goal-id", required=True) parser.add_argument( "--agent-id", - help="Registered caller; Agent callers may inspect/verify but cannot change the owner's contract.", + help="Registered caller; Agents may inspect, prepare or verify, but preparation never approves or executes a plan.", ) parser.add_argument( "--document", type=Path, - help="Owner-approved JSON with explicit scope: selected_work plus todo_ids, or all_advancement. Used only by configure.", + help="Prepared check plan with explicit scope; prepare validates it without approval/execution, configure requires the owner.", ) parser.add_argument( "--expected-provider-revision", @@ -42,7 +42,7 @@ def register_goal_acceptance_command(subparsers: Any, add_format: Any) -> None: parser.add_argument( "--execute", action="store_true", - help="Apply configuration or execute validation; otherwise preview only.", + help="With prepare, save a reviewable plan only; with configure/disable/verify, apply the named operation. Otherwise preview only.", ) @@ -89,7 +89,18 @@ def handle_goal_acceptance_command( "runtime_root": runtime_root_arg, "agent_id": args.agent_id, } - if args.action in {"configure", "disable"}: + if args.action == "prepare": + from ..control_plane.goals.acceptance_actions import prepare_acceptance_check + if args.document is None or not args.operation_id: + raise ValueError("prepare requires --document and a stable --operation-id") + if args.expected_provider_revision: + raise ValueError("prepare reads the current basis itself") + document = json.loads(args.document.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("acceptance document must be a JSON object") + payload = prepare_acceptance_check(**common, document=document, + operation_id=args.operation_id, execute=args.execute) + elif args.action in {"configure", "disable"}: if not args.expected_provider_revision: raise ValueError( "configure/disable requires --expected-provider-revision from inspect" diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 7fcc06aa2e..0c0c5d5a34 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -62,7 +62,8 @@ export function delegationValidationPlan(params: JsonObject): JsonObject { basis_sha256: canonicalAuthoritySha256({todo_id: todo.todo_id, requirements, effects}), check_count: effects.length, pinned_file_count: effects.reduce((count, effect) => count - + (Array.isArray(effect.validation_files) ? effect.validation_files.length : 0), 0)}; + + (Array.isArray(effect.validation_files) ? effect.validation_files.length : 0), 0), + ...currentOracleChecks(basis, effects)}; // Reuse the acceptance owner's work classification. Scheduling/progress // metadata and unrelated provider commits are not a new task declaration; // current claim/lifecycle still fence this particular validation attempt. @@ -73,6 +74,20 @@ export function delegationValidationPlan(params: JsonObject): JsonObject { effects, canonical_done: todo.done === true && todo.status === "done"}; } +/** Consume only the acceptance owner's current exact-source observation. */ +function currentOracleChecks(basis: JsonObject, effects: JsonObject[]): JsonObject { + const contract = basis.goal_acceptance_contract as JsonObject | undefined; + if (!contract || !Array.isArray(contract.criteria) || !contract.criteria.some(raw => (raw as JsonObject).oracle)) return {}; + const receipt = contract.verification as JsonObject | null; + const checks = (contract.criteria as JsonObject[]).flatMap(criterion => { + const oracle = criterion.oracle as JsonObject | undefined; + const result = (receipt?.results as JsonObject[] | undefined)?.find(row => row.criterion_id === criterion.id); + return oracle?.state === "current" && effects.some(effect => effect.criterion_id === criterion.id) + && result?.oracle_evidence ? [{criterion_id: criterion.id, ...result.oracle_evidence as JsonObject}] : []; + }); + return {oracle_checks: checks}; +} + /** Fresh host check observation, not an independent-verifier or persisted receipt. * Host IO brackets the actual checks; only stable declared output versions return. */ export function delegationCheckedArtifacts(params: JsonObject): JsonObject { @@ -113,7 +128,12 @@ export function delegationCheckedArtifacts(params: JsonObject): JsonObject { "delegation output changed during validation; recheck the original output"); return {source: observation.source, basis_sha256: observation.basis_sha256, check_count: observation.check_count, pinned_file_count: observation.pinned_file_count, - checked_at: params.checked_at, output_versions: after}; + checked_at: params.checked_at, output_versions: after, + ...(Array.isArray(observation.oracle_checks) ? {oracle_checks: observation.oracle_checks.filter(raw => { + const check = requireJsonObject(raw, "oracle execution evidence"); + return Array.isArray(check.output_versions) && after.every(output => (check.output_versions as JsonObject[]) + .some(row => row.ref === output.ref && row.sha256 === output.sha256)); + })} : {})}; } export function selectDelegationBinding(params: JsonObject): JsonObject { const config = requireJsonObject(params.config, "delegation configuration"); diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py index 35587ca79b..ee4c0c843a 100644 --- a/loopx/control_plane/collaboration/delegation_validation.py +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -29,6 +29,7 @@ def capture(service, binding: dict) -> dict: basis = inspect_goal_acceptance( registry_path=service.registry, runtime_root=str(service.root), goal_id=service.goal_id, agent_id=binding["agent_id"], todo_id=binding["todo_id"], + validation_workspace_path=Path(binding["workspace"]), ) todo = basis.get("todo") if not isinstance(todo, dict): diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index a614962dea..7e0b25b5d9 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -374,6 +374,8 @@ export function createEffectRuntimeHandlers( ["goal.source_session.turn_effect.gate", lazyHandler(() => import("./goals/source_session_lifetime.ts"), ({decideSourceTurnEffectGate}) => decideSourceTurnEffectGate)], ["goal.first_party_host_runtime.decide", lazyHandler(() => import("./goals/first_party_host_runtime.ts"), ({decideFirstPartyHostRuntime}) => decideFirstPartyHostRuntime)], ["goal.chat_session.lifecycle.decide", lazyHandler(() => import("./goals/chat_session_lifecycle.ts"), ({decideChatSessionLifecycle}) => decideChatSessionLifecycle)], + ["goal.acceptance.oracle.plan", lazyHandler(() => import("./goals/acceptance_oracle.ts"), ({planAcceptanceOracle}) => planAcceptanceOracle)], + ["goal.acceptance.oracle.observe", lazyHandler(() => import("./goals/acceptance_oracle.ts"), ({observeAcceptanceOracles}) => observeAcceptanceOracles)], ["goal.acceptance.inspect", lazyHandler(() => import("./goals/acceptance_authority.ts"), ({inspectLocalGoalAcceptance}) => inspectLocalGoalAcceptance)], ["goal.acceptance.configure", lazyHandler(() => import("./goals/acceptance_authority.ts"), ({commitLocalGoalAcceptance}) => commitLocalGoalAcceptance)], ["goal.acceptance.verify.commit", lazyHandler(() => import("./goals/acceptance_authority.ts"), ({commitLocalGoalAcceptanceVerification}) => commitLocalGoalAcceptanceVerification)], diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py index fda2747b36..98d6c78898 100644 --- a/loopx/control_plane/goals/acceptance.py +++ b/loopx/control_plane/goals/acceptance.py @@ -8,6 +8,7 @@ from __future__ import annotations import hashlib +from datetime import UTC, datetime from collections.abc import Mapping from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ run_declared_completion_validation_effect, ) from .goal_ref_validation import exact_goal_ref +from .acceptance_oracle_io import oracle_versions, run_snapshot_oracle _INSPECT_METHOD = "goal.acceptance.inspect" @@ -61,11 +63,8 @@ def _result( *, timeout: float | None = None, ) -> dict[str, Any]: - value = effect_runtime_result( - method, - dict(request), - **({"timeout": timeout} if timeout is not None else {}), - ) + value = (effect_runtime_result(method, dict(request)) if timeout is None + else effect_runtime_result(method, dict(request), timeout=timeout)) if not isinstance(value, dict): raise TypeError("Goal acceptance authority returned an invalid result") if value.get("status") not in { @@ -117,13 +116,35 @@ def inspect_goal_acceptance( runtime_root: str | None = None, agent_id: str | None = None, todo_id: str | None = None, + delivery_workspace: Mapping[str, Any] | None = None, + validation_workspace_path: Path | None = None, ) -> dict[str, Any]: """Read one canonical basis; command declarations stay inside the host.""" - return _result( + basis = _result( _INSPECT_METHOD, {**_routing(registry_path, goal_id, runtime_root, agent_id), **({"todo_id": todo_id} if todo_id is not None else {})}, ) + observations = [] + for criterion in (basis.get("contract") or {}).get("criteria", []): + oracle = criterion.get("oracle") + if oracle is None: + continue + workspace, failure = _resolve_completion_validation_workspace( + registry_path=registry_path, goal_id=goal_id, + task_repository=(basis.get("todo") or {}).get("task_repository"), + delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + label=criterion["id"], + ) + versions = None if failure else oracle_versions(workspace, oracle) + observations.append({"criterion_id": criterion["id"], "available": versions is not None, + "source_current": _acceptance_pins_match(criterion["validation_files"], workspace), + **(versions or {})}) + if observations: + basis["goal_acceptance_contract"] = effect_runtime_result("goal.acceptance.oracle.observe", { + "contract": basis["goal_acceptance_contract"], "observations": observations, + }) + return basis def _criterion_effects(criteria: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -135,6 +156,7 @@ def _criterion_effects(criteria: list[dict[str, Any]]) -> list[dict[str, Any]]: "validation_label": f"Goal acceptance: {row['id']}", "validation_timeout_seconds": row.get("validation_timeout_seconds", 29), "validation_files": row.get("validation_files", []), + **({"oracle": row["oracle"]} if "oracle" in row else {}), } for row in criteria ] @@ -257,12 +279,13 @@ def run_goal_acceptance_effects( "exit_code": exit_code if isinstance(exit_code, int) and 0 <= exit_code <= 255 else None, + **({"oracle_evidence": receipt["oracle_evidence"]} if "oracle_evidence" in receipt else {}), } ) return results -def _acceptance_pins_match(pins: list, workspace: Path | None) -> bool: +def _acceptance_pins_match(pins: list[dict[str, Any]], workspace: Path | None) -> bool: if not pins: return True if workspace is None: @@ -309,7 +332,7 @@ def run_goal_acceptance_validation_effect( raise TypeError("acceptance validation_files must be an array") label = str(effect.get("validation_label") or "Goal acceptance") workspace = None - if pins: + if pins or effect.get("oracle"): workspace, failure = _resolve_completion_validation_workspace( registry_path=registry_path, goal_id=goal_id, @@ -336,16 +359,36 @@ def stale_receipt() -> dict[str, Any]: if not _acceptance_pins_match(pins, workspace): return stale_receipt() - receipt = run_declared_completion_validation_effect( - effect={ - key: value for key, value in effect.items() if key != "validation_files" - }, - registry_path=registry_path, - goal_id=goal_id, - delivery_workspace=delivery_workspace, - validation_workspace_path=validation_workspace_path, - ) - return receipt if _acceptance_pins_match(pins, workspace) else stale_receipt() + oracle = effect.get("oracle") + plan = effect_runtime_result("goal.acceptance.oracle.plan", {"criterion": dict(effect)}) if oracle else None + before = oracle_versions(workspace, oracle) if oracle else None + if oracle and before is None: + return stale_receipt() + if oracle: + if workspace is None or before is None or plan is None: + return stale_receipt() + try: + receipt, stable_snapshot = run_snapshot_oracle(workspace=workspace, criterion=dict(effect), before=before) + except OSError: + return stale_receipt() + if not stable_snapshot: + return stale_receipt() + else: + receipt = run_declared_completion_validation_effect( + effect={key: value for key, value in effect.items() if key != "validation_files"}, + registry_path=registry_path, goal_id=goal_id, + delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + ) + if not _acceptance_pins_match(pins, workspace): + return stale_receipt() + if oracle: + after = oracle_versions(workspace, oracle) + if after is None or after != before: + return stale_receipt() + if receipt["passed"] and plan is not None: + receipt["oracle_evidence"] = {"source_id": plan["source_id"], "source_digest": plan["source_digest"], + "checked_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), **after} + return receipt def public_goal_acceptance(value: dict[str, Any]) -> dict[str, Any]: @@ -371,10 +414,14 @@ def verify_goal_acceptance( runtime_root: str | None = None, agent_id: str | None = None, execute: bool = False, + expected_provider_revision: str | None = None, + operation_id: str | None = None, ) -> dict[str, Any]: """Run the configured acceptance checks against a frozen canonical basis.""" route = _routing(registry_path, goal_id, runtime_root, agent_id) - basis = _result(_INSPECT_METHOD, route) + basis = inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id, runtime_root=runtime_root, agent_id=agent_id) + if expected_provider_revision is not None and basis["provider_revision"] != expected_provider_revision: + raise ValueError("Acceptance changed; reload the current contract before checking.") contract = basis.get("contract") if contract is None: raise ValueError("Goal acceptance is not enabled") @@ -395,14 +442,19 @@ def verify_goal_acceptance( **route, "expected_provider_revision": basis["provider_revision"], "actor_agent_id": None, - "operation_id": f"goal-acceptance-verify:{uuid4().hex}", + "operation_id": operation_id or f"goal-acceptance-verify:{uuid4().hex}", "contract_digest": basis["goal_acceptance_contract"]["digest"], "revision": basis["goal_acceptance_contract"]["revision"], "results": receipts, }, ) + public_result = public_goal_acceptance(result) + if any("oracle" in criterion for criterion in criteria): + observed = inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id, + runtime_root=runtime_root, agent_id=agent_id) + public_result["goal_acceptance_contract"] = observed["goal_acceptance_contract"] return { - **public_goal_acceptance(result), + **public_result, "executed": True, "checks_passed": all(row.get("passed") is True for row in receipts), "acceptance_ready": result.get("goal_acceptance_contract", {}).get("status") diff --git a/loopx/control_plane/goals/acceptance_actions.py b/loopx/control_plane/goals/acceptance_actions.py new file mode 100644 index 0000000000..699bbeb674 --- /dev/null +++ b/loopx/control_plane/goals/acceptance_actions.py @@ -0,0 +1,136 @@ +"""Local Chat-action transport for an Agent-prepared acceptance check. + +Reuse ChatActionStore's proposal/checkpoint owner and the TS acceptance owner. +The per-proposal host lock and started checkpoint fence command execution; +preparation only validates a document and cannot configure or run it. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, TypedDict +from uuid import uuid4 + +from ...chat_action_store import ActionConflictError, ChatActionStore +from ...file_lock import exclusive_file_lock +from .acceptance import ( + configure_goal_acceptance, inspect_goal_acceptance, public_goal_acceptance, + verify_goal_acceptance, +) + + +class _AcceptanceRoute(TypedDict): + registry_path: Path + runtime_root: str + goal_id: str + + +def prepare_acceptance_check(*, registry_path: Path, runtime_root: str | None, + goal_id: str, agent_id: str | None, + document: dict[str, Any], operation_id: str, + execute: bool) -> dict[str, Any]: + from ...chat_actions import ChatActionService + from ...paths import effective_runtime_root + + root = effective_runtime_root(registry_path, runtime_root) + if not execute: + route: _AcceptanceRoute = { + "registry_path": registry_path, "runtime_root": str(root), "goal_id": goal_id, + } + before = inspect_goal_acceptance(**route, agent_id=agent_id) + preview = configure_goal_acceptance(**route, document=document, + expected_provider_revision=before["provider_revision"], + operation_id=f"acceptance-preview:{uuid4().hex}", execute=False) + return {**public_goal_acceptance(preview), "prepared": False, "executed": False} + service = ChatActionService(store=ChatActionStore(root / "chat" / "actions"), + registry_path=registry_path) + parameters = service._normalize("goal.acceptance.check", { + "goal_id": goal_id, "document": document, "prepared_by": agent_id}) + proposal = service.preview({"action_kind": "goal.acceptance.check", + "summary": str(document.get("objective") or "Check the delivery"), + "idempotency_key": operation_id, "context": {"kind": "goal", "goal_id": goal_id}, + "normalized_parameters": parameters}) + return {"ok": True, "goal_id": goal_id, "prepared": True, "executed": False, + "proposal": proposal} + + +class GoalAcceptanceActionMixin: + store: ChatActionStore + registry_path: Path + + def _acceptance_route(self, goal_id: str) -> _AcceptanceRoute: + # ChatHTTPServer and the prepare CLI both use runtime/chat/actions. + return {"registry_path": self.registry_path, "goal_id": goal_id, + "runtime_root": str(self.store.root.parent.parent)} + + def _acceptance_preview(self, parameters: dict[str, Any]) -> tuple[str, dict[str, Any]]: + route = self._acceptance_route(parameters["goal_id"]) + before = inspect_goal_acceptance(**route) + preview = configure_goal_acceptance( + **route, document=parameters["document"], + expected_provider_revision=before["provider_revision"], + operation_id=f"acceptance-preview:{uuid4().hex}", execute=False, + ) + if preview.get("status") != "planned": + raise ValueError("The current Goal cannot prepare this check plan") + return before["provider_revision"], preview + + def _apply_acceptance_check(self, proposal_id: str) -> dict[str, Any]: + # The existing generic start_apply is replayable. Serialize this host + # command boundary so concurrent tabs/processes consume one intent. + with exclusive_file_lock(self.store.root / f"{proposal_id}.check", + agent_id="loopx-chat", operation="acceptance_check"): + proposal = self.store.load(proposal_id) + if proposal is None: + raise KeyError("Check plan was not found") + if proposal["status"] == "applied": + return {"proposal": proposal, "turn": None} + parameters = proposal["normalized_parameters"] + route = self._acceptance_route(parameters["goal_id"]) + steps = (proposal.get("checkpoint") or {}).get("steps", {}) + configured = steps.get("acceptance_configured") + operation_id = f"acceptance-check:{proposal_id}" + if configured is None: + expected = proposal["expected_state_fingerprint"] + try: + # Its native idempotency receipt also recovers a lost + # configuration response before the Chat checkpoint. + saved = configure_goal_acceptance( + **route, document=parameters["document"], + expected_provider_revision=expected, + operation_id=f"acceptance-configure:{proposal_id}", execute=True, + ) + except ValueError: + current = inspect_goal_acceptance(**route) + if current["provider_revision"] != expected: + stored = self.store.apply(proposal_id, + current_state_fingerprint=current["provider_revision"], receipt={}) + return {"proposal": stored, "turn": None} + raise + configured = {"provider_revision": saved["provider_revision"], + "digest": saved["goal_acceptance_contract"]["digest"]} + self.store.save_checkpoint(proposal_id, step="acceptance_configured", receipt=configured) + current = inspect_goal_acceptance(**route) + contract = current["goal_acceptance_contract"] + observed = contract.get("verification") or {} + if steps.get("acceptance_check_started"): + # A native committed receipt may recover a lost HTTP result. + # Missing receipt is uncertainty, never permission to rerun. + if observed.get("operation_id") != operation_id: + raise ActionConflictError("The previous check result is unconfirmed. Read current evidence before explicitly running another check.") + result = {**public_goal_acceptance(current), "executed": True, + "checks_passed": all(row["passed"] for row in observed["results"])} + else: + if (current["provider_revision"] != configured["provider_revision"] + or contract.get("digest") != configured["digest"]): + raise ActionConflictError("The check basis changed. Prepare a current plan before executing.") + self.store.save_checkpoint(proposal_id, step="acceptance_check_started", + receipt={"operation_id": operation_id}) + result = verify_goal_acceptance(**route, execute=True, + expected_provider_revision=configured["provider_revision"], operation_id=operation_id) + receipt = {"receipt_id": f"acceptance-{proposal_id}", "outcome": "acceptance_checked", + "projection_verified": True, "goal_id": parameters["goal_id"], + "operation_id": operation_id, "checks_passed": result["checks_passed"]} + stored = self.store.apply(proposal_id, + current_state_fingerprint=proposal["expected_state_fingerprint"], receipt=receipt) + return {"proposal": stored, "turn": None} diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index e62be27ee7..32a360cef9 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -19,6 +19,8 @@ import {GOAL_ACCEPTANCE_LIFECYCLE_SCHEMA, parseGoalAcceptanceLifecycleTransition readGoalAcceptanceLifecycle, sameExactGoalRef, type GoalAcceptanceLifecycle, type GoalAcceptanceLifecycleTransition, type WireExactGoalRef} from "./acceptance_lifecycle.ts"; +import {validateOracleResults} from "./acceptance_oracle.ts"; + const RESULT_SCHEMA = "loopx_goal_acceptance_result_v0"; const RECEIPT_SCHEMA = "loopx_goal_acceptance_operation_v0"; const LIFECYCLE_RECEIPT_SCHEMA = "loopx_goal_acceptance_lifecycle_operation_v0"; @@ -182,8 +184,15 @@ export async function configureGoalAcceptance(store: AuthorityStore, value: Json "acceptance binding must reference existing Agent advancement work"); return {...binding, todo_semantic_digest: goalAcceptanceTodoDigest(todo), revision, confirmed_by: "owner" as const}; }); + // Removing the optional oracle withdraws its current receipt. The immutable + // operation journal retains that evidence; older readers can then consume + // the ordinary contract without understanding the optional result fields. + const previousVerification = previous?.verification ?? null; + const verification = !document.criteria.some(criterion => criterion.oracle !== undefined) + && previousVerification?.results.some(result => result.oracle_evidence !== undefined) + ? null : previousVerification; const fields = {enabled: true, revision, digest: canonicalAuthoritySha256(document), - document, bindings, verification: previous?.verification ?? null}; + document, bindings, verification}; const goalRef = requestGoalRef(request); state = goalRef === null ? {schema_version: GOAL_ACCEPTANCE_SCHEMA, ...fields} @@ -222,6 +231,7 @@ export async function commitGoalAcceptanceVerification(store: AuthorityStore, va criterionIds = task.criterion_ids; } normalizeAcceptanceResults(results, criterionIds); + validateOracleResults(state.document.criteria, results); const verification: AcceptanceVerification = {operation_id: String(request.operation_id), contract_revision: state.revision, contract_digest: state.digest, todo_id: todoId, work_digest: goalAcceptanceWorkDigest(head.head, goalId, state.document.scope), results}; diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index 0ece3e8d36..f0609ccce0 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -10,6 +10,8 @@ import { BARE_SHA256_PATTERN } from "../content_digest.ts"; import {parseWireExactGoalRef, readGoalAcceptanceLifecycle, sameExactGoalRef, type GoalAcceptanceLifecycle, type WireExactGoalRef} from "./acceptance_lifecycle.ts"; +import {normalizeAcceptanceOracle, normalizeOracleEvidence, projectAcceptanceOracle, validateOracleResults, type AcceptanceOracle} from "./acceptance_oracle.ts"; + export const GOAL_ACCEPTANCE_SCHEMA = "loopx_goal_acceptance_v0"; export const GOAL_ACCEPTANCE_OWNED_SCHEMA = "loopx_goal_acceptance_v1"; export interface AcceptanceCriterion extends JsonObject { @@ -18,6 +20,7 @@ export interface AcceptanceCriterion extends JsonObject { validation_argv: string[]; validation_timeout_seconds: number; validation_files: {path: string; sha256: string}[]; + oracle?: AcceptanceOracle; } export type AcceptanceScope = | {kind: "all_advancement"} @@ -97,6 +100,7 @@ export function acceptanceValidationEffects(requirements: AcceptanceCompletionRe kind: "caller_validation", validation_command: null, validation_argv: criterion.validation_argv, validation_label: criterion.id, validation_timeout_seconds: criterion.validation_timeout_seconds, ...(criterion.validation_files == null ? {} : {validation_files: criterion.validation_files}), + ...(criterion.oracle === undefined ? {} : {oracle: criterion.oracle}), task_repository: todo.task_repository ?? null, }})); } @@ -170,7 +174,7 @@ export function normalizeGoalAcceptanceDocument(value: unknown): AcceptanceDocum acceptanceRequire(canonicalAuthorityBytes(raw).length <= 262144, "acceptance document exceeds byte limit"); const criteria = list(raw.criteria, "acceptance criteria", 64, 1).map(value => { const item = canonicalAuthorityObject(value, "acceptance criterion"); - acceptanceKeys(item, ["id", "description", "validation_argv"], ["validation_timeout_seconds", "validation_files"]); + acceptanceKeys(item, ["id", "description", "validation_argv"], ["validation_timeout_seconds", "validation_files", "oracle"]); const argv = list(item.validation_argv, "validation argv", 128, 1).map(arg => { acceptanceRequire(typeof arg === "string" && arg.length > 0 && arg.length <= 8192 && !arg.includes("\0"), "validation argv contains an invalid argument"); return arg; @@ -178,9 +182,10 @@ export function normalizeGoalAcceptanceDocument(value: unknown): AcceptanceDocum acceptanceRequire(argv[0].trim().length > 0, "validation argv requires an executable"); const timeout = item.validation_timeout_seconds ?? 5; acceptanceRequire(Number.isSafeInteger(timeout) && Number(timeout) >= 1 && Number(timeout) <= 25, "validation timeout must be 1..25 seconds"); - return {id: id(item.id), description: acceptanceText(item.description, "criterion description"), + const criterion = {id: id(item.id), description: acceptanceText(item.description, "criterion description"), validation_argv: argv, validation_timeout_seconds: Number(timeout), validation_files: validationFiles(item.validation_files === undefined ? [] : item.validation_files)}; + return {...criterion, ...(item.oracle === undefined ? {} : {oracle: normalizeAcceptanceOracle(item.oracle, criterion)})}; }).sort((a, b) => authorityUnicodeCompare(a.id, b.id)); acceptanceRequire(criteria.reduce((total, item) => total + item.validation_timeout_seconds, 0) <= 25, "acceptance validation timeouts must total at most 25 seconds (default 5 seconds per criterion); lower the timeouts or run longer evaluations outside the completion wrapper"); @@ -370,6 +375,7 @@ export function readGoalAcceptanceAuthority(head: JsonObject, goalId: string): G } verification = {...receipt, operation_id: operationId, todo_id: todoId, results: normalizeAcceptanceResults(receipt.results, expectedIds)} as AcceptanceVerification; + if (receipt.contract_revision === state.revision) validateOracleResults(document.criteria, verification.results); } if (lifecycle === null) { const legacyState: LegacyAcceptanceState = { @@ -441,14 +447,15 @@ export function normalizeAcceptanceResults(value: unknown, expectedIds?: readonl // Existing caller-validation runner metadata is accepted at the host seam // but deliberately not persisted or projected (labels can contain context). acceptanceKeys(item, ["criterion_id", "passed", "exit_code"], ["schema_version", "command_label", "status", "summary", - "stdout_captured", "stderr_captured", "local_path_captured"]); + "stdout_captured", "stderr_captured", "local_path_captured", "oracle_evidence"]); for (const key of ["stdout_captured", "stderr_captured", "local_path_captured"]) { acceptanceRequire(item[key] === undefined || item[key] === false, "validation result must not capture private output or paths"); } acceptanceRequire(typeof item.passed === "boolean" && (item.exit_code === null || (Number.isSafeInteger(item.exit_code) && Number(item.exit_code) >= 0 && Number(item.exit_code) <= 255)), "invalid verification result"); acceptanceRequire(item.passed === (item.exit_code === 0), "criterion pass/fail must agree with exit code zero"); - return {criterion_id: id(item.criterion_id), passed: item.passed, exit_code: item.exit_code as number | null}; + return {criterion_id: id(item.criterion_id), passed: item.passed, exit_code: item.exit_code as number | null, + ...(item.oracle_evidence === undefined ? {} : {oracle_evidence: normalizeOracleEvidence(item.oracle_evidence)})}; }).sort((a, b) => authorityUnicodeCompare(a.criterion_id, b.criterion_id)); const ids = unique(results.map(item => item.criterion_id), "verification results"); if (expectedIds) acceptanceRequire(canonicalAuthoritySha256(ids) === canonicalAuthoritySha256([...expectedIds].sort(authorityUnicodeCompare)), @@ -484,7 +491,8 @@ export function projectGoalAcceptance(head: JsonObject, goalId: string): JsonObj if (held.length) status = "held"; return {...exactProjection, ...(state.document.scope === undefined ? {} : {scope: state.document.scope}), enabled: true, revision: state.revision, digest: state.digest, objective: state.document.objective, - non_goals: state.document.non_goals, criteria: state.document.criteria.map(({id, description}) => ({id, description})), + non_goals: state.document.non_goals, criteria: state.document.criteria.map(criterion => ({id: criterion.id, description: criterion.description, + ...(criterion.oracle === undefined ? {} : {oracle: projectAcceptanceOracle(criterion, receipt?.results.find(row => row.criterion_id === criterion.id), status === "stale" || status === "held")})})), tasks, held_todo_ids: held.map(task => task.todo_id), status, verification: receipt ? {operation_id: receipt.operation_id, contract_revision: receipt.contract_revision, contract_digest: receipt.contract_digest, todo_id: receipt.todo_id, results: receipt.results} : null}; diff --git a/loopx/control_plane/goals/acceptance_http.py b/loopx/control_plane/goals/acceptance_http.py new file mode 100644 index 0000000000..8163c3a7d9 --- /dev/null +++ b/loopx/control_plane/goals/acceptance_http.py @@ -0,0 +1,130 @@ +"""Loopback operator transport for the existing acceptance owner. + +TypeScript retains configuration, evidence and CAS decisions. This adapter only +parses the local HTTP request and invokes the same host IO as the CLI. +""" + +from __future__ import annotations +from pathlib import Path +from typing import Any, Protocol +from urllib.parse import parse_qs, urlparse + + +class _AcceptanceServer(Protocol): + registry_path: Path + runtime_root: Path + + +class GoalAcceptanceRequestMixin: + server: _AcceptanceServer + path: str + + def _read_json(self) -> dict[str, Any]: + raise NotImplementedError + + def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: + raise NotImplementedError + + def _send_error(self, message: str, *, status: int, error_code: str) -> None: + raise NotImplementedError + + def _goal_acceptance_inspect(self) -> None: + from .acceptance import inspect_goal_acceptance, public_goal_acceptance + + try: + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + if ( + set(query) != {"goal_id"} + or len(query["goal_id"]) != 1 + or not query["goal_id"][0].strip() + ): + raise ValueError("one goal_id is required") + result = public_goal_acceptance( + inspect_goal_acceptance( + registry_path=self.server.registry_path, + runtime_root=str(self.server.runtime_root), + goal_id=query["goal_id"][0], + ) + ) + self._send_json({**result, "goal_id": query["goal_id"][0]}) + except (OSError, TypeError, ValueError, RuntimeError): + self._send_error( + "Acceptance is unavailable for this Goal; reload after its local authority is available.", + status=409, + error_code="goal_acceptance_unavailable", + ) + + def _goal_acceptance_update(self) -> None: + from .acceptance import ( + configure_goal_acceptance, + public_goal_acceptance, + verify_goal_acceptance, + ) + + try: + body = self._read_json() + if set(body) - { + "goal_id", + "action", + "document", + "expected_provider_revision", + "operation_id", + }: + raise ValueError("unknown acceptance request fields") + goal_id, action = body.get("goal_id"), body.get("action") + revision = body.get("expected_provider_revision") + if ( + not isinstance(goal_id, str) + or not goal_id.strip() + or not isinstance(revision, str) + or not revision + ): + raise ValueError("Goal and current provider revision are required") + common: dict[str, Any] = { + "registry_path": self.server.registry_path, + "runtime_root": str(self.server.runtime_root), + "goal_id": goal_id, + "execute": True, + } + if action == "verify": + if set(body) - {"goal_id", "action", "expected_provider_revision"}: + raise ValueError("check does not accept declarations or results") + result = verify_goal_acceptance( + **common, expected_provider_revision=revision + ) + elif action in {"configure", "disable"}: + document = body.get("document") + if ( + action == "configure" + and not isinstance(document, dict) + or action == "disable" + and document is not None + ): + raise ValueError( + "configuration requires a document; disable requires no document" + ) + operation_id = body.get("operation_id") + if not isinstance(operation_id, str) or not operation_id: + raise ValueError( + "configuration requires a stable operation identity" + ) + result = public_goal_acceptance( + configure_goal_acceptance( + **common, + document=document, + disable=action == "disable", + expected_provider_revision=revision, + operation_id=operation_id, + ) + ) + else: + raise ValueError("unknown acceptance operation") + self._send_json({**result, "goal_id": goal_id}) + except (OSError, TypeError, ValueError, RuntimeError): + # An uncertain check is never automatically replayed. The caller + # can inspect the current receipt before explicitly running again. + self._send_error( + "Acceptance could not be applied or checked. Read back the current contract and receipt before retrying.", + status=409, + error_code="goal_acceptance_operation_failed", + ) diff --git a/loopx/control_plane/goals/acceptance_oracle.ts b/loopx/control_plane/goals/acceptance_oracle.ts new file mode 100644 index 0000000000..af8d4121b5 --- /dev/null +++ b/loopx/control_plane/goals/acceptance_oracle.ts @@ -0,0 +1,135 @@ +/** Optional provenance for an owner-pinned checker, never an Agent's verdict. + * The local host executes the configured checker and observes file bytes. This + * does not authenticate a remote reviewer or attest unpinned dependencies. */ +import type {JsonObject} from "../effect_program.ts"; +import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; + +export type AcceptanceOracleState = "unverified" | "recorded" | "current" | "stale" | "failed" | "unavailable"; + +function requireThat(value: unknown, message: string): asserts value { + if (!value) throw new AuthorityStoreProtocolError(message); +} +function keys(row: JsonObject, fields: string[]): void { + requireThat(Object.keys(row).length === fields.length && fields.every(key => Object.hasOwn(row, key)), "invalid acceptance oracle fields"); +} +export function acceptanceArtifactRef(value: unknown): string { + requireThat(typeof value === "string" && value.length <= 1024 && /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(value) + && value.split("/").every(part => part !== "." && part !== ".."), "oracle files must be safe repository-relative refs"); + return value; +} +function refs(value: unknown, required: boolean): string[] { + requireThat(Array.isArray(value) && value.length >= (required ? 1 : 0) && value.length <= 20, "oracle needs bounded explicit file refs"); + const result = value.map(acceptanceArtifactRef).sort(); + requireThat(new Set(result).size === result.length, "duplicate oracle file ref"); + return result; +} +export interface AcceptanceOracle extends JsonObject { + kind: "owner_pinned_oracle"; + source_id: string; + input_refs: string[]; + output_refs: string[]; +} +export function normalizeAcceptanceOracle(value: unknown, criterion: JsonObject): AcceptanceOracle { + const row = canonicalAuthorityObject(value, "acceptance oracle"); + keys(row, ["kind", "source_id", "input_refs", "output_refs"]); + requireThat(row.kind === "owner_pinned_oracle" && typeof row.source_id === "string" + && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(row.source_id), "owner-pinned oracle source identity required"); + const input_refs = refs(row.input_refs, false), output_refs = refs(row.output_refs, true); + requireThat(!input_refs.some(ref => output_refs.includes(ref)), "oracle input and output roles must be distinct"); + const pins = criterion.validation_files as {path: string; sha256: string}[]; + const argv = criterion.validation_argv as string[]; + requireThat(Array.isArray(pins) && pins.length > 0 && Array.isArray(argv), + "oracle requires an explicitly pinned checker"); + // This is the supported invocation grammar, not interpreter attestation. + // Arbitrary commands may ignore argv[1]; only Python's immediate script + // position has that meaning here. Other runtimes can execute a pinned entry + // point directly. The interpreter/environment remain trusted host inputs. + const executableName = argv[0]?.split(/[\\/]/).at(-1) ?? ""; + const hostInterpreterPath = executableName === argv[0] || /^(?:\/|[A-Za-z]:[\\/])/.test(argv[0] ?? ""); + const pythonScript = hostInterpreterPath && /^python(?:3(?:\.\d+)?)?(?:\.exe)?$/.test(executableName) + && typeof argv[1] === "string" && !argv[1].startsWith("-") + && pins.some(pin => pin.path === argv[1]); + const directSource = typeof argv[0] === "string" && argv[0].includes("/") + && pins.some(pin => pin.path === argv[0]); + requireThat(directSource || pythonScript, + "oracle requires a pinned relative executable path containing '/' or a pinned script immediately after a supported Python interpreter; arbitrary commands and interpreter options are unsupported"); + requireThat(!pins.some(pin => input_refs.includes(pin.path) || output_refs.includes(pin.path)), + "oracle source and checked artifacts must be distinct"); + return {kind: "owner_pinned_oracle", source_id: row.source_id, input_refs, output_refs}; +} +export function acceptanceOracleBasis(criterion: JsonObject): string { + return canonicalAuthoritySha256({oracle: criterion.oracle, validation_argv: criterion.validation_argv, + validation_files: criterion.validation_files, validation_timeout_seconds: criterion.validation_timeout_seconds}); +} +export interface OracleVersion extends JsonObject {ref: string; sha256: string} +export function normalizeOracleVersions(value: unknown): OracleVersion[] { + requireThat(Array.isArray(value) && value.length <= 20, "bounded oracle versions required"); + const result = value.map(item => { + const row = canonicalAuthorityObject(item, "oracle artifact version"); keys(row, ["ref", "sha256"]); + requireThat(typeof row.sha256 === "string" && BARE_SHA256_PATTERN.test(row.sha256), "oracle artifact version requires SHA-256"); + return {ref: acceptanceArtifactRef(row.ref), sha256: row.sha256}; + }).sort((a,b) => a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0); + requireThat(new Set(result.map(row => row.ref)).size === result.length, "duplicate oracle artifact version"); + return result; +} +export function normalizeOracleEvidence(value: unknown): JsonObject { + const row = canonicalAuthorityObject(value, "oracle execution evidence"); + keys(row, ["source_id", "source_digest", "checked_at", "input_versions", "output_versions"]); + requireThat(typeof row.source_id === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(row.source_id) + && typeof row.source_digest === "string" && BARE_SHA256_PATTERN.test(row.source_digest), "oracle evidence source required"); + requireThat(typeof row.checked_at === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(row.checked_at) + && Number.isFinite(Date.parse(row.checked_at)), "oracle check time required"); + return {...row, input_versions: normalizeOracleVersions(row.input_versions), output_versions: normalizeOracleVersions(row.output_versions)}; +} +export function validateOracleResults(criteria: JsonObject[], results: JsonObject[]): void { + for (const result of results) { + const criterion = criteria.find(row => row.id === result.criterion_id); + if (result.oracle_evidence === undefined) { + requireThat(result.passed !== true || criterion?.oracle === undefined, "successful oracle check omitted execution evidence"); + continue; + } + requireThat(criterion?.oracle !== undefined && result.passed === true, "unconfigured or failed check cannot attest oracle evidence"); + const oracle = criterion.oracle as AcceptanceOracle, evidence = normalizeOracleEvidence(result.oracle_evidence); + requireThat(evidence.source_id === oracle.source_id && evidence.source_digest === acceptanceOracleBasis(criterion), "oracle evidence source changed"); + for (const role of ["input", "output"]) { + const versions = evidence[`${role}_versions`] as OracleVersion[]; + requireThat(canonicalAuthoritySha256(versions.map(row => row.ref)) === canonicalAuthoritySha256(oracle[`${role}_refs`]), "oracle evidence omitted or added a checked artifact"); + } + } +} +/** Raw canonical projections say recorded, never current, without host IO. */ +export function projectAcceptanceOracle(criterion: JsonObject, result: JsonObject | undefined, stale: boolean): JsonObject { + const oracle = criterion.oracle as AcceptanceOracle; + return {kind: oracle.kind, source_id: oracle.source_id, source_digest: acceptanceOracleBasis(criterion), + input_refs: oracle.input_refs, output_refs: oracle.output_refs, + state: stale ? "stale" : !result ? "unverified" : result.passed === false ? "failed" : result.oracle_evidence ? "recorded" : "unavailable"}; +} +/** Host observations only; a read never runs checks, certifies a new version or + * changes canonical acceptance/completion. Unavailable facts are not success. */ +export function observeAcceptanceOracles(params: JsonObject): JsonObject { + const contract = canonicalAuthorityObject(params.contract, "acceptance projection"); + if (contract.enabled !== true) return contract; + requireThat(Array.isArray(contract.criteria) && Array.isArray(params.observations), "oracle observations required"); + const receipt = contract.verification as JsonObject | null; + const results = receipt?.results as JsonObject[] | undefined; + return {...contract, criteria: (contract.criteria as JsonObject[]).map(criterion => { + if (!criterion.oracle) return criterion; + const oracle = criterion.oracle as JsonObject; + if (oracle.state !== "recorded") return criterion; + const evidence = results?.find(row => row.criterion_id === criterion.id)?.oracle_evidence as JsonObject | undefined; + const facts = (params.observations as JsonObject[]).find(row => row.criterion_id === criterion.id); + let state: AcceptanceOracleState = "unavailable"; + if (facts?.available === true && evidence) { + state = facts.source_current === true && ["input", "output"].every(role => + canonicalAuthoritySha256(normalizeOracleVersions(facts[`${role}_versions`])) === canonicalAuthoritySha256(evidence[`${role}_versions`])) ? "current" : "stale"; + } + return {...criterion, oracle: {...oracle, state}}; + })}; +} + +export function planAcceptanceOracle(params: JsonObject): JsonObject { + const criterion = canonicalAuthorityObject(params.criterion, "oracle criterion"); + const oracle = normalizeAcceptanceOracle(criterion.oracle, criterion); + return {...oracle, source_digest: acceptanceOracleBasis({...criterion, oracle})}; +} diff --git a/loopx/control_plane/goals/acceptance_oracle_io.py b/loopx/control_plane/goals/acceptance_oracle_io.py new file mode 100644 index 0000000000..c49965dd25 --- /dev/null +++ b/loopx/control_plane/goals/acceptance_oracle_io.py @@ -0,0 +1,105 @@ +"""Host filesystem/process IO for the TypeScript acceptance oracle owner. + +No configuration, evidence classification or acceptance decision is owned here. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + + +def oracle_file_versions( + workspace: Path | None, refs: list[str] +) -> list[dict[str, str]] | None: + if workspace is None: + return None + versions = [] + try: + for ref in sorted(refs): + path = workspace + for part in Path(ref).parts: + path = path / part + if path.is_symlink(): + return None + if not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + versions.append({"ref": ref, "sha256": digest.hexdigest()}) + except OSError: + return None + return versions + + +def oracle_versions( + workspace: Path | None, oracle: dict[str, Any] +) -> dict[str, Any] | None: + inputs = oracle_file_versions(workspace, oracle["input_refs"]) + outputs = oracle_file_versions(workspace, oracle["output_refs"]) + if inputs is None or outputs is None: + return None + return {"input_versions": inputs, "output_versions": outputs} + + +def run_snapshot_oracle( + *, workspace: Path, criterion: dict[str, Any], before: dict[str, Any] +) -> tuple[dict[str, Any], bool]: + """Run the approved checker against copies of precisely the declared files. + + CWD isolation freezes the bytes seen through the declared relative refs; it + is not an OS sandbox or an attestation of imports/environment/network access. + """ + import shutil + import subprocess + from tempfile import TemporaryDirectory + from ..runtime.validation_command import run_caller_validation + + oracle = criterion["oracle"] + pins = criterion["validation_files"] + with TemporaryDirectory(prefix="loopx-acceptance-oracle-") as directory: + snapshot = Path(directory) + for ref in { + *(pin["path"] for pin in pins), + *oracle["input_refs"], + *oracle["output_refs"], + }: + target = snapshot / ref + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(workspace / ref, target) + + def pins_current() -> bool: + versions = oracle_file_versions(snapshot, [pin["path"] for pin in pins]) + return versions is not None and { + row["ref"]: row["sha256"] for row in versions + } == {pin["path"]: pin["sha256"] for pin in pins} + + stable = pins_current() and oracle_versions(snapshot, oracle) == before + if not stable: + return {}, False + try: + receipt = run_caller_validation( + snapshot, + validation_argv=criterion["validation_argv"], + validation_label=criterion["validation_label"], + timeout_seconds=criterion["validation_timeout_seconds"], + ) + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as exc: + receipt = { + "schema_version": "issue_fix_validation_command_v0", + "command_label": criterion["validation_label"], + "passed": False, + "exit_code": None, + "status": "timeout" + if isinstance(exc, subprocess.TimeoutExpired) + else "command_not_run", + "stdout_captured": False, + "stderr_captured": False, + "local_path_captured": False, + } + return dict(receipt), pins_current() and oracle_versions( + snapshot, oracle + ) == before diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index 5091b2d0fc..8196e8c147 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -591,6 +591,16 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): if (proposal.status !== "preview_ready" && proposal.status !== "deferred") return held("inactive", "inactive_proposal"); const reviewed = (reason: ActionReviewReason, canApply = true): ActionReviewPlan => finish({ interaction: "review", reason, canApply }); + if (proposal.action_kind === "goal.acceptance.check") { + const context = objectValue(proposal.context); + const goalId = textValue(parameters?.goal_id); + if (!goalId || context?.kind !== "goal" || context.goal_id !== goalId + || !objectValue(parameters?.document) || !identity.proposalId || !identity.sourceFingerprint) { + return held("refresh", "incomplete_proposal"); + } + return reviewed(proposal.permission_classification === "local_command_execution" + ? "action_review" : "unknown_permission", proposal.permission_classification === "local_command_execution"); + } if (proposal.action_kind !== "goal.lifecycle") { return reviewed(proposal.permission_classification === "protected" ? "protected_action" : "action_review"); } diff --git a/tests/capabilities/test_pr_review_contract.py b/tests/capabilities/test_pr_review_contract.py index 2cbfead0f7..f42c50284f 100644 --- a/tests/capabilities/test_pr_review_contract.py +++ b/tests/capabilities/test_pr_review_contract.py @@ -677,6 +677,25 @@ def test_motivation_publishes_existing_problem_context_before_implementation(): assert "not truth or comprehension" in publication["rule"] +def test_experience_review_challenges_total_work_without_erasing_consent(): + # Agent-consumed prose is behavior: preserve both the reduction obligation + # and its legitimate-consent/configuration countercases in the shared packet. + contract = build_agent_response_contract()["review_execution_contract"] + context = next(row for row in contract["evidence_requirements"] + if row["evidence_id"] == "problem_context") + impact = context["outcome_impact"] + assert impact["applicable_fields"] == ["before_after", "evidence_refs"] + rule = impact["rule"] + for obligation in ("original request", "entire human job", "smaller lawful alternative", + "zero additional rounds", "off-screen preparation", + "high feature value alone is insufficient", "not a universal click gate", + "Optional technical editing remains valid", "necessary scoped consent"): + assert obligation in rule + direct = build_review_execution_contract() + assert next(row for row in direct["evidence_requirements"] + if row["evidence_id"] == "problem_context")["outcome_impact"] == impact + + def test_review_packet_and_template_share_optional_visual_explanation(capsys): fixture = Path(__file__).resolve().parents[2] / "examples/fixtures/pr-review.public.json" assert cli_main(["--format", "json", "pr-review", "--fixture", str(fixture)]) == 0 diff --git a/tests/control_plane/test_goal_acceptance_cli.py b/tests/control_plane/test_goal_acceptance_cli.py index 7b61c6172e..7bd28cc3d8 100644 --- a/tests/control_plane/test_goal_acceptance_cli.py +++ b/tests/control_plane/test_goal_acceptance_cli.py @@ -128,6 +128,8 @@ def test_owner_configure_validation_failure_success_and_disable(acceptance_goal) (project / "artifact.txt").write_text("accepted") code, passed = cli("verify", "--agent-id", "agent-a", "--execute") assert code == 0 and passed["checks_passed"] is True, passed + assert passed["status"] == "applied" and passed["goal_id"] == "goal-acceptance" + assert all("oracle" not in row for row in passed["goal_acceptance_contract"]["criteria"]) observed = cli("inspect")[1] assert observed["goal_acceptance_contract"]["status"] == "accepted" assert "validation_argv" not in json.dumps(observed) diff --git a/tests/control_plane/test_goal_acceptance_oracle.py b/tests/control_plane/test_goal_acceptance_oracle.py new file mode 100644 index 0000000000..1cf6d6efdf --- /dev/null +++ b/tests/control_plane/test_goal_acceptance_oracle.py @@ -0,0 +1,376 @@ +"""Real File/SQLite oracle and owner HTTP journeys; no model or active Goal.""" + +import hashlib +import json +import shutil +import sys +import pytest +from test_goal_acceptance_cli import acceptance_goal as acceptance_goal + + +def test_owner_pinned_oracle_binds_actual_versions_and_withdraws_stale_evidence( + acceptance_goal, +): + project, document, cli, _ = acceptance_goal + checker = project / "check.py" + checker.write_text( + "from pathlib import Path\nassert Path('artifact.txt').read_text() == Path('input.txt').read_text()\n" + ) + (project / "input.txt").write_text("accepted") + (project / "artifact.txt").write_text("accepted") + raw = json.loads(document.read_text()) + row = raw["criteria"][0] + row["validation_argv"] = [sys.executable, "check.py"] + row["validation_files"] = [ + {"path": "check.py", "sha256": hashlib.sha256(checker.read_bytes()).hexdigest()} + ] + row["oracle"] = { + "kind": "owner_pinned_oracle", + "source_id": "export-checker", + "input_refs": ["input.txt"], + "output_refs": ["artifact.txt"], + } + document.write_text(json.dumps(raw)) + initial = cli("inspect")[1] + code, configured = cli( + "configure", + "--document", + str(document), + "--expected-provider-revision", + initial["provider_revision"], + "--execute", + ) + assert code == 0, configured + assert ( + configured["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"] + == "unverified" + ) + code, checked = cli("verify", "--execute") + assert code == 0, checked + receipt = checked["goal_acceptance_contract"]["verification"]["results"][0][ + "oracle_evidence" + ] + assert receipt["source_id"] == "export-checker" + assert receipt["input_versions"] == [ + {"ref": "input.txt", "sha256": hashlib.sha256(b"accepted").hexdigest()} + ] + assert receipt["output_versions"] == [ + {"ref": "artifact.txt", "sha256": hashlib.sha256(b"accepted").hexdigest()} + ] + assert receipt["checked_at"].endswith("Z") + current = cli("inspect")[1]["goal_acceptance_contract"] + assert current["criteria"][0]["oracle"]["state"] == "current" + (project / "artifact.txt").write_text("a later unverified version") + stale = cli("inspect")[1]["goal_acceptance_contract"] + assert stale["criteria"][0]["oracle"]["state"] == "stale" + assert stale["verification"]["results"][0]["oracle_evidence"] == receipt + (project / "artifact.txt").write_text("accepted") + checker.write_text("raise SystemExit(0)\n") + assert ( + cli("inspect")[1]["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"] + == "stale" + ) + code, rejected = cli("verify", "--execute") + assert code == 1 and rejected["checks_passed"] is False + + +def test_oracle_http_owner_journey_uses_same_real_authority(acceptance_goal): + import http.client + import threading + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.paths import effective_runtime_root + + project, document, cli, _ = acceptance_goal + # Fixture's source registry is adjacent to the document; this is an isolated + # File/SQLite provider, never the caller's active Goal. + registry = document.parent / "registry.json" + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.registry_path = registry + server.runtime_root = effective_runtime_root(registry, None) + server.runtime_root_override = str(server.runtime_root) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(body=None, *, origin=None): + connection = http.client.HTTPConnection(*server.server_address, timeout=60) + try: + connection.request( + "GET" if body is None else "POST", + "/api/chat/goal-acceptance?goal_id=goal-acceptance" + if body is None + else "/api/chat/goal-acceptance", + None + if body is None + else json.dumps({"goal_id": "goal-acceptance", **body}), + { + "Content-Type": "application/json", + "Origin": origin or f"http://127.0.0.1:{server.server_port}", + }, + ) + result = connection.getresponse() + return result.status, json.loads(result.read()) + finally: + connection.close() + + try: + status, before = request() + assert status == 200 and before["goal_acceptance_contract"] == { + "enabled": False + } + checker = project / "check.py" + checker.write_text( + "from pathlib import Path\nassert Path('artifact.txt').read_text() == 'accepted'\n" + ) + owner_document = json.loads(document.read_text()) + owner_document["criteria"][0].update( + validation_argv=[sys.executable, "check.py"], + validation_files=[ + { + "path": "check.py", + "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(), + } + ], + oracle={ + "kind": "owner_pinned_oracle", + "source_id": "http-owner-checker", + "input_refs": [], + "output_refs": ["artifact.txt"], + }, + ) + config = { + "action": "configure", + "document": owner_document, + "expected_provider_revision": before["provider_revision"], + "operation_id": "http-contract", + } + status, saved = request(config) + assert status == 200 and saved["goal_id"] == "goal-acceptance" + assert ( + saved["goal_acceptance_contract"] + == cli("inspect")[1]["goal_acceptance_contract"] + ) + assert "validation_argv" not in json.dumps(saved) + assert request({**config, "operation_id": "stale-config"})[0] == 409 + assert request({**config, "actor_agent_id": "agent-a"})[0] == 409 + assert ( + request( + { + "action": "verify", + "expected_provider_revision": saved["provider_revision"], + "results": [{"passed": True}], + } + )[0] + == 409 + ) + assert ( + request( + { + "action": "verify", + "expected_provider_revision": saved["provider_revision"], + }, + origin="https://foreign.example", + )[0] + == 403 + ) + (project / "artifact.txt").write_text("accepted") + status, checked = request( + { + "action": "verify", + "expected_provider_revision": saved["provider_revision"], + } + ) + assert status == 200 and checked["checks_passed"] is True, checked + assert ( + checked["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"] + == "current" + ) + assert ( + checked["goal_acceptance_contract"] + == cli("inspect")[1]["goal_acceptance_contract"] + ) + current = request()[1] + status, disabled = request( + { + "action": "disable", + "expected_provider_revision": current["provider_revision"], + "operation_id": "http-disable", + } + ) + assert status == 200 and disabled["goal_acceptance_contract"] == { + "enabled": False + } + assert cli("inspect")[1]["goal_acceptance_contract"] == {"enabled": False} + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.mark.parametrize( + "change", + ["snapshot_output", "live_input", "live_output", "missing_source", "timeout"], +) +def test_oracle_rejects_changed_or_missing_files_and_timeout(acceptance_goal, change): + project, document, cli, _ = acceptance_goal + (project / "input.txt").write_text("expected") + (project / "artifact.txt").write_text("expected") + live = project / ("input.txt" if change == "live_input" else "artifact.txt") + script = "from pathlib import Path\n" + if change == "snapshot_output": + script += "Path('artifact.txt').write_text('changed by checker')\n" + elif change.startswith("live_"): + script += f"Path({str(live)!r}).write_text('changed while checker ran')\n" + elif change == "timeout": + script += "import time\ntime.sleep(5)\n" + else: + script += "Path('artifact.txt').read_text()\n" + checker = project / "check.py" + checker.write_text(script) + doc = json.loads(document.read_text()) + criterion = doc["criteria"][0] + criterion.update( + validation_argv=[sys.executable, "check.py"], + validation_timeout_seconds=1, + validation_files=[ + { + "path": "check.py", + "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(), + } + ], + oracle={ + "kind": "owner_pinned_oracle", + "source_id": "owner-checker", + "input_refs": ["input.txt"], + "output_refs": ["artifact.txt"], + }, + ) + document.write_text(json.dumps(doc)) + before = cli("inspect")[1] + code, result = cli( + "configure", + "--document", + str(document), + "--expected-provider-revision", + before["provider_revision"], + "--execute", + ) + assert code == 0, result + if change == "missing_source": + checker.unlink() + code, result = cli("verify", "--execute") + assert code == 1 and result["checks_passed"] is False, result + check = result["goal_acceptance_contract"]["verification"]["results"][0] + assert check["passed"] is False and "oracle_evidence" not in check + assert ( + cli("inspect")[1]["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"] + != "current" + ) + + +def test_pinned_file_argument_is_not_an_executed_checker(acceptance_goal): + non_interpreter = shutil.which("true") + if non_interpreter is None: + pytest.skip("POSIX true executable is required for this invocation regression") + project, document, cli, _ = acceptance_goal + checker = project / "reject.py" + checker.write_text("raise SystemExit(17)\n") + (project / "artifact.txt").write_text("unchanged output") + raw = json.loads(document.read_text()) + criterion = raw["criteria"][0] + criterion.update( + validation_argv=[sys.executable, "reject.py"], + validation_files=[ + { + "path": "reject.py", + "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(), + } + ], + oracle={ + "kind": "owner_pinned_oracle", + "source_id": "rejecting-checker", + "input_refs": [], + "output_refs": ["artifact.txt"], + }, + ) + document.write_text(json.dumps(raw)) + before = cli("inspect")[1] + code, configured = cli( + "configure", + "--document", + str(document), + "--expected-provider-revision", + before["provider_revision"], + "--execute", + ) + assert code == 0, configured + code, rejected = cli("verify", "--execute") + assert code == 1 and rejected["checks_passed"] is False, rejected + result = rejected["goal_acceptance_contract"]["verification"]["results"][0] + assert result["exit_code"] == 17 and "oracle_evidence" not in result + before = cli("inspect")[1] + criterion["validation_argv"] = [non_interpreter, "reject.py"] + document.write_text(json.dumps(raw)) + code, refused = cli( + "configure", + "--document", + str(document), + "--expected-provider-revision", + before["provider_revision"], + "--execute", + ) + assert code == 1, refused + assert refused["error"] == "goal_acceptance_invalid_request" + assert cli("inspect")[1] == before + + +def test_pinned_direct_executable_checker_runs_from_snapshot(acceptance_goal): + if sys.platform == "win32": + pytest.skip( + "Direct POSIX shebang execution is not a Windows invocation contract" + ) + project, document, cli, _ = acceptance_goal + checker = project / "checks" / "verify" + checker.parent.mkdir() + checker.write_text( + f"#!{sys.executable}\nfrom pathlib import Path\nassert Path('artifact.txt').read_text() == 'accepted'\n" + ) + checker.chmod(0o755) + (project / "artifact.txt").write_text("accepted") + raw = json.loads(document.read_text()) + raw["criteria"][0].update( + validation_argv=["checks/verify"], + validation_files=[ + { + "path": "checks/verify", + "sha256": hashlib.sha256(checker.read_bytes()).hexdigest(), + } + ], + oracle={ + "kind": "owner_pinned_oracle", + "source_id": "direct-checker", + "input_refs": [], + "output_refs": ["artifact.txt"], + }, + ) + document.write_text(json.dumps(raw)) + before = cli("inspect")[1] + code, configured = cli( + "configure", + "--document", + str(document), + "--expected-provider-revision", + before["provider_revision"], + "--execute", + ) + assert code == 0, configured + code, checked = cli("verify", "--execute") + assert code == 0 and checked["checks_passed"] is True, checked + assert ( + "oracle_evidence" + in checked["goal_acceptance_contract"]["verification"]["results"][0] + ) + assert ( + cli("inspect")[1]["goal_acceptance_contract"]["criteria"][0]["oracle"]["state"] + == "current" + ) diff --git a/tests/control_plane/test_goal_acceptance_prepared_action.py b/tests/control_plane/test_goal_acceptance_prepared_action.py new file mode 100644 index 0000000000..265853dc1a --- /dev/null +++ b/tests/control_plane/test_goal_acceptance_prepared_action.py @@ -0,0 +1,141 @@ +"""Agent preparation -> one local-owner check; real File/SQLite authority.""" + +import json +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from loopx.chat_actions import ChatActionService +from loopx.chat_action_store import ActionConflictError, ChatActionStore +from loopx.control_plane.goals import acceptance_actions +from test_goal_acceptance_cli import acceptance_goal as acceptance_goal + + +def prepare(acceptance_goal): + project, document, cli, _ = acceptance_goal + raw = json.loads(document.read_text()) + # The Agent prepares the public-safe runtime alias, never a machine path. + raw["criteria"][0]["validation_argv"] = ["python3", "check.py"] + (project / "check.py").write_text( + "from pathlib import Path\n" + "p=Path('runs.txt')\np.write_text(p.read_text()+'x' if p.exists() else 'x')\n" + "assert Path('artifact.txt').read_text() == 'accepted'\n" + ) + document.write_text(json.dumps(raw)) + (project / "artifact.txt").write_text("accepted") + code, result = cli("prepare", "--agent-id", "agent-a", "--document", str(document), + "--operation-id", "prepare-check", "--execute") + assert code == 0, result + root = project.parent + service = ChatActionService(store=ChatActionStore(root / "runtime/chat/actions"), + registry_path=root / "registry.json") + return project, cli, service, result["proposal"] + + +def test_agent_preparation_does_not_approve_or_execute(acceptance_goal): + project, cli, service, proposal = prepare(acceptance_goal) + assert cli("inspect")[1]["goal_acceptance_contract"] == {"enabled": False} + assert not (project / "runs.txt").exists() + assert proposal["permission_classification"] == "local_command_execution" + result = service.apply(proposal["proposal_id"]) + assert result["proposal"]["status"] == "applied" + assert result["proposal"]["receipt"]["checks_passed"] is True + assert (project / "runs.txt").read_text() == "x" + assert cli("inspect")[1]["goal_acceptance_contract"]["status"] == "accepted" + assert service.apply(proposal["proposal_id"]) == result + assert (project / "runs.txt").read_text() == "x" + + +def test_concurrent_apply_consumes_one_prepared_intent(acceptance_goal): + project, _, service, proposal = prepare(acceptance_goal) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: service.apply(proposal["proposal_id"]), range(2))) + assert all(r["proposal"]["status"] == "applied" for r in results) + assert (project / "runs.txt").read_text() == "x" + + +@pytest.mark.parametrize("lost_step", ["acceptance_configured", "native_check_response"]) +def test_lost_response_recovers_native_receipt_without_rerunning( + acceptance_goal, monkeypatch, lost_step, +): + project, _, service, proposal = prepare(acceptance_goal) + original_save = service.store.save_checkpoint + original_verify = acceptance_actions.verify_goal_acceptance + if lost_step == "acceptance_configured": + def lose_configuration(*args, **kwargs): + if kwargs["step"] == lost_step: + raise RuntimeError("lost configuration response") + return original_save(*args, **kwargs) + monkeypatch.setattr(service.store, "save_checkpoint", lose_configuration) + else: + def lose_native_receipt(**kwargs): + original_verify(**kwargs) + raise RuntimeError("lost check response") + monkeypatch.setattr(acceptance_actions, "verify_goal_acceptance", lose_native_receipt) + with pytest.raises(RuntimeError, match="lost"): + service.apply(proposal["proposal_id"]) + monkeypatch.setattr(service.store, "save_checkpoint", original_save) + monkeypatch.setattr(acceptance_actions, "verify_goal_acceptance", original_verify) + result = service.apply(proposal["proposal_id"]) + assert result["proposal"]["status"] == "applied" + assert (project / "runs.txt").read_text() == "x" + + +def test_missing_native_receipt_does_not_authorize_automatic_retry(acceptance_goal, monkeypatch): + project, cli, service, proposal = prepare(acceptance_goal) + def interrupted(**_kwargs): + raise RuntimeError("host interrupted after execution fence") + monkeypatch.setattr(acceptance_actions, "verify_goal_acceptance", interrupted) + with pytest.raises(RuntimeError): + service.apply(proposal["proposal_id"]) + with pytest.raises(ActionConflictError, match="unconfirmed"): + service.apply(proposal["proposal_id"]) + assert not (project / "runs.txt").exists() + assert cli("inspect")[1]["goal_acceptance_contract"]["status"] == "unverified" + + +def test_stale_prepared_plan_cannot_replace_new_owner_configuration(acceptance_goal): + project, document, cli, _ = acceptance_goal + _, _, service, proposal = prepare(acceptance_goal) + before = cli("inspect")[1] + code, configured = cli("configure", "--document", str(document), + "--expected-provider-revision", before["provider_revision"], + "--operation-id", "another-owner-change", "--execute") + assert code == 0, configured + result = service.apply(proposal["proposal_id"]) + assert result["proposal"]["status"] == "stale" + assert not (project / "runs.txt").exists() + + +def test_prepare_dry_run_and_unknown_agent_do_not_write(acceptance_goal): + project, document, cli, _ = acceptance_goal + before = cli("inspect")[1] + code, preview = cli("prepare", "--agent-id", "agent-a", "--document", str(document), + "--operation-id", "read-only-preparation") + assert code == 0 and preview["executed"] is False and preview["prepared"] is False + assert cli("inspect")[1] == before + assert not (project.parent / "runtime/chat/actions").exists() + code, rejected = cli("prepare", "--agent-id", "unknown", "--document", str(document), + "--operation-id", "unknown-preparation", "--execute") + assert code != 0 and rejected["ok"] is False + assert cli("inspect")[1] == before + + +def test_check_failure_is_recorded_without_goal_completion(acceptance_goal): + project, cli, service, proposal = prepare(acceptance_goal) + (project / "artifact.txt").write_text("not accepted") + applied = service.apply(proposal["proposal_id"])["proposal"] + assert applied["status"] == "applied" + assert applied["receipt"]["checks_passed"] is False + assert cli("inspect")[1]["goal_acceptance_contract"]["status"] == "failed" + assert (project / "runs.txt").read_text() == "x" + + +def test_prepared_plan_cannot_claim_another_goal_context(acceptance_goal): + project, cli, service, proposal = prepare(acceptance_goal) + with pytest.raises(ValueError, match="context must match"): + service.preview({"action_kind": "goal.acceptance.check", "summary": "Wrong context", + "idempotency_key": "wrong-context", "context": {"kind": "goal", "goal_id": "other"}, + "normalized_parameters": proposal["normalized_parameters"]}) + assert cli("inspect")[1]["goal_acceptance_contract"]["enabled"] is False + assert not (project / "runs.txt").exists() diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 5d36220968..89abeb040f 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -124,6 +124,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/effect_runtime_snapshot.ts", "control_plane/goals/acceptance_authority.ts", "control_plane/goals/acceptance_contract.ts", + "control_plane/goals/acceptance_oracle.ts", "control_plane/goals/direction_authority.ts", "control_plane/goals/goal_amendment_authority.ts", "control_plane/goals/goal_amendment_proposal.ts", diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 31e4fac292..28501f092c 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -26,6 +26,8 @@ import {commitGoalAcceptanceVerification, commitLocalGoalAcceptance, commitLocal import {decodeCompletionValidationRevision, planCompletionValidationRevision} from "../../loopx/control_plane/todos/completion_validation_revision.ts"; +import {acceptanceOracleBasis} from "../../loopx/control_plane/goals/acceptance_oracle.ts"; + const goal = "goal-acceptance-test"; const goalA = {goal_id: goal, goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}; const goalB = {goal_id: goal, goal_instance_id: "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}; @@ -231,6 +233,35 @@ async function fixture(t: TestContext, provider: typeof providers[number]): Prom for (const provider of providers) { const options = {skip: (provider === "postgresql" && !process.env.LOOPX_TEST_POSTGRES_URL) || (provider === "sqlite" && !sqliteQualified)}; + test(`${provider}: optional oracle source and exact versions survive canonical commit and revoke on changed work`, options, async t => { + const store = await fixture(t, provider); await seed(store); + const doc = normalizeGoalAcceptanceDocument({scope: {kind: "selected_work", todo_ids: ["todo_first"]}, objective: "Check exact output", non_goals: [], + bindings: [{todo_id: "todo_first", criterion_ids: ["oracle"]}], criteria: [{id: "oracle", description: "Owner-approved checker", + validation_argv: ["python3", "check.py"], validation_files: [{path: "check.py", sha256: "a".repeat(64)}], + oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: [], output_refs: ["output.txt"]}}]}); + assert.equal((await configureGoalAcceptance(store, await configureRequest(store, {document: doc}))).status, "applied"); + const beforeInvalidInvocation = await head(store); + await assert.rejects(configureGoalAcceptance(store, await configureRequest(store, {document: { + ...doc, criteria: [{...doc.criteria[0], validation_argv: ["/usr/bin/true", "check.py"]}], + }})), /oracle requires/); + assert.deepEqual(await head(store), beforeInvalidInvocation, "rejected source invocation must not write canonical state"); + const evidence = {source_id: "owner-checker", source_digest: acceptanceOracleBasis(doc.criteria[0]), checked_at: "2026-01-01T00:00:00Z", + input_versions: [], output_versions: [{ref: "output.txt", sha256: "b".repeat(64)}]}; + const results = [{criterion_id: "oracle", passed: true, exit_code: 0, oracle_evidence: evidence}]; + await assert.rejects(commitGoalAcceptanceVerification(store, await verifyRequest(store, {actor_agent_id: "worker", results}))); + assert.equal((await commitGoalAcceptanceVerification(store, await verifyRequest(store, {results}))).status, "applied"); + const view = (await inspectGoalAcceptance(store, goal)).goal_acceptance_contract as JsonObject; + assert.deepEqual(((view.verification as JsonObject).results as JsonObject[])[0].oracle_evidence, evidence); + assert.equal((((view.criteria as JsonObject[])[0]).oracle as JsonObject).state, "recorded", "canonical rows alone do not attest current filesystem bytes"); + await update(store, "todo_first", {text: "Changed output requirement"}); + const stale = (await inspectGoalAcceptance(store, goal)).goal_acceptance_contract as JsonObject; + assert.equal((((stale.criteria as JsonObject[])[0]).oracle as JsonObject).state, "stale"); + const ordinary = {...doc, criteria: doc.criteria.map(({oracle: _oracle, ...criterion}) => criterion)}; + assert.equal((await configureGoalAcceptance(store, await configureRequest(store, {document: ordinary}))).status, "applied"); + const rolledBack = (await inspectGoalAcceptance(store, goal)).goal_acceptance_contract as JsonObject; + assert.equal(rolledBack.verification, null, "ordinary rollback cannot reuse an oracle success"); + assert.equal(Object.hasOwn((rolledBack.criteria as JsonObject[])[0], "oracle"), false); + }); test(`${provider}: first exact acceptance mutation binds the current instance atomically`, options, async t => { const store = await fixture(t, provider); await seed(store); const request = await configureRequest(store, {goal_ref: goalA}); diff --git a/tests/control_plane_ts/goal_acceptance_oracle.test.ts b/tests/control_plane_ts/goal_acceptance_oracle.test.ts new file mode 100644 index 0000000000..033e76ca6c --- /dev/null +++ b/tests/control_plane_ts/goal_acceptance_oracle.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {normalizeGoalAcceptanceDocument, normalizeAcceptanceResults} from "../../loopx/control_plane/goals/acceptance_contract.ts"; +import {acceptanceOracleBasis, validateOracleResults, observeAcceptanceOracles} from "../../loopx/control_plane/goals/acceptance_oracle.ts"; +const document = () => normalizeGoalAcceptanceDocument({scope: {kind: "selected_work", todo_ids: ["todo_work"]}, objective: "Compare exact output with its expected input", non_goals: [], bindings: [{todo_id: "todo_work", criterion_ids: ["check"]}], criteria: [{id: "check", description: "Owner supplied checker", validation_argv: ["python3", "check.py"], validation_timeout_seconds: 5, validation_files: [{path: "check.py", sha256: "a".repeat(64)}], oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: ["input.txt"], output_refs: ["output.txt"]}}]}); +test("oracle requires a pinned invoked source and distinct explicit artifact roles", () => { + for (const change of [ + {validation_files: []}, {validation_argv: ["python3", "-c", "pass"]}, + {validation_argv: ["check.py"]}, + {validation_argv: ["/usr/bin/true", "check.py"]}, + {validation_argv: ["/usr/bin/cat", "check.py"]}, + {validation_argv: ["not-python3", "check.py"]}, + {validation_argv: ["tools/python3", "check.py"]}, + {validation_argv: ["python3", "--", "check.py"]}, + {validation_argv: ["python3", "-c", "pass"], validation_files: [{path: "-c", sha256: "a".repeat(64)}]}, + {oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: ["output.txt"], output_refs: ["output.txt"]}}, + {oracle: {kind: "owner_pinned_oracle", source_id: "owner-checker", input_refs: [], output_refs: ["../output.txt"]}}, + ]) { + const doc = document(); + assert.throws(() => normalizeGoalAcceptanceDocument({...doc, criteria: [{...doc.criteria[0], ...change}]})); + } +}); +test("oracle admits explicit Python script forms and pinned direct executables", () => { + const doc = document(), criterion = doc.criteria[0]; + for (const executable of ["python", "python3", "/opt/runtime/bin/python3.13", "C:\\Python313\\python.exe"]) { + assert.doesNotThrow(() => normalizeGoalAcceptanceDocument({...doc, criteria: [{...criterion, validation_argv: [executable, "check.py", "--script-argument"]}]})); + } + assert.doesNotThrow(() => normalizeGoalAcceptanceDocument({...doc, criteria: [{...criterion, + validation_argv: ["checks/verify"], validation_files: [{path: "checks/verify", sha256: "a".repeat(64)}]}]})); +}); +test("ordinary criteria keep their command and canonical shape outside oracle invocation rules", () => { + const doc = document(), {oracle: _oracle, ...criterion} = doc.criteria[0]; + const ordinary = {...doc, criteria: [{...criterion, validation_argv: ["/usr/bin/true", "check.py"]}]}; + assert.deepEqual(normalizeGoalAcceptanceDocument(ordinary), ordinary); +}); +test("success cannot be supplied without the exact configured oracle and checked refs", () => { + const criterion = document().criteria[0]; + const evidence = {source_id: "owner-checker", source_digest: acceptanceOracleBasis(criterion), checked_at: "2026-01-01T00:00:00Z", + input_versions: [{ref: "input.txt", sha256: "b".repeat(64)}], output_versions: [{ref: "output.txt", sha256: "c".repeat(64)}]}; + const result = {criterion_id: "check", passed: true, exit_code: 0}; + assert.throws(() => validateOracleResults([criterion], [result])); + for (const patch of [{source_id: "self-asserted-worker"}, {source_digest: "d".repeat(64)}, {output_versions: []}]) { + assert.throws(() => validateOracleResults([criterion], [{...result, oracle_evidence: {...evidence, ...patch}}])); + } + const results = normalizeAcceptanceResults([{...result, oracle_evidence: evidence}]); + assert.doesNotThrow(() => validateOracleResults([criterion], results)); + assert.throws(() => validateOracleResults([{...criterion, oracle: undefined}], results)); + const contract = {enabled: true, criteria: [{id: "check", oracle: {state: "recorded"}}], verification: {results}}; + assert.equal((observeAcceptanceOracles({contract, observations: []}).criteria as any[])[0].oracle.state, "unavailable"); + for (const facts of [ + {source_current: false, ...evidence}, + {source_current: true, ...evidence, output_versions: [{ref: "output.txt", sha256: "d".repeat(64)}]}, + ]) assert.equal((observeAcceptanceOracles({contract, observations: [{criterion_id: "check", available: true, ...facts}]}).criteria as any[])[0].oracle.state, "stale"); + assert.equal((observeAcceptanceOracles({contract, observations: [{criterion_id: "check", available: true, source_current: true, ...evidence}]}).criteria as any[])[0].oracle.state, "current"); +}); diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts index eefa2d3a09..99076da00a 100644 --- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts +++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts @@ -1,3 +1,4 @@ +import {compileActionReviewPlan} from "../../loopx/control_plane/presentation/action_review_plan.ts"; import assert from "node:assert/strict"; import {randomUUID} from "node:crypto"; import {mkdtemp, rm} from "node:fs/promises"; @@ -393,3 +394,18 @@ test("a competing commit cannot separate acceptance evidence from Todo completio assert.equal(((await loaded(store)).head.todos as JsonObject[])[0]!.done, false); assert.equal((await store.readReceipt("complete")).status, "missing"); }); + + +test("prepared check review requires its exact Goal and local command effect", () => { + const proposal = {schema_version: "loopx_chat_action_proposal_v1", action_kind: "goal.acceptance.check", + status: "preview_ready", proposal_id: "proposal", expected_state_fingerprint: "current-basis", + permission_classification: "local_command_execution", normalized_parameters: {goal_id: "goal", document: {}}, + context: {kind: "goal", goal_id: "goal"}, available_transitions: ["apply", "cancel"]}; + assert.equal(compileActionReviewPlan(proposal).canApply, true); + for (const changed of [{permission_classification: "durable_write"}, + {context: {kind: "goal", goal_id: "other"}}, {normalized_parameters: {goal_id: "goal"}}, + {gate: {kind: "authority"}, status: "gated"}, {status: "stale"}]) { + assert.equal(compileActionReviewPlan({...proposal, ...changed}).canApply, false); + } + assert.equal(compileActionReviewPlan({...proposal, status: "applied", receipt: {projection_verified: true}}).canApply, false); +}); diff --git a/tests/test_independent_delegation_validation.py b/tests/test_independent_delegation_validation.py index 2a95e0c059..d0c5bf0b21 100644 --- a/tests/test_independent_delegation_validation.py +++ b/tests/test_independent_delegation_validation.py @@ -317,3 +317,46 @@ def test_selected_unbound_work_cannot_fall_back_to_its_own_validator(service, mo runner.execute("selected-unbound") assert runner.read("selected-unbound")["status"] == "rejected" assert not (root / "analyst" / "initial" / "host-invocations").exists() + + +def test_team_consumes_only_current_exact_owner_oracle_receipt(service, monkeypatch): + from pathlib import Path + from loopx.control_plane.goals.acceptance import verify_goal_acceptance + + root, runner = service + monkeypatch.setattr(runner, "_spawn", lambda _: None) + runner.start("analysis", "oracle-1", brief()) + runner.execute("oracle-1") + assert wait(runner, "oracle-1")["status"] == "accepted" + binding = runner.binding("analysis", require_active=True) + workspace = Path(binding["workspace"]) + # An owner explicitly selects this real project workspace. The public API + # cannot nominate an arbitrary path or manufacture a checker result. + registry = json.loads(runner.registry.read_text()) + next(row for row in registry["goals"] if row["id"] == runner.goal_id)["repo"] = str(workspace) + runner.registry.write_text(json.dumps(registry)) + (workspace / "expected.json").write_bytes((workspace / "output.json").read_bytes()) + checker = workspace / "check.py" + checker.write_text("from pathlib import Path\nassert Path('output.json').read_bytes() == Path('expected.json').read_bytes()\n") + basis = inspect_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id) + document = {"scope": {"kind": "selected_work", "todo_ids": [binding["todo_id"]]}, "objective": "Check this execution's exact result", + "non_goals": [], "bindings": [{"todo_id": binding["todo_id"], "criterion_ids": ["oracle"]}], "criteria": [{ + "id": "oracle", "description": "Owner-approved expected result", "validation_argv": [sys.executable, "check.py"], + "validation_timeout_seconds": 5, "validation_files": [{"path": "check.py", "sha256": sha256(checker.read_bytes()).hexdigest()}], + "oracle": {"kind": "owner_pinned_oracle", "source_id": "expected-result", "input_refs": ["expected.json"], "output_refs": ["output.json"]}}]} + configure_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id, + document=document, expected_provider_revision=basis["provider_revision"], execute=True) + before = runner.read("oracle-1") + assert before["validation"].get("oracle_checks") == [] + checked = verify_goal_acceptance(registry_path=runner.registry, runtime_root=str(runner.root), goal_id=runner.goal_id, execute=True) + assert checked["checks_passed"] is True + current = runner.read("oracle-1") + evidence = current["validation"]["oracle_checks"][0] + assert evidence["source_id"] == "expected-result" + assert evidence["output_versions"] == [{"ref": "output.json", "sha256": current["artifacts"][0]["sha256"]}] + code, cli_read = cli(runner, "read", "--operation-id", "oracle-1") + assert code == 0 and cli_read["validation"]["oracle_checks"] == current["validation"]["oracle_checks"] + (workspace / "expected.json").write_text("unverified source input") + with pytest.raises(ValueError, match="acceptance rejected"): + runner.read("oracle-1") + assert (workspace / "host-invocations").read_text() == "1"