227 lines
7.3 KiB
TypeScript
227 lines
7.3 KiB
TypeScript
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
import type {
|
|
CanonicalPromptRequest,
|
|
PlaybookMetadata,
|
|
PlaybookSpecification,
|
|
RepositoryProfile,
|
|
} from '@devrunbook/composer'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { parse } from 'yaml'
|
|
|
|
import { composeAndCreateGeneratedRun } from './compose-and-create-generated-run'
|
|
import {
|
|
computeRenderDigest,
|
|
type GeneratedRun,
|
|
type GeneratedRunStore,
|
|
type ImmutableJsonObject,
|
|
} from '../generated-runs/create-generated-run'
|
|
|
|
class RecordingStore implements GeneratedRunStore {
|
|
candidate: GeneratedRun | undefined
|
|
|
|
async createIdempotently(candidate: GeneratedRun) {
|
|
this.candidate = candidate
|
|
return { run: candidate, created: true }
|
|
}
|
|
}
|
|
|
|
const snapshots = {
|
|
playbook: { slug: 'bounded-change', version: '1.0.0' },
|
|
repositoryProfile: null,
|
|
normalizedInput: { request: 'Implement the bounded change' },
|
|
policy: { autonomyLevel: 'verify', conditionsResolved: true },
|
|
provenance: [{ block: 'mission', source: 'playbook' }],
|
|
} as const
|
|
|
|
function dependencies(store: GeneratedRunStore) {
|
|
return {
|
|
store,
|
|
nextId: () => 'run-1',
|
|
now: () => new Date('2026-07-27T12:00:00.000Z'),
|
|
workspaceAuthorization: {
|
|
findWorkspaceAuthorization: async (
|
|
userId: string,
|
|
workspaceId: string,
|
|
) => ({
|
|
userId,
|
|
workspaceId,
|
|
instanceRole: 'user' as const,
|
|
workspaceRole: 'editor' as const,
|
|
userStatus: 'active' as const,
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('composeAndCreateGeneratedRun', () => {
|
|
it('renders once and immediately persists the exact bytes and verified digest', async () => {
|
|
const store = new RecordingStore()
|
|
const result = await composeAndCreateGeneratedRun(dependencies(store), {
|
|
prompt: {
|
|
metadata: {
|
|
slug: 'bounded-change',
|
|
version: '1.0.0',
|
|
title: 'Bounded change',
|
|
},
|
|
specification: {
|
|
intent: { outcome: 'Implement only the requested change.' },
|
|
guardrails: [{ text: 'Do not broaden scope.' }],
|
|
workflow: [
|
|
{
|
|
title: 'Implement',
|
|
instruction: 'Make the smallest coherent change.',
|
|
},
|
|
],
|
|
completion: { criteria: ['Targeted validation passes.'] },
|
|
},
|
|
template: '# Context\n\nRequest: {{ inputs.request }}',
|
|
inputs: { request: 'Implement the bounded change' },
|
|
workMode: 'execute',
|
|
autonomyLevel: 'verify',
|
|
},
|
|
snapshots,
|
|
lint: { exportReadiness: 'ready', findings: [] },
|
|
generatedBy: 'user-1',
|
|
workspaceId: 'workspace-1',
|
|
playbookVersionId: 'playbook-version-1',
|
|
idempotencyKey: 'compose-1',
|
|
})
|
|
|
|
expect(result.created).toBe(true)
|
|
expect(store.candidate).toStrictEqual(result.run)
|
|
expect(result.run.renderedPrompt).toContain(
|
|
'Request: Implement the bounded change',
|
|
)
|
|
expect(result.run.renderDigest).toBe(
|
|
computeRenderDigest(result.run.renderedPrompt),
|
|
)
|
|
expect(Object.isFrozen(result.run.snapshots.policy)).toBe(true)
|
|
})
|
|
|
|
it('loads the root-cause inputs and persists golden prompt bytes unchanged', async () => {
|
|
const repositoryRoot = path.resolve(import.meta.dirname, '../../../..')
|
|
const playbookRoot = path.join(
|
|
repositoryRoot,
|
|
'content/playbooks/root-cause-bugfix',
|
|
)
|
|
const playbook = parse(
|
|
await readFile(path.join(playbookRoot, 'playbook.yaml'), 'utf8'),
|
|
) as {
|
|
metadata: PlaybookMetadata
|
|
spec: PlaybookSpecification & { template: { main: string } }
|
|
}
|
|
const example = parse(
|
|
await readFile(path.join(playbookRoot, 'examples/minimal.yaml'), 'utf8'),
|
|
) as {
|
|
workMode: string
|
|
autonomyLevel: CanonicalPromptRequest['autonomyLevel']
|
|
inputs: CanonicalPromptRequest['inputs'] & ImmutableJsonObject
|
|
}
|
|
const profile = parse(
|
|
await readFile(
|
|
path.join(
|
|
repositoryRoot,
|
|
'examples/repository-profiles/example-profile.yaml',
|
|
),
|
|
'utf8',
|
|
),
|
|
) as RepositoryProfile
|
|
const golden = await readFile(
|
|
path.join(
|
|
repositoryRoot,
|
|
'examples/rendered-prompts/root-cause-bugfix.md',
|
|
),
|
|
'utf8',
|
|
)
|
|
const store = new RecordingStore()
|
|
|
|
const result = await composeAndCreateGeneratedRun(dependencies(store), {
|
|
prompt: {
|
|
metadata: playbook.metadata,
|
|
specification: playbook.spec,
|
|
template: await readFile(
|
|
path.join(playbookRoot, playbook.spec.template.main),
|
|
'utf8',
|
|
),
|
|
inputs: example.inputs,
|
|
workMode: example.workMode,
|
|
autonomyLevel: example.autonomyLevel,
|
|
repositoryProfile: profile,
|
|
},
|
|
snapshots: {
|
|
playbook: {
|
|
slug: playbook.metadata.slug,
|
|
version: playbook.metadata.version,
|
|
},
|
|
repositoryProfile: { name: profile.metadata.name, revision: 1 },
|
|
normalizedInput: example.inputs,
|
|
policy: { autonomyLevel: example.autonomyLevel },
|
|
provenance: [],
|
|
},
|
|
lint: { exportReadiness: 'ready', findings: [] },
|
|
generatedBy: 'fixture-user',
|
|
workspaceId: 'fixture-workspace',
|
|
playbookVersionId: 'fixture-playbook-version',
|
|
idempotencyKey: 'root-cause-golden',
|
|
})
|
|
|
|
expect(result.run.renderedPrompt).toBe(golden)
|
|
expect(store.candidate?.renderedPrompt).toBe(golden)
|
|
expect(result.run.renderDigest).toBe(computeRenderDigest(golden))
|
|
})
|
|
|
|
it('rejects invalid declared inputs, modes, autonomy and missing repositories before storage', async () => {
|
|
const store = new RecordingStore()
|
|
await expect(
|
|
composeAndCreateGeneratedRun(dependencies(store), {
|
|
prompt: {
|
|
metadata: { slug: 'strict', version: '1.0.0', title: 'Strict' },
|
|
specification: {
|
|
intent: { outcome: 'Validate first.' },
|
|
modes: ['execute'],
|
|
autonomy: { min: 'implement', max: 'verify', default: 'verify' },
|
|
inputs: [
|
|
{
|
|
key: 'request',
|
|
type: 'string',
|
|
required: true,
|
|
minLength: 3,
|
|
},
|
|
],
|
|
compatibility: { repositoryRequired: true },
|
|
},
|
|
template: '{{ inputs.request }}',
|
|
inputs: { request: '' },
|
|
workMode: 'inspect',
|
|
autonomyLevel: 'repair',
|
|
},
|
|
snapshots: {
|
|
...snapshots,
|
|
normalizedInput: { request: 'different' },
|
|
policy: { autonomyLevel: 'observe' },
|
|
},
|
|
lint: { exportReadiness: 'ready', findings: [] },
|
|
generatedBy: 'user-1',
|
|
workspaceId: 'workspace-1',
|
|
playbookVersionId: 'playbook-version-1',
|
|
idempotencyKey: 'strict-invalid',
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'composition_input_invalid',
|
|
details: {
|
|
issues: expect.arrayContaining([
|
|
'workMode is not supported by this playbook',
|
|
'autonomyLevel is outside the playbook autonomy range',
|
|
'repositoryProfile is required by this playbook',
|
|
'inputs.request must not be empty',
|
|
'snapshots.normalizedInput must match the composed inputs',
|
|
'snapshots.policy.autonomyLevel must match the composed autonomy level',
|
|
]),
|
|
},
|
|
})
|
|
expect(store.candidate).toBeUndefined()
|
|
})
|
|
})
|