237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
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 }
|
|
}
|