This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { DomainError } from '@devrunbook/domain'
|
||||
|
||||
import {
|
||||
authorizeWorkspaceAction,
|
||||
type AuthenticatedActor,
|
||||
type WorkspaceAuthorizationLookup,
|
||||
} from '../auth/workspace-authorization'
|
||||
import { lintPlaybookPackage } from '../quality/playbook-package-linter'
|
||||
import {
|
||||
evaluateStaticCase,
|
||||
type QualityMatrix,
|
||||
type StaticEvaluationCase,
|
||||
type StaticEvaluationObservation,
|
||||
type StaticEvaluationResult,
|
||||
} from '../quality/static-quality-evaluation'
|
||||
import {
|
||||
parsePrivatePlaybookDraftEtag,
|
||||
type PrivatePlaybookDraftStore,
|
||||
} from './private-playbook-drafts'
|
||||
|
||||
export interface PrivatePlaybookQualityStore {
|
||||
attestReview(attestation: {
|
||||
readonly workspaceId: string
|
||||
readonly versionId: string
|
||||
readonly reviewedBy: string
|
||||
readonly attestedDigest: string
|
||||
readonly schemaAndSemanticValidationPassed: boolean
|
||||
readonly blockingLintFindingCount: number
|
||||
readonly limitationsDocumented: boolean
|
||||
readonly unresolvedSafetyRegression: boolean
|
||||
readonly review: Readonly<Record<string, unknown>>
|
||||
readonly reviewedAt: Date
|
||||
}): Promise<boolean>
|
||||
upsertStaticCase(request: {
|
||||
readonly workspaceId: string
|
||||
readonly versionId: string
|
||||
readonly evaluationCase: StaticEvaluationCase
|
||||
readonly caseDigest: string
|
||||
readonly now: Date
|
||||
}): Promise<string | null>
|
||||
appendStaticResult(request: {
|
||||
readonly workspaceId: string
|
||||
readonly versionId: string
|
||||
readonly logicalCaseId: string
|
||||
readonly fixtureVersion: string
|
||||
readonly result: StaticEvaluationResult
|
||||
readonly environment: Readonly<Record<string, unknown>>
|
||||
readonly executedBy: string
|
||||
readonly now: Date
|
||||
}): Promise<string | null>
|
||||
}
|
||||
|
||||
export interface PrivatePlaybookQualityDependencies {
|
||||
readonly authorization: WorkspaceAuthorizationLookup
|
||||
readonly drafts: PrivatePlaybookDraftStore
|
||||
readonly quality: PrivatePlaybookQualityStore
|
||||
readonly now: () => Date
|
||||
}
|
||||
|
||||
interface QualityActorRequest {
|
||||
readonly actor: AuthenticatedActor | null
|
||||
readonly workspaceId: string
|
||||
readonly versionId: string
|
||||
readonly expectedEtag: string
|
||||
}
|
||||
|
||||
async function authorizeEditor(
|
||||
dependencies: PrivatePlaybookQualityDependencies,
|
||||
request: QualityActorRequest,
|
||||
): Promise<string> {
|
||||
const context = await authorizeWorkspaceAction(dependencies.authorization, {
|
||||
actor: request.actor,
|
||||
workspaceId: request.workspaceId,
|
||||
action: 'write',
|
||||
})
|
||||
return context.userId
|
||||
}
|
||||
|
||||
async function currentDraft(
|
||||
dependencies: PrivatePlaybookQualityDependencies,
|
||||
request: QualityActorRequest,
|
||||
) {
|
||||
const draft = await dependencies.drafts.findVersionForWorkspace(
|
||||
request.workspaceId,
|
||||
request.versionId,
|
||||
)
|
||||
if (!draft)
|
||||
throw new DomainError(
|
||||
'private_playbook_not_found',
|
||||
'Private playbook not found',
|
||||
)
|
||||
const expected = parsePrivatePlaybookDraftEtag(request.expectedEtag)
|
||||
if (
|
||||
expected.revision !== draft.draftRevision ||
|
||||
expected.digest !== draft.draftDigest
|
||||
) {
|
||||
throw new DomainError(
|
||||
'private_playbook_quality_conflict',
|
||||
'The private playbook changed before evidence was recorded',
|
||||
)
|
||||
}
|
||||
if (draft.publishedAt !== null) {
|
||||
throw new DomainError(
|
||||
'private_playbook_published_immutable',
|
||||
'Published playbook versions cannot receive mutable draft evidence',
|
||||
)
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical)
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right, 'en'))
|
||||
.map(([key, child]) => [key, canonical(child)]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(canonical(value)), 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function reviewNote(value: string): string {
|
||||
const note = value.trim()
|
||||
if (note.length < 1 || note.length > 4000) {
|
||||
throw new DomainError(
|
||||
'private_playbook_review_invalid',
|
||||
'Review note must contain between 1 and 4000 characters',
|
||||
)
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
export async function reviewPrivatePlaybookDraft(
|
||||
dependencies: PrivatePlaybookQualityDependencies,
|
||||
request: QualityActorRequest & {
|
||||
readonly limitationsDocumented: boolean
|
||||
readonly unresolvedSafetyRegression: boolean
|
||||
readonly note: string
|
||||
},
|
||||
) {
|
||||
const reviewedBy = await authorizeEditor(dependencies, request)
|
||||
const draft = await currentDraft(dependencies, request)
|
||||
const lint = lintPlaybookPackage({
|
||||
packageJson: draft.packageJson,
|
||||
packageDigest: draft.draftDigest,
|
||||
template: { path: 'template', content: draft.templateText },
|
||||
published: false,
|
||||
})
|
||||
const blockingLintFindingCount = lint.findings.filter(
|
||||
(finding) => finding.severity === 'error',
|
||||
).length
|
||||
const recorded = await dependencies.quality.attestReview({
|
||||
workspaceId: request.workspaceId,
|
||||
versionId: request.versionId,
|
||||
reviewedBy,
|
||||
attestedDigest: draft.draftDigest,
|
||||
schemaAndSemanticValidationPassed: true,
|
||||
blockingLintFindingCount,
|
||||
limitationsDocumented: request.limitationsDocumented,
|
||||
unresolvedSafetyRegression: request.unresolvedSafetyRegression,
|
||||
review: { note: reviewNote(request.note), lint },
|
||||
reviewedAt: dependencies.now(),
|
||||
})
|
||||
if (!recorded)
|
||||
throw new DomainError(
|
||||
'private_playbook_quality_conflict',
|
||||
'The private playbook changed before review evidence was recorded',
|
||||
)
|
||||
return { digest: draft.draftDigest, lint, recorded: true as const }
|
||||
}
|
||||
|
||||
export async function evaluatePrivatePlaybookStaticCase(
|
||||
dependencies: PrivatePlaybookQualityDependencies,
|
||||
request: QualityActorRequest & {
|
||||
readonly evaluationCase: StaticEvaluationCase
|
||||
readonly observation: StaticEvaluationObservation
|
||||
readonly dimensions: QualityMatrix
|
||||
readonly environment: Readonly<Record<string, unknown>>
|
||||
},
|
||||
) {
|
||||
const executedBy = await authorizeEditor(dependencies, request)
|
||||
const draft = await currentDraft(dependencies, request)
|
||||
const target = request.evaluationCase.target
|
||||
if (
|
||||
target.id !== draft.logicalId ||
|
||||
target.version !== draft.semanticVersion ||
|
||||
target.digest !== draft.draftDigest
|
||||
) {
|
||||
throw new DomainError(
|
||||
'private_playbook_evaluation_stale',
|
||||
'Evaluation case target does not match the current draft',
|
||||
)
|
||||
}
|
||||
const caseId = await dependencies.quality.upsertStaticCase({
|
||||
workspaceId: request.workspaceId,
|
||||
versionId: request.versionId,
|
||||
evaluationCase: request.evaluationCase,
|
||||
caseDigest: digest(request.evaluationCase),
|
||||
now: dependencies.now(),
|
||||
})
|
||||
if (!caseId)
|
||||
throw new DomainError(
|
||||
'private_playbook_evaluation_conflict',
|
||||
'The private playbook changed before the evaluation case was stored',
|
||||
)
|
||||
const result = evaluateStaticCase(
|
||||
request.evaluationCase,
|
||||
request.observation,
|
||||
request.dimensions,
|
||||
)
|
||||
const resultId = await dependencies.quality.appendStaticResult({
|
||||
workspaceId: request.workspaceId,
|
||||
versionId: request.versionId,
|
||||
logicalCaseId: request.evaluationCase.id,
|
||||
fixtureVersion: request.evaluationCase.fixture.version,
|
||||
result,
|
||||
environment: request.environment,
|
||||
executedBy,
|
||||
now: dependencies.now(),
|
||||
})
|
||||
if (!resultId)
|
||||
throw new DomainError(
|
||||
'private_playbook_evaluation_conflict',
|
||||
'The private playbook changed before the result was stored',
|
||||
)
|
||||
return { caseId, resultId, result }
|
||||
}
|
||||
Reference in New Issue
Block a user