Files
Agent-System-Frontend/src/duties/draft.ts
T
weichenandCursor 985fc0f931 Adopt the server duty draft when a save result is unknown
Why: a lost write response was keeping the old duty version, so the next save replayed it, and a busy save could stay locked if another request advanced the sequence.
Evidence: SELF vitest duty-draft 6 passed, tsc passed, knip 43, Playwright duty flows 3 passed.
Impact: an unknown save reloads server roles, confirmation resets, and busy clears even if a later read is discarded.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:06:24 +00:00

198 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
author: string
reviewers: string[]
approver: string
}
export interface DutyDocDraft extends DutyRoles {
docId: string
title: string
code: string
typeKey: string
stage: string
dutyVersion: number
activeRound: number
legacySectionOverrides: number
dirty: boolean
}
export interface DutyMember {
owner: string
role: string
valid: boolean
canAuthor: boolean
canReview: boolean
canApprove: boolean
}
interface DutySaveItem {
docId: string
author: string
reviewers: string[]
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
if (roles.author === owner) return 'author'
if (roles.approver === owner) return 'approver'
if (roles.reviewers.includes(owner)) return 'reviewer'
return 'none'
}
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
roles: DutyRoles
replacedAuthor: string
replacedApprover: string
} {
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
let author = roles.author === owner ? '' : roles.author
let approver = roles.approver === owner ? '' : roles.approver
let reviewers = roles.reviewers.filter(person => person !== owner)
switch (duty) {
case 'author':
author = owner
break
case 'approver':
approver = owner
break
case 'reviewer':
reviewers = [...reviewers, owner]
break
case 'none':
break
default: {
const exhaustive: never = duty
throw new Error(exhaustive)
}
}
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
}
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
return ''
}
function dutyConflicts(roles: DutyRoles): number {
const seen = new Map<string, number>()
const mark = (owner: string) => {
if (!owner) return
seen.set(owner, (seen.get(owner) ?? 0) + 1)
}
mark(roles.author)
mark(roles.approver)
for (const reviewer of roles.reviewers) mark(reviewer)
let conflicts = 0
for (const count of seen.values()) if (count > 1) conflicts += 1
return conflicts
}
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
return {
authors: roles.author ? 1 : 0,
reviewers: roles.reviewers.length,
approvers: roles.approver ? 1 : 0,
conflicts: dutyConflicts(roles),
}
}
export function saveIssues(roles: DutyRoles): string[] {
const issues: string[] = []
if (!roles.author) issues.push('需指定编制人')
if (roles.reviewers.length === 0) issues.push('审核至少一人')
if (!roles.approver) issues.push('需指定批准人')
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
return issues
}
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
for (const cell of cells) {
if (cell.docId !== docId) continue
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
const next = applyCell(roles, cell.owner, cell.duty)
roles.author = next.roles.author
roles.reviewers = next.roles.reviewers
roles.approver = next.roles.approver
}
return roles
}
export function absorbServerDocs(
current: DutyDocDraft[],
docs: {
docId: string; title: string; code?: string; typeKey: string; stage?: string
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
}[],
cells: { docId: string; owner: string; duty: string }[],
replaceDirty = false,
): DutyDocDraft[] {
const byId = new Map(current.map(doc => [doc.docId, doc]))
const pageIds = new Set(docs.map(doc => doc.docId))
const next: DutyDocDraft[] = []
for (const doc of docs) {
const previous = byId.get(doc.docId)
const serverRoles = rolesFromCells(doc.docId, cells)
const keepDraft = !replaceDirty && previous?.dirty === true
next.push({
docId: doc.docId,
title: doc.title,
code: doc.code ?? doc.docId,
typeKey: doc.typeKey,
stage: doc.stage ?? '',
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
activeRound: doc.activeRound ?? 0,
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
dirty: keepDraft,
author: keepDraft ? previous.author : serverRoles.author,
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
approver: keepDraft ? previous.approver : serverRoles.approver,
})
}
for (const doc of current) {
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
}
return next
}
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
return docs.map(doc => {
if (doc.docId !== docId) return doc
const next = applyCell(doc, owner, duty)
return { ...doc, ...next.roles, dirty: true }
})
}
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
for (const doc of dirty) {
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => ({
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
})),
issues,
}
}