From 6f01e749796efc8f512e81900f39d8aa250c62f5 Mon Sep 17 00:00:00 2001 From: huyz <739820174@qq.com> Date: Sat, 26 Sep 2026 00:24:49 +0800 Subject: [PATCH 1/4] fix(services): keep unsaved connection drafts across background refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ConnectionForm sync effect unconditionally reset every field, including a typed PAT, whenever the saved connection values changed. A background services refresh landing while a draft was unsaved could therefore silently discard the user's input, and the next submit went out without a token. Guard the sync effect with a dirty flag: user edits mark the form dirty and win over background refreshes; the flag clears once a save or disconnect accepts the server state. Add a regression test where a refresh lands with changed saved addresses while a PAT is typed — it fails on main and passes with this change. Refs #512 --- .../src/settings/ServiceConnections.tsx | 14 ++++++---- .../test/service-connections.test.tsx | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/desktop/renderer/src/settings/ServiceConnections.tsx b/apps/desktop/renderer/src/settings/ServiceConnections.tsx index dee09bdb..d385debe 100644 --- a/apps/desktop/renderer/src/settings/ServiceConnections.tsx +++ b/apps/desktop/renderer/src/settings/ServiceConnections.tsx @@ -34,12 +34,14 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn const tokenRef = useRef(token); tokenRef.current = token; const busyRef = useRef(false); + const dirtyRef = useRef(false); const savedApiUrl = connection.apiUrl ?? ""; const savedWebUrl = connection.webUrl ?? ""; const savedWorkspaceId = connection.workspaceId ?? ""; const savedTokenConfigured = connection.tokenConfigured; useEffect(() => { + if (dirtyRef.current) return; // an unsaved draft wins over a background refresh setApiUrl(savedApiUrl); setWebUrl(savedWebUrl); setWorkspaceId(savedWorkspaceId); @@ -64,6 +66,7 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn }); } catch { setNotice({ type: "error", key: "services.failed" }); return; } if (response.status !== 200) { setNotice({ type: "error", key: serviceErrorKey(response.body) }); return; } + dirtyRef.current = false; setToken(""); setProbe(null); onChange(response.body); setNotice({ type: "success", key: "services.saved" }); window.dispatchEvent(new Event(SERVICES_CHANGED)); @@ -81,6 +84,7 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn async function disconnect() { const response = await window.owb.services.disconnect(kind); if (response.status !== 200) { setNotice({ type: "error", key: serviceErrorKey(response.body) }); return; } + dirtyRef.current = false; setProbe(null); onChange(response.body); setNotice({ type: "success", key: "services.disconnected" }); window.dispatchEvent(new Event(SERVICES_CHANGED)); @@ -104,27 +108,27 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn {t("services.apiUrl")} setApiUrl(event.target.value)} autoComplete="off" spellCheck={false} /> + onChange={(event) => { dirtyRef.current = true; setApiUrl(event.target.value); }} autoComplete="off" spellCheck={false} /> {kind === "mem" ? : null} {connection.tokenConfigured ? { setClearToken(event.target.checked); setToken(""); }}>{t("services.clearToken")} : null} + onChange={(event) => { dirtyRef.current = true; setClearToken(event.target.checked); setToken(""); }}>{t("services.clearToken")} : null}

{t("services.addressHint")}

: null}
diff --git a/apps/desktop/renderer/test/service-connections.test.tsx b/apps/desktop/renderer/test/service-connections.test.tsx index 79995851..0ea7e0ea 100644 --- a/apps/desktop/renderer/test/service-connections.test.tsx +++ b/apps/desktop/renderer/test/service-connections.test.tsx @@ -106,4 +106,30 @@ describe("independent service connections", () => { await waitFor(() => expect(services.list.mock.calls.length).toBeGreaterThan(2)); expect(screen.getAllByRole("button", { name: "打开 Doc" }).length).toBe(2); }); + + it("keeps a typed PAT when a background refresh lands with changed saved addresses", async () => { + const services = installBridge(); + render(); + const token = await screen.findByLabelText("个人访问令牌(PAT)"); + fireEvent.change(token, { target: { value: "fresh-pat" } }); + services.list.mockImplementation(async () => ({ + status: 200, + body: { + connections: [{ + kind: "doc", + apiUrl: "https://docs.example/v2", + webUrl: "https://docs.example/work2", + configured: true, + tokenConfigured: true, + workspaceId: null, + }], + }, + })); + window.dispatchEvent(new Event("owb:services-changed")); + await waitFor(() => expect(services.list.mock.calls.length).toBeGreaterThan(1)); + expect(token).toHaveValue("fresh-pat"); + fireEvent.click(screen.getByRole("button", { name: "保存连接" })); + await screen.findByText("连接已保存,可以检查服务是否可访问。"); + expect(services.configure).toHaveBeenLastCalledWith(expect.objectContaining({ kind: "doc", token: "fresh-pat" })); + }); }); From b798429e5717c99edb383d3fe352da95a1736bce Mon Sep 17 00:00:00 2001 From: PeterGuy326 Date: Thu, 1 Oct 2026 23:07:20 +0800 Subject: [PATCH 2/4] feat(space): add acceptance.v1 contract for human acceptance verdicts Introduces the acceptance record that lets a human accept or reject an agent's deliverable inside a space. This is deliberately separate from approval: an approval gates permission to act, an acceptance is a verdict on work already done. Agents may only push work to review; only an acceptance may move it to done. Validators are fail-closed in the style of goal.v1: allowlisted keys, closed enums, bounded text, and decision/verdict cross-checks: - accepted requires a verdict for every criterion, all passed - rejected requires a non-empty reason note - criteriaIndex values must be unique and non-negative Contract + tests only; the space/document UI lands in a follow-up once this shape is agreed. Refs #558 --- apps/server/test/acceptance.test.ts | 189 +++++++++++++++ packages/shared/package.json | 4 + packages/shared/src/acceptance.ts | 357 ++++++++++++++++++++++++++++ packages/shared/src/index.ts | 1 + 4 files changed, 551 insertions(+) create mode 100644 apps/server/test/acceptance.test.ts create mode 100644 packages/shared/src/acceptance.ts diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts new file mode 100644 index 00000000..7c50d16c --- /dev/null +++ b/apps/server/test/acceptance.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ACCEPTANCE_SCHEMA_VERSION, + validateAcceptanceCreateRequest, + validateAcceptanceRecord, + type AcceptanceRecord, +} from "@roleweave/shared"; + +function baseRecord(overrides: Record = {}): Record { + return { + schemaVersion: ACCEPTANCE_SCHEMA_VERSION, + acceptanceId: "acc-1", + spaceId: "space-billing", + source: { positionId: "analyst" }, + verdicts: [ + { criteriaIndex: 0, passed: true }, + { criteriaIndex: 1, passed: true, evidencePath: "产出/增量更新.md" }, + ], + decision: "accepted", + decidedBy: "operator", + decidedAt: "2026-10-01T12:00:00.000Z", + ...overrides, + }; +} + +test("accepts a well-formed accepted record", () => { + const result = validateAcceptanceRecord(baseRecord()); + assert.equal(result.ok, true); + if (!result.ok) return; + const value: AcceptanceRecord = result.value; + assert.equal(value.schemaVersion, ACCEPTANCE_SCHEMA_VERSION); + assert.equal(value.decision, "accepted"); + assert.equal(value.verdicts.length, 2); + assert.equal(value.verdicts[1].evidencePath, "产出/增量更新.md"); +}); + +test("rejects an accepted decision when a criterion is not passed", () => { + const result = validateAcceptanceRecord( + baseRecord({ verdicts: [{ criteriaIndex: 0, passed: true }, { criteriaIndex: 1, passed: false }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_incomplete_verdicts"); +}); + +test("rejects an accepted decision with an empty verdict list", () => { + const result = validateAcceptanceRecord(baseRecord({ verdicts: [] })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_incomplete_verdicts"); +}); + +test("rejects a rejected decision with an empty note", () => { + const result = validateAcceptanceRecord( + baseRecord({ decision: "rejected", note: " ", verdicts: [{ criteriaIndex: 0, passed: false }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_missing_note"); +}); + +test("rejects a rejected decision with no note at all", () => { + const result = validateAcceptanceRecord( + baseRecord({ decision: "rejected", verdicts: [{ criteriaIndex: 0, passed: false }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_missing_note"); +}); + +test("keeps a rejected record that carries a reason note", () => { + const result = validateAcceptanceRecord( + baseRecord({ decision: "rejected", note: "criterion 1 has no reproducible evidence", verdicts: [{ criteriaIndex: 0, passed: false }] }), + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.decision, "rejected"); + assert.equal(result.value.note, "criterion 1 has no reproducible evidence"); +}); + +test("rejects duplicate criteriaIndex values", () => { + const result = validateAcceptanceRecord( + baseRecord({ verdicts: [{ criteriaIndex: 0, passed: true }, { criteriaIndex: 0, passed: true }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_duplicate_reference"); +}); + +test("rejects a negative or non-integer criteriaIndex", () => { + for (const criteriaIndex of [-1, 1.5]) { + const result = validateAcceptanceRecord( + baseRecord({ verdicts: [{ criteriaIndex, passed: true }] }), + ); + assert.equal(result.ok, false, `criteriaIndex ${criteriaIndex} should be rejected`); + if (result.ok) continue; + assert.equal(result.code, "acceptance_invalid"); + } +}); + +test("rejects an unsupported schemaVersion", () => { + const result = validateAcceptanceRecord(baseRecord({ schemaVersion: "acceptance.v2" })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid"); +}); + +test("rejects unknown top-level keys", () => { + const result = validateAcceptanceRecord(baseRecord({ surprise: true })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_unknown_field"); +}); + +test("rejects a decision outside the allowlist", () => { + const result = validateAcceptanceRecord(baseRecord({ decision: "maybe" })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid_enum"); +}); + +test("rejects a source with an unexpected field", () => { + const result = validateAcceptanceRecord( + baseRecord({ source: { positionId: "analyst", sneaky: "x" } }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_unknown_field"); +}); + +test("accepts a source carrying turnId and artifactPath", () => { + const result = validateAcceptanceRecord( + baseRecord({ source: { positionId: "analyst", turnId: "turn-9", artifactPath: "产出/增量更新.md" } }), + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.source.turnId, "turn-9"); + assert.equal(result.value.source.artifactPath, "产出/增量更新.md"); +}); + +test("rejects a non-string note", () => { + const result = validateAcceptanceRecord( + baseRecord({ + decision: "rejected", + note: 42, + verdicts: [{ criteriaIndex: 0, passed: false }], + }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid"); +}); + +test("rejects a whitespace-bearing identifier", () => { + const result = validateAcceptanceRecord(baseRecord({ spaceId: "space billing" })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid"); +}); + +test("rejects a missing required field", () => { + const raw = baseRecord(); + delete raw.decidedBy; + const result = validateAcceptanceRecord(raw); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_unknown_field"); +}); + +test("create request mirrors the same decision/verdict consistency", () => { + const rejected = validateAcceptanceCreateRequest({ + spaceId: "space-billing", + source: { positionId: "analyst" }, + verdicts: [{ criteriaIndex: 0, passed: false }], + decision: "accepted", + }); + assert.equal(rejected.ok, false); + if (rejected.ok) return; + assert.equal(rejected.code, "acceptance_incomplete_verdicts"); + + const accepted = validateAcceptanceCreateRequest({ + spaceId: "space-billing", + source: { positionId: "analyst" }, + verdicts: [{ criteriaIndex: 0, passed: true }], + decision: "accepted", + }); + assert.equal(accepted.ok, true); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index c62beb22..ec21dec8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -45,6 +45,10 @@ "types": "./dist/goals.d.ts", "import": "./dist/goals.js" }, + "./acceptance": { + "types": "./dist/acceptance.d.ts", + "import": "./dist/acceptance.js" + }, "./task-board": { "types": "./dist/task-board.d.ts", "import": "./dist/task-board.js" diff --git a/packages/shared/src/acceptance.ts b/packages/shared/src/acceptance.ts new file mode 100644 index 00000000..34cd32c1 --- /dev/null +++ b/packages/shared/src/acceptance.ts @@ -0,0 +1,357 @@ +/** + * Acceptance contracts (v1). + * + * An AcceptanceRecord is the human act of accepting a deliverable produced by + * an agent inside a space. It is deliberately separate from an approval: + * + * - approval -> permission to act, owned by the workspace/organization, + * modelled by ApprovalRecord (packages/shared/src/approvals.ts) + * - acceptance -> a verdict on work already done, owned by the space + * + * The distinction matters because an agent may only ever push work to `review`. + * Only an acceptance decision may move it to `done`; the plan side + * (GoalWorkItem) and the execution side (GoalTaskExecution) never overwrite + * each other. + * + * Validators are fail-closed: object keys are allowlisted, enums are closed, + * text is bounded, and the decision is cross-checked against the verdicts. + */ + +export const ACCEPTANCE_SCHEMA_VERSION = "acceptance.v1" as const; + +export const ACCEPTANCE_MAX_ID_LENGTH = 128; +export const ACCEPTANCE_MAX_SHORT_TEXT_LENGTH = 256; +export const ACCEPTANCE_MAX_TEXT_LENGTH = 4_096; +export const ACCEPTANCE_MAX_PATH_LENGTH = 1_024; +export const ACCEPTANCE_MAX_VERDICTS = 64; + +export const acceptanceDecisions = ["accepted", "rejected"] as const; +export type AcceptanceDecision = (typeof acceptanceDecisions)[number]; + +/** Per-criterion verdict. An accepted record must cover every criterion. */ +export interface AcceptanceVerdict { + criteriaIndex: number; + passed: boolean; + /** Optional pointer to the artifact that satisfies the criterion. */ + evidencePath?: string; +} + +export interface AcceptanceSource { + positionId: string; + turnId?: string; + artifactPath?: string; +} + +export interface AcceptanceRecord { + schemaVersion: typeof ACCEPTANCE_SCHEMA_VERSION; + acceptanceId: string; + spaceId: string; + source: AcceptanceSource; + verdicts: AcceptanceVerdict[]; + decision: AcceptanceDecision; + note?: string; + decidedBy: string; + decidedAt: string; +} + +export interface AcceptanceCreateRequest { + spaceId: string; + source: AcceptanceSource; + verdicts: AcceptanceVerdict[]; + decision: AcceptanceDecision; + note?: string; +} + +// ── Validators ────────────────────────────────────────────────────────────── + +export type AcceptanceValidationCode = + | "acceptance_invalid" + | "acceptance_unknown_field" + | "acceptance_empty_string" + | "acceptance_text_too_long" + | "acceptance_invalid_enum" + | "acceptance_duplicate_reference" + | "acceptance_incomplete_verdicts" + | "acceptance_missing_note"; + +export interface AcceptanceValidationFailure { + ok: false; + code: AcceptanceValidationCode; + message: string; +} + +export interface AcceptanceValidationSuccess { + ok: true; + value: T; +} + +export type AcceptanceValidationResult = AcceptanceValidationSuccess | AcceptanceValidationFailure; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function fail(code: AcceptanceValidationCode, message: string): AcceptanceValidationFailure { + return { ok: false, code, message }; +} + +function keysMatch( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = new Set([...required, ...optional]); + return ( + Object.keys(value).every((key) => allowed.has(key)) && + required.every((key) => Object.hasOwn(value, key)) + ); +} + +function nonEmptyText( + value: unknown, + field: string, + maxLength = ACCEPTANCE_MAX_TEXT_LENGTH, +): AcceptanceValidationResult { + if (typeof value !== "string" || value.trim().length === 0) { + return fail("acceptance_empty_string", `${field} must be a non-empty string`); + } + if (value.length > maxLength) { + return fail("acceptance_text_too_long", `${field} exceeds its text bound`); + } + if ([...value].some((ch) => ch.charCodeAt(0) < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t")) { + return fail("acceptance_invalid", `${field} contains a control character`); + } + return { ok: true, value }; +} + +function identifier(value: unknown, field: string): AcceptanceValidationResult { + const result = nonEmptyText(value, field, ACCEPTANCE_MAX_SHORT_TEXT_LENGTH); + if (!result.ok) return result; + if (/\s/.test(result.value)) { + return fail("acceptance_invalid", `${field} must not contain whitespace`); + } + return result; +} + +function pathText(value: unknown, field: string): AcceptanceValidationResult { + return nonEmptyText(value, field, ACCEPTANCE_MAX_PATH_LENGTH); +} + +function enumValue( + value: unknown, + values: readonly T[], + field: string, +): AcceptanceValidationResult { + if (typeof value !== "string" || !values.includes(value as T)) { + return fail("acceptance_invalid_enum", `${field} is outside the allowlist`); + } + return { ok: true, value: value as T }; +} + +function iso8601(value: unknown, field: string): AcceptanceValidationResult { + if (typeof value !== "string") return fail("acceptance_invalid", `${field} must be a string`); + if (value.length > ACCEPTANCE_MAX_SHORT_TEXT_LENGTH) { + return fail("acceptance_text_too_long", `${field} exceeds its text bound`); + } + if (Number.isNaN(Date.parse(value))) { + return fail("acceptance_invalid", `${field} is not a valid ISO-8601 timestamp`); + } + return { ok: true, value }; +} + +function validateSource(raw: unknown, field: string): AcceptanceValidationResult { + if (!isRecord(raw) || !keysMatch(raw, ["positionId"], ["turnId", "artifactPath"])) { + return fail("acceptance_unknown_field", `${field} has unexpected or missing fields`); + } + const positionId = identifier(raw.positionId, `${field}.positionId`); + if (positionId.ok === false) return fail(positionId.code, positionId.message); + + const source: AcceptanceSource = { positionId: positionId.value }; + + if (raw.turnId !== undefined) { + const turnId = identifier(raw.turnId, `${field}.turnId`); + if (turnId.ok === false) return fail(turnId.code, turnId.message); + source.turnId = turnId.value; + } + if (raw.artifactPath !== undefined) { + const artifactPath = pathText(raw.artifactPath, `${field}.artifactPath`); + if (artifactPath.ok === false) return fail(artifactPath.code, artifactPath.message); + source.artifactPath = artifactPath.value; + } + return { ok: true, value: source }; +} + +function validateVerdicts(raw: unknown): AcceptanceValidationResult { + if (!Array.isArray(raw)) return fail("acceptance_invalid", "verdicts must be an array"); + if (raw.length > ACCEPTANCE_MAX_VERDICTS) { + return fail("acceptance_invalid", `verdicts has too many items (max ${ACCEPTANCE_MAX_VERDICTS})`); + } + const seen = new Set(); + const verdicts: AcceptanceVerdict[] = []; + for (let index = 0; index < raw.length; index += 1) { + const item = raw[index]; + const field = `verdicts[${index}]`; + if (!isRecord(item) || !keysMatch(item, ["criteriaIndex", "passed"], ["evidencePath"])) { + return fail("acceptance_unknown_field", `${field} has unexpected or missing fields`); + } + if ( + typeof item.criteriaIndex !== "number" || + !Number.isInteger(item.criteriaIndex) || + item.criteriaIndex < 0 + ) { + return fail("acceptance_invalid", `${field}.criteriaIndex must be a non-negative integer`); + } + if (seen.has(item.criteriaIndex)) { + return fail("acceptance_duplicate_reference", `${field}.criteriaIndex is a duplicate`); + } + seen.add(item.criteriaIndex); + if (typeof item.passed !== "boolean") { + return fail("acceptance_invalid", `${field}.passed must be a boolean`); + } + const verdict: AcceptanceVerdict = { criteriaIndex: item.criteriaIndex, passed: item.passed }; + if (item.evidencePath !== undefined) { + const evidencePath = pathText(item.evidencePath, `${field}.evidencePath`); + if (!evidencePath.ok) return evidencePath; + verdict.evidencePath = evidencePath.value; + } + verdicts.push(verdict); + } + return { ok: true, value: verdicts }; +} + +/** + * Enforce the decision/verdicts cross-checks that keep an accepted record + * meaningful: + * - accepted requires a non-empty verdict list, all `passed: true` + * - rejected requires a non-empty reason note + * + * `note` is taken raw so a blank note on a rejection is reported as the + * specific `acceptance_missing_note` rather than a generic empty string. + */ +function checkDecisionConsistency( + decision: AcceptanceDecision, + verdicts: AcceptanceVerdict[], + note: string | undefined, +): { ok: true } | { ok: false; code: AcceptanceValidationCode; message: string } { + if (decision === "accepted") { + if (verdicts.length === 0 || verdicts.some((verdict) => !verdict.passed)) { + return fail( + "acceptance_incomplete_verdicts", + "an accepted record requires every criterion to be present and passed", + ); + } + return { ok: true }; + } + // rejected + if (note === undefined || note.trim().length === 0) { + return fail("acceptance_missing_note", "a rejected record requires a non-empty note"); + } + return { ok: true }; +} + +/** + * A rejection note is read raw so the decision/verdict consistency check owns + * the "blank reason" verdict; a present-but-unparsable note still fails loudly. + */ +function parseNote( + raw: unknown, + fallbackField: string, +): AcceptanceValidationResult { + if (raw === undefined) return { ok: true, value: undefined }; + if (typeof raw !== "string") return fail("acceptance_invalid", `${fallbackField} must be a string`); + if (raw.trim().length === 0) return { ok: true, value: raw }; + if (raw.length > ACCEPTANCE_MAX_TEXT_LENGTH) { + return fail("acceptance_text_too_long", `${fallbackField} exceeds its text bound`); + } + if ([...raw].some((ch) => ch.charCodeAt(0) < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t")) { + return fail("acceptance_invalid", `${fallbackField} contains a control character`); + } + return { ok: true, value: raw }; +} + +export function validateAcceptanceRecord(raw: unknown): AcceptanceValidationResult { + if ( + !isRecord(raw) || + !keysMatch( + raw, + ["schemaVersion", "acceptanceId", "spaceId", "source", "verdicts", "decision", "decidedBy", "decidedAt"], + ["note"], + ) + ) { + return fail("acceptance_unknown_field", "acceptance has unexpected or missing fields"); + } + if (raw.schemaVersion !== ACCEPTANCE_SCHEMA_VERSION) { + return fail("acceptance_invalid", "acceptance.schemaVersion is not supported"); + } + const acceptanceId = identifier(raw.acceptanceId, "acceptance.acceptanceId"); + if (!acceptanceId.ok) return acceptanceId; + const spaceId = identifier(raw.spaceId, "acceptance.spaceId"); + if (!spaceId.ok) return spaceId; + const source = validateSource(raw.source, "acceptance.source"); + if (!source.ok) return source; + const verdicts = validateVerdicts(raw.verdicts); + if (!verdicts.ok) return verdicts; + const decision = enumValue(raw.decision, acceptanceDecisions, "acceptance.decision"); + if (!decision.ok) return decision; + const decidedBy = identifier(raw.decidedBy, "acceptance.decidedBy"); + if (!decidedBy.ok) return decidedBy; + const decidedAt = iso8601(raw.decidedAt, "acceptance.decidedAt"); + if (!decidedAt.ok) return decidedAt; + + const parsedNote = parseNote(raw.note, "acceptance.note"); + if (!parsedNote.ok) return parsedNote; + const note = parsedNote.value; + + const consistency = checkDecisionConsistency(decision.value, verdicts.value, note); + if (!consistency.ok) return fail(consistency.code, consistency.message); + + return { + ok: true, + value: { + schemaVersion: ACCEPTANCE_SCHEMA_VERSION, + acceptanceId: acceptanceId.value, + spaceId: spaceId.value, + source: source.value, + verdicts: verdicts.value, + decision: decision.value, + ...(note !== undefined ? { note } : {}), + decidedBy: decidedBy.value, + decidedAt: decidedAt.value, + }, + }; +} + +export function validateAcceptanceCreateRequest(raw: unknown): AcceptanceValidationResult { + if ( + !isRecord(raw) || + !keysMatch(raw, ["spaceId", "source", "verdicts", "decision"], ["note"]) + ) { + return fail("acceptance_unknown_field", "create request has unexpected or missing fields"); + } + const spaceId = identifier(raw.spaceId, "spaceId"); + if (!spaceId.ok) return spaceId; + const source = validateSource(raw.source, "source"); + if (!source.ok) return source; + const verdicts = validateVerdicts(raw.verdicts); + if (!verdicts.ok) return verdicts; + const decision = enumValue(raw.decision, acceptanceDecisions, "decision"); + if (!decision.ok) return decision; + + const parsedNote = parseNote(raw.note, "note"); + if (!parsedNote.ok) return parsedNote; + const note = parsedNote.value; + + const consistency = checkDecisionConsistency(decision.value, verdicts.value, note); + if (!consistency.ok) return fail(consistency.code, consistency.message); + + return { + ok: true, + value: { + spaceId: spaceId.value, + source: source.value, + verdicts: verdicts.value, + decision: decision.value, + ...(note !== undefined ? { note } : {}), + }, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c0c4f19e..64134633 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -26,6 +26,7 @@ export * from "./drive.js"; export * from "./context-sources.js"; export * from "./updates.js"; export * from "./goals.js"; +export * from "./acceptance.js"; export * from "./task-board.js"; export * from "./relationship-graph.js"; export * from "./avatar.js"; From b0fa6209112a3c89f7fc9a4937bdc3871791170c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:47:06 +0800 Subject: [PATCH 3/4] fix(space): narrow verdict index access for noUncheckedIndexedAccess Same class of error as the space-doc suite: the base tsconfig sets noUncheckedIndexedAccess and apps/server/tsconfig.json includes test/**, so `verdicts[1].evidencePath` is a possibly-undefined read. CI caught it on #560; this branch carries the same line. Refs #558 --- apps/server/test/acceptance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts index 7c50d16c..bca47d7f 100644 --- a/apps/server/test/acceptance.test.ts +++ b/apps/server/test/acceptance.test.ts @@ -32,7 +32,7 @@ test("accepts a well-formed accepted record", () => { assert.equal(value.schemaVersion, ACCEPTANCE_SCHEMA_VERSION); assert.equal(value.decision, "accepted"); assert.equal(value.verdicts.length, 2); - assert.equal(value.verdicts[1].evidencePath, "产出/增量更新.md"); + assert.equal(value.verdicts[1]?.evidencePath, "产出/增量更新.md"); }); test("rejects an accepted decision when a criterion is not passed", () => { From 1a19cd5e45d49c78d90301b39b8cc9b1b3b52f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:52:39 +0800 Subject: [PATCH 4/4] fix(packaging): ship acceptance.js in the runtime allowlist SHARED_RUNTIME_FILES must match packages/shared/src in order, and RUNTIME_FILE_SETS is what actually gets packaged. Without this entry the acceptance contract would be missing from the built app. Refs #558 --- apps/desktop/packaging/runtime-layout.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index ba1d12a4..2c4f9e82 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -160,6 +160,7 @@ const SHARED_RUNTIME_FILES = [ "pending-approval.cjs", "position-id.cjs", "turn-engines.cjs", + "dist/acceptance.js", "dist/agent-binding.js", "dist/api.js", "dist/approval-preview.js",