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/9] 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/9] 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 c2693255751e0145ce742dc58bf68a76023510a4 Mon Sep 17 00:00:00 2001 From: huyz <739820174@qq.com> Date: Thu, 1 Oct 2026 23:34:04 +0800 Subject: [PATCH 3/9] feat(space): document-driven collaboration space UI with acceptance gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a collaboration space (协作区) as a folder in the workspace, where a single Markdown file is the source of truth for both the document view and the structure view. No second data model, no custom DSL. Shared contract (space-doc.v1): - parseSpaceDoc / renderSpaceDoc around two fixed H2 sections, 验收标准 and 任务. The parser is total: any string yields a result, missing sections come back empty. The renderer rewrites only those two sections and keeps every other byte of the document intact, so prose the parser does not understand is never lost. - spaceDocCriteriaCount for cheap progress reads without full parse. Renderer: - SpaceModule: file tree + document/structure view switch + participants and owner + task board + acceptance gate. - AcceptanceGate: per-criterion checkbox with evidence path; 验收通过 stays disabled until every criterion is ticked; 打回 requires a note. Acceptance writes done back into the document, so the verdict is durable in the same file the agent reads. - acceptance-gate-model holds the pure rules (untickedCriteria, canAccept, canReject, buildVerdicts, buildAcceptanceRecord) so the gate is testable without DOM. - useSpaceFiles reads through window.owb.positionDocs / positionDocFile and writes through writePositionDoc; when writing is unavailable the space degrades to read-only instead of throwing. - SpaceHost bridges the shell: derives participants from positionNames with the owner pinned first. Shell wiring: - App.tsx adds "space" to the activeModule union, a rail item, and the render branch. rail.space is the only hard registration point and is covered by i18n.test.tsx. - zh/en locales gain rail.space plus the space.* copy block. Tests: - apps/server/test/space-doc.test.ts (17): round-trip fidelity, fixed point, CRLF, section isolation, heading alias, unknown status reporting. - apps/desktop/renderer/test/space-acceptance-gate.test.ts (11). - apps/desktop/renderer/test/space-module.test.tsx (5). Refs #558 --- apps/desktop/renderer/src/App.tsx | 17 +- .../renderer/src/space/AcceptanceGate.tsx | 134 +++++++ apps/desktop/renderer/src/space/SpaceHost.tsx | 58 +++ .../renderer/src/space/SpaceModule.tsx | 336 +++++++++++++++++ .../src/space/acceptance-gate-model.ts | 92 +++++ apps/desktop/renderer/src/space/index.ts | 16 + .../desktop/renderer/src/space/space-gate.css | 63 ++++ apps/desktop/renderer/src/space/space.css | 293 ++++++++++++++ .../renderer/src/space/useSpaceFiles.ts | 89 +++++ .../test/space-acceptance-gate.test.ts | 116 ++++++ .../renderer/test/space-module.test.tsx | 124 ++++++ apps/server/test/space-doc.test.ts | 179 +++++++++ packages/shared/package.json | 4 + packages/shared/src/index.ts | 1 + packages/shared/src/space-doc.ts | 356 ++++++++++++++++++ packages/ui/src/locales/en.ts | 38 ++ packages/ui/src/locales/zh.ts | 38 ++ 17 files changed, 1952 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/renderer/src/space/AcceptanceGate.tsx create mode 100644 apps/desktop/renderer/src/space/SpaceHost.tsx create mode 100644 apps/desktop/renderer/src/space/SpaceModule.tsx create mode 100644 apps/desktop/renderer/src/space/acceptance-gate-model.ts create mode 100644 apps/desktop/renderer/src/space/index.ts create mode 100644 apps/desktop/renderer/src/space/space-gate.css create mode 100644 apps/desktop/renderer/src/space/space.css create mode 100644 apps/desktop/renderer/src/space/useSpaceFiles.ts create mode 100644 apps/desktop/renderer/test/space-acceptance-gate.test.ts create mode 100644 apps/desktop/renderer/test/space-module.test.tsx create mode 100644 apps/server/test/space-doc.test.ts create mode 100644 packages/shared/src/space-doc.ts diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index 2a018c23..2ff25e9d 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -34,7 +34,7 @@ import type { WorkspaceCreateResponse, WorkspaceInfoResponse, } from "@roleweave/shared"; -import { ChevronsRight, FolderKanban, FolderOpen, Inbox, MessagesSquare, MoreHorizontal, Network, PanelLeft, PencilLine, Plus, Settings, Undo2, X } from "lucide-react"; +import { Boxes, ChevronsRight, FolderKanban, FolderOpen, Inbox, MessagesSquare, MoreHorizontal, Network, PanelLeft, PencilLine, Plus, Settings, Undo2, X } from "lucide-react"; import { useThemeMode, useThemeProfile } from "./theme-toggle"; import { useTheme, ThemeProvider } from "./theme-context"; import { themeToAntdSeed } from "./theme-resolution"; @@ -84,6 +84,7 @@ import { decodeEscapedUnicode } from "./display-text"; import { SettingsModule, type ConfigurationCategory } from "./settings/SettingsModule"; import { GoalsModule } from "./goals/GoalsModule"; import { ProjectManagementModule } from "./projects/ProjectManagementModule"; +import { SpaceHost } from "./space/SpaceHost.js"; import { ProjectSwitcher } from "./project/ProjectSwitcher"; import { ProjectWorkspaceDialog } from "./project/ProjectWorkspaceDialog"; import { assignDefaultAvatars, avatarSrcFor, readAvatarPreferences, type AvatarValue } from "./PositionAvatar"; @@ -135,7 +136,7 @@ function AppInner({ }) { const themeContext = useTheme(); const [activeModule, setActiveModuleRaw] = useState< - "conversation" | "profile" | "org" | "groups" | "reports" | "approvals" | "docs" | "goals" | "projects" | "settings" | "progress" + "conversation" | "profile" | "org" | "groups" | "reports" | "approvals" | "docs" | "goals" | "projects" | "settings" | "progress" | "space" >("conversation"); const [settingsInitialCategory, setSettingsInitialCategory] = useState(); const [contactsOpen, setContactsOpen] = useState(false); @@ -1971,6 +1972,7 @@ function AppInner({ { id: "collaboration", label: t("rail.collaboration"), icon: