diff --git a/docs/SQLITE-SCHEMA.md b/docs/SQLITE-SCHEMA.md index 8af9dd6a..c9ef5350 100644 --- a/docs/SQLITE-SCHEMA.md +++ b/docs/SQLITE-SCHEMA.md @@ -1072,13 +1072,15 @@ state back to `.veritas-kanban/*.json` or telemetry NDJSON files. | `telemetry_events` | Complete telemetry events plus type, task, project, token, duration, and result columns. | | `run_events` | Complete `run-event/v1` envelopes plus ordered attempt cursor, provider identity, dedupe, and receive columns. | | `run_supervisors` | Complete `run-supervisor/v1` snapshots plus task/attempt, state, revision, lease owner/expiry, and recovery indexes. | +| `durable_goals` | Complete `durable-goal/v1` objective state, root task/workflow identity, compare-and-set revision, blockers, continuation chain, usage, and completion-evidence requirements. | | `admission_reservations` | Versioned capacity and execution-tree budget reservations plus task/workspace/root/provider/host scopes, root objective/node/parent indexes, lease state, revision, and idempotency evidence. | `ActivityService`, `StatusHistoryService`, `TelemetryService`, -`RunEventJournalService`, `RunSupervisorService`, and `AdmissionControlService` -select these SQLite repositories when `VERITAS_STORAGE=sqlite`. File storage -still forces the file-backed services to `storageType='file'`, so explicit file -mode cannot be accidentally flipped by the environment. +`RunEventJournalService`, `RunSupervisorService`, `DurableGoalService`, and +`AdmissionControlService` select these SQLite repositories when +`VERITAS_STORAGE=sqlite`. File storage still forces the file-backed services to +`storageType='file'`, so explicit file mode cannot be accidentally flipped by +the environment. Dashboard metric aggregation uses the same active storage backend. In SQLite mode, `/metrics/all`, `/metrics/trends`, agent comparison, task cost, and diff --git a/server/src/__tests__/durable-goal-repository.test.ts b/server/src/__tests__/durable-goal-repository.test.ts new file mode 100644 index 00000000..67a297ed --- /dev/null +++ b/server/src/__tests__/durable-goal-repository.test.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DurableGoalService } from '../services/durable-goal-service.js'; +import { FileDurableGoalRepository } from '../storage/durable-goal-repository.js'; +import type { DurableGoalRepository } from '../storage/interfaces.js'; +import { SqliteDatabase } from '../storage/sqlite/database.js'; +import { SqliteDurableGoalRepository } from '../storage/sqlite/durable-goal-repository.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('durable goal repository parity', () => { + it('persists compare-and-set goal state through a file repository restart', async () => { + const root = await temporaryRoot(); + const filePath = path.join(root, 'durable-goals.jsonl'); + + await exerciseRepository( + new FileDurableGoalRepository(filePath), + async () => new FileDurableGoalRepository(filePath) + ); + }); + + it('persists compare-and-set goal state through a SQLite restart', async () => { + const root = await temporaryRoot(); + const databasePath = path.join(root, 'veritas.db'); + let database = new SqliteDatabase({ databasePath }); + database.open(); + + await exerciseRepository(new SqliteDurableGoalRepository(database), async () => { + database.close(); + database = new SqliteDatabase({ databasePath }); + database.open(); + return new SqliteDurableGoalRepository(database); + }); + + database.close(); + }); +}); + +async function exerciseRepository( + repository: DurableGoalRepository, + reopen: () => Promise +): Promise { + const service = new DurableGoalService({ + repository, + now: () => new Date('2026-07-26T02:00:00.000Z'), + }); + const created = await service.create({ + id: 'goal_0123456789abcdef', + workspaceId: 'workspace-a', + objective: 'Persist this objective across restart.', + acceptanceCriteria: ['The resumed service reads revision two.'], + root: { kind: 'workflow', workflowId: 'workflow-865', taskId: 'task-865' }, + continuation: { mode: 'manual' }, + completionRequirements: [ + { + id: 'restart-evidence', + description: 'Restart recovery is verified.', + required: true, + verificationKind: 'test', + }, + ], + }); + const paused = await service.transition(created.id, { + expectedRevision: created.revision, + to: 'paused', + actorId: 'operator-brad', + reason: 'Exercise durable compare-and-set state.', + }); + + expect(paused).toMatchObject({ state: 'paused', revision: 2 }); + expect( + await repository.list({ + workspaceId: 'workspace-a', + states: ['paused'], + rootTaskId: 'task-865', + rootWorkflowId: 'workflow-865', + }) + ).toEqual([paused]); + + const restartedRepository = await reopen(); + const restarted = new DurableGoalService({ + repository: restartedRepository, + now: () => new Date('2026-07-26T02:01:00.000Z'), + }); + expect(await restarted.get(created.id)).toEqual(paused); + expect( + await restarted.transition(created.id, { + expectedRevision: paused.revision, + to: 'active', + actorId: 'operator-brad', + reason: 'Resume after restart.', + }) + ).toMatchObject({ state: 'active', revision: 3 }); +} + +async function temporaryRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-durable-goals-')); + roots.push(root); + return root; +} diff --git a/server/src/__tests__/durable-goal-service.test.ts b/server/src/__tests__/durable-goal-service.test.ts new file mode 100644 index 00000000..baaeaacf --- /dev/null +++ b/server/src/__tests__/durable-goal-service.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import type { + DurableGoalCompareAndSetInput, + DurableGoalCompareAndSetResult, + DurableGoalListQuery, + DurableGoalRecord, +} from '@veritas-kanban/shared'; +import { DurableGoalService } from '../services/durable-goal-service.js'; +import type { DurableGoalRepository } from '../storage/interfaces.js'; + +const NOW = new Date('2026-07-26T02:00:00.000Z'); +const GOAL_ID = 'goal_0123456789abcdef'; + +class InMemoryDurableGoalRepository implements DurableGoalRepository { + private readonly records = new Map(); + + async create(record: DurableGoalRecord): Promise { + if (this.records.has(record.id)) throw new Error('duplicate'); + this.records.set(record.id, structuredClone(record)); + return structuredClone(record); + } + + async get(id: string): Promise { + const record = this.records.get(id); + return record ? structuredClone(record) : null; + } + + async list(query: DurableGoalListQuery): Promise { + return [...this.records.values()] + .filter((record) => record.workspaceId === query.workspaceId) + .filter((record) => !query.states || query.states.includes(record.state)) + .map((record) => structuredClone(record)); + } + + async compareAndSet( + input: DurableGoalCompareAndSetInput + ): Promise { + const current = this.records.get(input.id); + if (!current) return { updated: false, reason: 'not-found' }; + if (current.revision !== input.expectedRevision) { + return { + record: structuredClone(current), + updated: false, + reason: 'stale-revision', + }; + } + if (input.next.id !== input.id || input.next.revision !== input.expectedRevision + 1) { + return { + record: structuredClone(current), + updated: false, + reason: 'invalid-revision', + }; + } + this.records.set(input.id, structuredClone(input.next)); + return { record: structuredClone(input.next), updated: true }; + } +} + +function service(): DurableGoalService { + return new DurableGoalService({ + repository: new InMemoryDurableGoalRepository(), + now: () => NOW, + }); +} + +async function createGoal(goalService = service()) { + const goal = await goalService.create({ + id: GOAL_ID, + objective: 'Deliver the durable goal supervisor.', + constraints: ['Preserve operator authority.'], + acceptanceCriteria: ['The goal survives restart.'], + root: { kind: 'task', taskId: 'task-865' }, + continuation: { + mode: 'automatic', + maxTurns: 20, + maxRollovers: 2, + requireApprovalForRollover: true, + }, + completionRequirements: [ + { + id: 'focused-tests', + description: 'Focused verification passes.', + required: true, + verificationKind: 'test', + }, + ], + }); + return { goalService, goal }; +} + +describe('DurableGoalService', () => { + it('creates a versioned active goal with bounded policy and zero aggregate usage', async () => { + const { goal } = await createGoal(); + + expect(goal).toMatchObject({ + schemaVersion: 'durable-goal/v1', + id: GOAL_ID, + workspaceId: 'local', + state: 'active', + revision: 1, + root: { kind: 'task', taskId: 'task-865' }, + usage: { + totalTokens: 0, + costUsd: 0, + toolCalls: 0, + retries: 0, + fanOut: 0, + }, + }); + }); + + it('requires an evidence-gated completion contract at creation', async () => { + await expect( + service().create({ + id: GOAL_ID, + objective: 'Attempt an unsupported objective.', + acceptanceCriteria: ['The objective should not be accepted.'], + root: { kind: 'task', taskId: 'task-865' }, + continuation: { mode: 'manual' }, + }) + ).rejects.toThrow('Durable goals require at least one evidence-gated completion requirement'); + }); + + it('applies an exact compare-and-set transition and rejects a stale writer', async () => { + const { goalService, goal } = await createGoal(); + const paused = await goalService.transition(goal.id, { + expectedRevision: goal.revision, + to: 'paused', + actorId: 'operator-brad', + reason: 'Pause before external coordination.', + }); + + expect(paused).toMatchObject({ + state: 'paused', + revision: 2, + transitions: [ + { + revision: 2, + from: 'active', + to: 'paused', + actorId: 'operator-brad', + }, + ], + }); + await expect( + goalService.transition(goal.id, { + expectedRevision: goal.revision, + to: 'cancelled', + actorId: 'operator-brad', + reason: 'Stale cancellation.', + }) + ).rejects.toMatchObject({ statusCode: 409, code: 'CONFLICT' }); + }); + + it('preserves an actionable blocker and supports a safe resume', async () => { + const { goalService, goal } = await createGoal(); + const blocked = await goalService.transition(goal.id, { + expectedRevision: goal.revision, + to: 'blocked', + actorId: 'goal-supervisor', + reason: 'External approval is missing.', + blocker: { + id: 'blocker-approval', + code: 'EXTERNAL_APPROVAL_REQUIRED', + summary: 'A release owner must approve publication.', + attempts: 2, + nextSafeAction: 'Wait for the release owner.', + requiredAuthority: 'release:publish', + recordedAt: NOW.toISOString(), + }, + }); + const resumed = await goalService.transition(goal.id, { + expectedRevision: blocked.revision, + to: 'active', + actorId: 'operator-brad', + reason: 'Release approval was granted.', + }); + + expect(blocked.blockers[0]).toMatchObject({ + code: 'EXTERNAL_APPROVAL_REQUIRED', + attempts: 2, + requiredAuthority: 'release:publish', + }); + expect(resumed).toMatchObject({ state: 'active', revision: 3 }); + }); + + it('requires a blocker when entering blocked state', async () => { + const { goalService, goal } = await createGoal(); + + await expect( + goalService.transition(goal.id, { + expectedRevision: goal.revision, + to: 'blocked', + actorId: 'goal-supervisor', + reason: 'No blocker was supplied.', + }) + ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_ERROR' }); + }); + + it('rejects completion until all required evidence is present', async () => { + const { goalService, goal } = await createGoal(); + + await expect( + goalService.transition(goal.id, { + expectedRevision: goal.revision, + to: 'complete', + actorId: 'goal-supervisor', + reason: 'Unsupported completion.', + }) + ).rejects.toMatchObject({ + statusCode: 400, + details: { missingRequirementIds: ['focused-tests'] }, + }); + }); + + it('records verified completion evidence and makes terminal state immutable', async () => { + const { goalService, goal } = await createGoal(); + const complete = await goalService.transition(goal.id, { + expectedRevision: goal.revision, + to: 'complete', + actorId: 'operator-brad', + reason: 'All acceptance criteria are verified.', + completionEvidence: [ + { + requirementId: 'focused-tests', + evidenceId: 'ci-run-30182450098', + summary: 'Focused durable-goal tests passed.', + verifier: 'github-actions', + verifiedAt: NOW.toISOString(), + }, + ], + }); + + expect(complete).toMatchObject({ + state: 'complete', + terminalReason: 'All acceptance criteria are verified.', + completionEvidence: [{ evidenceId: 'ci-run-30182450098' }], + }); + await expect( + goalService.transition(goal.id, { + expectedRevision: complete.revision, + to: 'active', + actorId: 'operator-brad', + reason: 'Terminal goals cannot reopen.', + }) + ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_ERROR' }); + await expect( + goalService.linkRun(goal.id, { + expectedRevision: complete.revision, + run: { taskId: 'task-865', attemptId: 'attempt-after-complete' }, + }) + ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_ERROR' }); + }); + + it('links a continuation run exactly once without changing goal state', async () => { + const { goalService, goal } = await createGoal(); + const linked = await goalService.linkRun(goal.id, { + expectedRevision: goal.revision, + run: { + taskId: 'task-865', + attemptId: 'attempt-1', + conversationId: 'conversation-1', + }, + }); + const duplicate = await goalService.linkRun(goal.id, { + expectedRevision: linked.revision, + run: { + taskId: 'task-865', + attemptId: 'attempt-1', + conversationId: 'conversation-1', + }, + }); + + expect(linked).toMatchObject({ + state: 'active', + revision: 2, + currentRun: { attemptId: 'attempt-1' }, + }); + expect(linked.continuationChain).toHaveLength(1); + expect(duplicate).toEqual(linked); + }); +}); diff --git a/server/src/__tests__/storage/sqlite-storage.test.ts b/server/src/__tests__/storage/sqlite-storage.test.ts index d2dcf28b..2faac89d 100644 --- a/server/src/__tests__/storage/sqlite-storage.test.ts +++ b/server/src/__tests__/storage/sqlite-storage.test.ts @@ -11,6 +11,7 @@ import { SqliteActivityRepository, SqliteStatusHistoryRepository, SqliteTelemetryRepository, + SqliteDurableGoalRepository, type FileStorageOptions, } from '../../storage/index.js'; @@ -70,6 +71,7 @@ describe('SqliteStorageProvider', () => { expect(provider.activities).toBeInstanceOf(SqliteActivityRepository); expect(provider.statusHistory).toBeInstanceOf(SqliteStatusHistoryRepository); expect(provider.telemetry).toBeInstanceOf(SqliteTelemetryRepository); + expect(provider.durableGoals).toBeInstanceOf(SqliteDurableGoalRepository); await provider.shutdown(); expect(provider.getDatabase().isOpen()).toBe(false); diff --git a/server/src/schemas/durable-goal-schemas.ts b/server/src/schemas/durable-goal-schemas.ts new file mode 100644 index 00000000..1745e290 --- /dev/null +++ b/server/src/schemas/durable-goal-schemas.ts @@ -0,0 +1,220 @@ +import { z } from 'zod'; +import { + DURABLE_GOAL_CONTINUATION_MODES, + DURABLE_GOAL_SCHEMA_VERSION, + DURABLE_GOAL_STATES, + type DurableGoalRecord, +} from '@veritas-kanban/shared'; + +const IdentifierSchema = z.string().trim().min(1).max(240); +const IsoTimestampSchema = z.string().datetime(); +const BoundedTextSchema = z.string().trim().min(1).max(4_000); + +const BudgetLimitsSchema = z + .object({ + inputTokens: z.number().nonnegative().optional(), + outputTokens: z.number().nonnegative().optional(), + totalTokens: z.number().nonnegative().optional(), + costUsd: z.number().nonnegative().optional(), + toolCalls: z.number().nonnegative().optional(), + runtimeSeconds: z.number().nonnegative().optional(), + idleRuntimeSeconds: z.number().nonnegative().optional(), + retries: z.number().nonnegative().optional(), + fanOut: z.number().nonnegative().optional(), + }) + .strict(); + +const BudgetUsageSchema = z + .object({ + inputTokens: z.number().nonnegative(), + outputTokens: z.number().nonnegative(), + totalTokens: z.number().nonnegative(), + costUsd: z.number().nonnegative(), + toolCalls: z.number().nonnegative(), + runtimeSeconds: z.number().nonnegative(), + idleRuntimeSeconds: z.number().nonnegative(), + retries: z.number().nonnegative(), + fanOut: z.number().nonnegative(), + }) + .strict(); + +const RunLinkSchema = z + .object({ + taskId: IdentifierSchema, + attemptId: IdentifierSchema.optional(), + workflowRunId: IdentifierSchema.optional(), + conversationId: IdentifierSchema.optional(), + parentAttemptId: IdentifierSchema.optional(), + linkedAt: IsoTimestampSchema, + }) + .strict(); + +const CompletionRequirementSchema = z + .object({ + id: IdentifierSchema, + description: BoundedTextSchema, + required: z.boolean(), + verificationKind: z.enum(['test', 'build', 'artifact', 'operator', 'external', 'other']), + }) + .strict(); + +const CompletionEvidenceSchema = z + .object({ + requirementId: IdentifierSchema, + evidenceId: IdentifierSchema, + summary: BoundedTextSchema, + verifier: IdentifierSchema, + verifiedAt: IsoTimestampSchema, + }) + .strict(); + +export const DurableGoalRecordSchema: z.ZodType = z + .object({ + schemaVersion: z.literal(DURABLE_GOAL_SCHEMA_VERSION), + id: z.string().regex(/^goal_[A-Za-z0-9_-]{12,64}$/), + workspaceId: IdentifierSchema, + objective: z.string().trim().min(1).max(50_000), + constraints: z.array(BoundedTextSchema).max(200), + acceptanceCriteria: z.array(BoundedTextSchema).min(1).max(200), + root: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('task'), taskId: IdentifierSchema }).strict(), + z + .object({ + kind: z.literal('workflow'), + workflowId: IdentifierSchema, + taskId: IdentifierSchema.optional(), + }) + .strict(), + ]), + state: z.enum(DURABLE_GOAL_STATES), + revision: z.number().int().positive(), + continuation: z + .object({ + mode: z.enum(DURABLE_GOAL_CONTINUATION_MODES), + maxTurns: z.number().int().positive().max(100_000).optional(), + maxRollovers: z.number().int().nonnegative().max(10_000).optional(), + compactAfterTokens: z.number().int().positive().optional(), + requireApprovalForRollover: z.boolean().optional(), + }) + .strict(), + budgets: BudgetLimitsSchema.optional(), + usage: BudgetUsageSchema, + currentRun: RunLinkSchema.optional(), + continuationChain: z.array(RunLinkSchema).max(10_000), + blockers: z + .array( + z + .object({ + id: IdentifierSchema, + code: IdentifierSchema, + summary: BoundedTextSchema, + attempts: z.number().int().nonnegative(), + nextSafeAction: BoundedTextSchema, + requiredAuthority: BoundedTextSchema.optional(), + externalStateChange: BoundedTextSchema.optional(), + recordedAt: IsoTimestampSchema, + }) + .strict() + ) + .max(1_000), + completionRequirements: z.array(CompletionRequirementSchema).min(1).max(200), + completionEvidence: z.array(CompletionEvidenceSchema).max(1_000), + transitions: z + .array( + z + .object({ + revision: z.number().int().positive(), + from: z.enum(DURABLE_GOAL_STATES), + to: z.enum(DURABLE_GOAL_STATES), + actorId: IdentifierSchema, + reason: BoundedTextSchema, + recordedAt: IsoTimestampSchema, + }) + .strict() + ) + .max(10_000), + terminalReason: BoundedTextSchema.optional(), + createdAt: IsoTimestampSchema, + updatedAt: IsoTimestampSchema, + }) + .strict() + .superRefine((record, context) => { + const requirementIds = new Set(); + for (const [index, requirement] of record.completionRequirements.entries()) { + if (requirementIds.has(requirement.id)) { + context.addIssue({ + code: 'custom', + path: ['completionRequirements', index, 'id'], + message: 'Completion requirement IDs must be unique.', + }); + } + requirementIds.add(requirement.id); + } + if (!record.completionRequirements.some((requirement) => requirement.required)) { + context.addIssue({ + code: 'custom', + path: ['completionRequirements'], + message: 'Durable goals require at least one evidence-gated completion requirement.', + }); + } + + const evidenceIds = new Set(); + const evidencedRequirements = new Set(); + for (const [index, evidence] of record.completionEvidence.entries()) { + if (!requirementIds.has(evidence.requirementId)) { + context.addIssue({ + code: 'custom', + path: ['completionEvidence', index, 'requirementId'], + message: 'Completion evidence must reference a configured requirement.', + }); + } + if (evidenceIds.has(evidence.evidenceId)) { + context.addIssue({ + code: 'custom', + path: ['completionEvidence', index, 'evidenceId'], + message: 'Completion evidence IDs must be unique.', + }); + } + evidenceIds.add(evidence.evidenceId); + evidencedRequirements.add(evidence.requirementId); + } + + if (record.state === 'complete') { + for (const [index, requirement] of record.completionRequirements.entries()) { + if (requirement.required && !evidencedRequirements.has(requirement.id)) { + context.addIssue({ + code: 'custom', + path: ['completionRequirements', index], + message: 'Required completion evidence is missing.', + }); + } + } + } + + const terminal = ['complete', 'cancelled', 'failed'].includes(record.state); + if (terminal !== Boolean(record.terminalReason)) { + context.addIssue({ + code: 'custom', + path: ['terminalReason'], + message: 'Terminal goal states require a terminal reason, and active states must omit it.', + }); + } + if (record.state === 'blocked' && record.blockers.length === 0) { + context.addIssue({ + code: 'custom', + path: ['blockers'], + message: 'Blocked goals must preserve an actionable blocker.', + }); + } + const lastTransition = record.transitions.at(-1); + if ( + lastTransition && + (lastTransition.revision > record.revision || lastTransition.to !== record.state) + ) { + context.addIssue({ + code: 'custom', + path: ['transitions'], + message: 'The latest transition must precede the current revision and match its state.', + }); + } + }); diff --git a/server/src/services/durable-goal-service.ts b/server/src/services/durable-goal-service.ts new file mode 100644 index 00000000..e86212d3 --- /dev/null +++ b/server/src/services/durable-goal-service.ts @@ -0,0 +1,291 @@ +import { randomUUID } from 'node:crypto'; +import type { + AgentBudgetLimits, + DurableGoalBlocker, + DurableGoalCompletionEvidence, + DurableGoalCompletionRequirement, + DurableGoalContinuationPolicy, + DurableGoalListQuery, + DurableGoalRecord, + DurableGoalRoot, + DurableGoalRunLink, + DurableGoalState, +} from '@veritas-kanban/shared'; +import { DURABLE_GOAL_SCHEMA_VERSION, ZERO_AGENT_BUDGET_USAGE } from '@veritas-kanban/shared'; +import { ConflictError, NotFoundError, ValidationError } from '../middleware/error-handler.js'; +import { DurableGoalRecordSchema } from '../schemas/durable-goal-schemas.js'; +import type { DurableGoalRepository } from '../storage/interfaces.js'; +import { FileDurableGoalRepository } from '../storage/durable-goal-repository.js'; +import { getStorage, getStorageTypeFromEnv } from '../storage/index.js'; + +const TERMINAL_STATES = new Set(['complete', 'cancelled', 'failed']); + +const ALLOWED_TRANSITIONS: Readonly> = { + active: [ + 'paused', + 'blocked', + 'awaiting-approval', + 'usage-limited', + 'budget-limited', + 'complete', + 'cancelled', + 'failed', + ], + paused: [ + 'active', + 'blocked', + 'awaiting-approval', + 'usage-limited', + 'budget-limited', + 'complete', + 'cancelled', + 'failed', + ], + blocked: [ + 'active', + 'paused', + 'awaiting-approval', + 'usage-limited', + 'budget-limited', + 'complete', + 'cancelled', + 'failed', + ], + 'awaiting-approval': [ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + 'complete', + 'cancelled', + 'failed', + ], + 'usage-limited': [ + 'active', + 'paused', + 'blocked', + 'awaiting-approval', + 'budget-limited', + 'complete', + 'cancelled', + 'failed', + ], + 'budget-limited': [ + 'active', + 'paused', + 'blocked', + 'awaiting-approval', + 'usage-limited', + 'complete', + 'cancelled', + 'failed', + ], + complete: [], + cancelled: [], + failed: [], +}; + +export interface CreateDurableGoalInput { + id?: string; + workspaceId?: string; + objective: string; + constraints?: string[]; + acceptanceCriteria: string[]; + root: DurableGoalRoot; + continuation: DurableGoalContinuationPolicy; + budgets?: AgentBudgetLimits; + completionRequirements?: DurableGoalCompletionRequirement[]; +} + +export interface TransitionDurableGoalInput { + expectedRevision: number; + to: DurableGoalState; + actorId: string; + reason: string; + blocker?: DurableGoalBlocker; + completionEvidence?: DurableGoalCompletionEvidence[]; +} + +export interface LinkDurableGoalRunInput { + expectedRevision: number; + run: Omit & { linkedAt?: string }; +} + +export interface DurableGoalServiceOptions { + repository?: DurableGoalRepository; + now?: () => Date; +} + +let fileRepository: FileDurableGoalRepository | undefined; + +function defaultRepository(): DurableGoalRepository { + if (getStorageTypeFromEnv() === 'sqlite') return getStorage().durableGoals; + fileRepository ??= new FileDurableGoalRepository(); + return fileRepository; +} + +export class DurableGoalService { + private readonly repositoryOverride?: DurableGoalRepository; + private readonly now: () => Date; + + constructor(options: DurableGoalServiceOptions = {}) { + this.repositoryOverride = options.repository; + this.now = options.now ?? (() => new Date()); + } + + private get repository(): DurableGoalRepository { + return this.repositoryOverride ?? defaultRepository(); + } + + async create(input: CreateDurableGoalInput): Promise { + const timestamp = this.now().toISOString(); + const record = DurableGoalRecordSchema.parse({ + schemaVersion: DURABLE_GOAL_SCHEMA_VERSION, + id: input.id ?? `goal_${randomUUID().replaceAll('-', '')}`, + workspaceId: input.workspaceId?.trim() || 'local', + objective: input.objective, + constraints: input.constraints ?? [], + acceptanceCriteria: input.acceptanceCriteria, + root: input.root, + state: 'active', + revision: 1, + continuation: input.continuation, + budgets: input.budgets, + usage: { ...ZERO_AGENT_BUDGET_USAGE }, + continuationChain: [], + blockers: [], + completionRequirements: input.completionRequirements ?? [], + completionEvidence: [], + transitions: [], + createdAt: timestamp, + updatedAt: timestamp, + }); + return this.repository.create(record); + } + + async get(id: string): Promise { + const record = await this.repository.get(id); + if (!record) throw new NotFoundError('Durable goal not found.'); + return record; + } + + async list(query: DurableGoalListQuery): Promise { + return this.repository.list(query); + } + + async transition(id: string, input: TransitionDurableGoalInput): Promise { + const current = await this.get(id); + this.requireRevision(current, input.expectedRevision); + if (!ALLOWED_TRANSITIONS[current.state].includes(input.to)) { + throw new ValidationError('Durable goal state transition is not allowed.', { + goalId: id, + from: current.state, + to: input.to, + }); + } + if (input.to === 'blocked' && !input.blocker) { + throw new ValidationError('Blocked goals require an actionable blocker.'); + } + if (input.blocker && input.to !== 'blocked') { + throw new ValidationError('A blocker can only be recorded by a blocked transition.'); + } + if (input.to === 'complete') { + const evidenced = new Set([ + ...current.completionEvidence.map((evidence) => evidence.requirementId), + ...(input.completionEvidence ?? []).map((evidence) => evidence.requirementId), + ]); + const missing = current.completionRequirements + .filter((requirement) => requirement.required && !evidenced.has(requirement.id)) + .map((requirement) => requirement.id); + if (missing.length > 0) { + throw new ValidationError('Durable goal completion evidence is incomplete.', { + goalId: id, + missingRequirementIds: missing, + }); + } + } + + const timestamp = this.now().toISOString(); + const revision = current.revision + 1; + const next = DurableGoalRecordSchema.parse({ + ...current, + state: input.to, + revision, + blockers: input.blocker ? [...current.blockers, input.blocker] : current.blockers, + completionEvidence: [...current.completionEvidence, ...(input.completionEvidence ?? [])], + transitions: [ + ...current.transitions, + { + revision, + from: current.state, + to: input.to, + actorId: input.actorId, + reason: input.reason, + recordedAt: timestamp, + }, + ], + terminalReason: TERMINAL_STATES.has(input.to) ? input.reason : undefined, + updatedAt: timestamp, + }); + return this.compareAndSet(current, next); + } + + async linkRun(id: string, input: LinkDurableGoalRunInput): Promise { + const current = await this.get(id); + this.requireRevision(current, input.expectedRevision); + if (TERMINAL_STATES.has(current.state)) { + throw new ValidationError('Terminal goals cannot accept another run.', { + goalId: id, + state: current.state, + }); + } + const linkedAt = input.run.linkedAt ?? this.now().toISOString(); + const run = { ...input.run, linkedAt }; + const duplicate = current.continuationChain.some( + (candidate) => + candidate.taskId === run.taskId && + candidate.attemptId === run.attemptId && + candidate.workflowRunId === run.workflowRunId + ); + if (duplicate) return current; + + const next = DurableGoalRecordSchema.parse({ + ...current, + revision: current.revision + 1, + currentRun: run, + continuationChain: [...current.continuationChain, run], + updatedAt: this.now().toISOString(), + }); + return this.compareAndSet(current, next); + } + + private requireRevision(record: DurableGoalRecord, expectedRevision: number): void { + if (record.revision !== expectedRevision) { + throw new ConflictError('Durable goal compare-and-set revision is stale.', { + goalId: record.id, + expectedRevision, + currentRevision: record.revision, + }); + } + } + + private async compareAndSet( + current: DurableGoalRecord, + next: DurableGoalRecord + ): Promise { + const result = await this.repository.compareAndSet({ + id: current.id, + expectedRevision: current.revision, + next, + }); + if (result.updated && result.record) return result.record; + if (result.reason === 'not-found') throw new NotFoundError('Durable goal not found.'); + throw new ConflictError('Durable goal compare-and-set update was rejected.', { + goalId: current.id, + expectedRevision: current.revision, + currentRevision: result.record?.revision, + reason: result.reason, + }); + } +} diff --git a/server/src/storage/durable-goal-repository.ts b/server/src/storage/durable-goal-repository.ts new file mode 100644 index 00000000..5a1804b3 --- /dev/null +++ b/server/src/storage/durable-goal-repository.ts @@ -0,0 +1,158 @@ +import { constants } from 'node:fs'; +import { lstat, mkdir, open } from 'node:fs/promises'; +import path from 'node:path'; +import type { + DurableGoalCompareAndSetInput, + DurableGoalCompareAndSetResult, + DurableGoalListQuery, + DurableGoalRecord, +} from '@veritas-kanban/shared'; +import { DurableGoalRecordSchema } from '../schemas/durable-goal-schemas.js'; +import { withFileLock } from '../services/file-lock.js'; +import { getRuntimeDir } from '../utils/paths.js'; +import { ensureWithinBase } from '../utils/sanitize.js'; +import type { DurableGoalRepository } from './interfaces.js'; + +const MAX_GOAL_LOG_BYTES = 64 * 1024 * 1024; +const MAX_GOAL_SNAPSHOTS = 50_000; + +export function getDurableGoalsPath(): string { + return path.join(getRuntimeDir(), 'durable-goals.jsonl'); +} + +export class FileDurableGoalRepository implements DurableGoalRepository { + constructor(private readonly filePath = getDurableGoalsPath()) { + ensureWithinBase(path.dirname(filePath), filePath); + } + + async create(record: DurableGoalRecord): Promise { + const parsed = DurableGoalRecordSchema.parse(record); + if (parsed.revision !== 1) throw new Error('New durable goals must start at revision 1.'); + await this.prepareParent(); + return withFileLock(this.filePath, async () => { + const snapshots = await this.readSnapshots(); + if (snapshots.some((candidate) => candidate.id === parsed.id)) { + throw new Error(`Durable goal ${parsed.id} already exists.`); + } + await this.appendSnapshot(parsed, snapshots); + return parsed; + }); + } + + async get(id: string): Promise { + return this.materialize(await this.readSnapshots()).get(id) ?? null; + } + + async list(query: DurableGoalListQuery): Promise { + const stateFilter = query.states ? new Set(query.states) : undefined; + const limit = Math.min(Math.max(query.limit ?? 100, 1), 1_000); + return [...this.materialize(await this.readSnapshots()).values()] + .filter((record) => record.workspaceId === query.workspaceId) + .filter((record) => !stateFilter || stateFilter.has(record.state)) + .filter( + (record) => + !query.rootTaskId || + (record.root.kind === 'task' && record.root.taskId === query.rootTaskId) || + (record.root.kind === 'workflow' && record.root.taskId === query.rootTaskId) + ) + .filter( + (record) => + !query.rootWorkflowId || + (record.root.kind === 'workflow' && record.root.workflowId === query.rootWorkflowId) + ) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) + .slice(0, limit); + } + + async compareAndSet( + input: DurableGoalCompareAndSetInput + ): Promise { + await this.prepareParent(); + return withFileLock(this.filePath, async () => { + const snapshots = await this.readSnapshots(); + const current = this.materialize(snapshots).get(input.id); + if (!current) return { updated: false, reason: 'not-found' }; + if (current.revision !== input.expectedRevision) { + return { record: current, updated: false, reason: 'stale-revision' }; + } + if (input.next.revision !== input.expectedRevision + 1 || input.next.id !== input.id) { + return { record: current, updated: false, reason: 'invalid-revision' }; + } + const next = DurableGoalRecordSchema.parse(input.next); + await this.appendSnapshot(next, snapshots); + return { record: next, updated: true }; + }); + } + + private async prepareParent(): Promise { + const parent = path.dirname(this.filePath); + await mkdir(parent, { recursive: true, mode: 0o700 }); + const stat = await lstat(parent); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('Durable goal directory is not a private regular directory.'); + } + } + + private async readSnapshots(): Promise { + let handle: Awaited> | undefined; + try { + handle = await open(this.filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > MAX_GOAL_LOG_BYTES) { + throw new Error('Durable goal log is not a bounded regular file.'); + } + const content = await handle.readFile({ encoding: 'utf8' }); + if (!content.trim()) return []; + const lines = content.split(/\r?\n/).filter(Boolean); + if (lines.length > MAX_GOAL_SNAPSHOTS) { + throw new Error('Durable goal log reached its bounded snapshot limit.'); + } + return lines.map((line) => DurableGoalRecordSchema.parse(JSON.parse(line))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new Error('Durable goal log is not a bounded regular file.', { cause: error }); + } + throw error; + } finally { + await handle?.close(); + } + } + + private materialize(snapshots: DurableGoalRecord[]): Map { + const byId = new Map(); + for (const snapshot of snapshots) { + const current = byId.get(snapshot.id); + if (!current || snapshot.revision > current.revision) byId.set(snapshot.id, snapshot); + } + return byId; + } + + private async appendSnapshot( + snapshot: DurableGoalRecord, + existing: DurableGoalRecord[] + ): Promise { + if (existing.length >= MAX_GOAL_SNAPSHOTS) { + throw new Error('Durable goal log reached its bounded snapshot limit.'); + } + const line = `${JSON.stringify(snapshot)}\n`; + const existingBytes = existing.reduce( + (total, candidate) => total + Buffer.byteLength(JSON.stringify(candidate), 'utf8') + 1, + 0 + ); + if (existingBytes + Buffer.byteLength(line, 'utf8') > MAX_GOAL_LOG_BYTES) { + throw new Error('Durable goal log reached its bounded byte limit.'); + } + const handle = await open( + this.filePath, + constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600 + ); + try { + await handle.write(line, undefined, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + } +} diff --git a/server/src/storage/file-storage.ts b/server/src/storage/file-storage.ts index b871fc10..321d59b5 100644 --- a/server/src/storage/file-storage.ts +++ b/server/src/storage/file-storage.ts @@ -43,6 +43,7 @@ import type { RunApprovalRepository, PhaseTransitionRepository, RunSupervisorRepository, + DurableGoalRepository, AdmissionReservationRepository, ToolControlPlaneRepository, } from './interfaces.js'; @@ -72,6 +73,7 @@ import { FileRunEventRepository } from './run-event-repository.js'; import { FileRunApprovalRepository } from './run-approval-repository.js'; import { FilePhaseTransitionRepository } from './phase-transition-repository.js'; import { FileRunSupervisorRepository } from './run-supervisor-repository.js'; +import { FileDurableGoalRepository } from './durable-goal-repository.js'; import { FileAdmissionReservationRepository } from './admission-reservation-repository.js'; import { FileToolControlPlaneRepository } from './tool-control-plane-repository.js'; @@ -494,6 +496,7 @@ export interface FileStorageOptions { runApprovalsPath?: string; phaseTransitionsPath?: string; runSupervisorsPath?: string; + durableGoalsPath?: string; admissionReservationsPath?: string; toolControlPlanePath?: string; } @@ -511,6 +514,7 @@ export class FileStorageProvider implements StorageProvider { readonly runApprovals: RunApprovalRepository; readonly phaseTransitions: PhaseTransitionRepository; readonly runSupervisors: RunSupervisorRepository; + readonly durableGoals: DurableGoalRepository; readonly admissionReservations: AdmissionReservationRepository; readonly toolControlPlane: ToolControlPlaneRepository; @@ -566,6 +570,7 @@ export class FileStorageProvider implements StorageProvider { this.runApprovals = new FileRunApprovalRepository(options.runApprovalsPath); this.phaseTransitions = new FilePhaseTransitionRepository(options.phaseTransitionsPath); this.runSupervisors = new FileRunSupervisorRepository(options.runSupervisorsPath); + this.durableGoals = new FileDurableGoalRepository(options.durableGoalsPath); this.admissionReservations = new FileAdmissionReservationRepository( options.admissionReservationsPath ); diff --git a/server/src/storage/index.ts b/server/src/storage/index.ts index cc716bcb..f840a9eb 100644 --- a/server/src/storage/index.ts +++ b/server/src/storage/index.ts @@ -27,6 +27,8 @@ export type { RunEventRepositoryAppendInput, RunApprovalRepository, PhaseTransitionRepository, + RunSupervisorRepository, + DurableGoalRepository, AdmissionReservationRepository, ToolControlPlaneRepository, SetupContextRepository, @@ -63,6 +65,7 @@ export { InMemoryPhaseTransitionRepository, getPhaseTransitionsPath, } from './phase-transition-repository.js'; +export { FileDurableGoalRepository, getDurableGoalsPath } from './durable-goal-repository.js'; export { FileAdmissionReservationRepository, getAdmissionReservationsPath, @@ -95,6 +98,7 @@ export { SqliteTelemetryRepository } from './sqlite/telemetry-repository.js'; export { SqliteRunEventRepository } from './sqlite/run-event-repository.js'; export { SqliteRunApprovalRepository } from './sqlite/run-approval-repository.js'; export { SqlitePhaseTransitionRepository } from './sqlite/phase-transition-repository.js'; +export { SqliteDurableGoalRepository } from './sqlite/durable-goal-repository.js'; export { SqliteAdmissionReservationRepository } from './sqlite/admission-reservation-repository.js'; export { FileToolControlPlaneRepository, diff --git a/server/src/storage/interfaces.ts b/server/src/storage/interfaces.ts index d19cb46e..fbccbfed 100644 --- a/server/src/storage/interfaces.ts +++ b/server/src/storage/interfaces.ts @@ -44,6 +44,10 @@ import type { RunSupervisorCompareAndSetResult, RunSupervisorListQuery, RunSupervisorRecord, + DurableGoalCompareAndSetInput, + DurableGoalCompareAndSetResult, + DurableGoalListQuery, + DurableGoalRecord, AdmissionReservation, AdmissionReservationClaimInput, AdmissionReservationClaimResult, @@ -435,6 +439,24 @@ export interface RunSupervisorRepository { compareAndSet(input: RunSupervisorCompareAndSetInput): Promise; } +// --------------------------------------------------------------------------- +// Durable Goal Repository +// --------------------------------------------------------------------------- + +export interface DurableGoalRepository { + /** Persist one newly allocated durable goal at revision 1. */ + create(record: DurableGoalRecord): Promise; + + /** Return one durable goal, or null when it does not exist. */ + get(id: string): Promise; + + /** Query materialized durable goals. */ + list(query: DurableGoalListQuery): Promise; + + /** Replace one goal with a revision-guarded compare-and-set operation. */ + compareAndSet(input: DurableGoalCompareAndSetInput): Promise; +} + // --------------------------------------------------------------------------- // Durable Admission Reservation Repository // --------------------------------------------------------------------------- @@ -494,6 +516,7 @@ export interface StorageProvider { readonly runApprovals: RunApprovalRepository; readonly phaseTransitions: PhaseTransitionRepository; readonly runSupervisors: RunSupervisorRepository; + readonly durableGoals: DurableGoalRepository; readonly admissionReservations: AdmissionReservationRepository; readonly toolControlPlane: ToolControlPlaneRepository; readonly setupContext?: SetupContextRepository; diff --git a/server/src/storage/sqlite/durable-goal-repository.ts b/server/src/storage/sqlite/durable-goal-repository.ts new file mode 100644 index 00000000..f934b5ff --- /dev/null +++ b/server/src/storage/sqlite/durable-goal-repository.ts @@ -0,0 +1,135 @@ +import type { + DurableGoalCompareAndSetInput, + DurableGoalCompareAndSetResult, + DurableGoalListQuery, + DurableGoalRecord, +} from '@veritas-kanban/shared'; +import { DurableGoalRecordSchema } from '../../schemas/durable-goal-schemas.js'; +import type { DurableGoalRepository } from '../interfaces.js'; +import type { SqliteDatabase } from './database.js'; + +interface GoalRow { + goal_json: string; +} + +export class SqliteDurableGoalRepository implements DurableGoalRepository { + constructor(private readonly database: SqliteDatabase) {} + + async create(record: DurableGoalRecord): Promise { + const parsed = DurableGoalRecordSchema.parse(record); + if (parsed.revision !== 1) throw new Error('New durable goals must start at revision 1.'); + this.database + .getConnection() + .prepare( + `INSERT INTO durable_goals ( + id, workspace_id, root_task_id, root_workflow_id, state, revision, + goal_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + parsed.id, + parsed.workspaceId, + rootTaskId(parsed), + parsed.root.kind === 'workflow' ? parsed.root.workflowId : null, + parsed.state, + parsed.revision, + JSON.stringify(parsed), + parsed.createdAt, + parsed.updatedAt + ); + return parsed; + } + + async get(id: string): Promise { + const row = this.database + .getConnection() + .prepare('SELECT goal_json FROM durable_goals WHERE id = ?') + .get(id) as GoalRow | undefined; + return row ? DurableGoalRecordSchema.parse(JSON.parse(row.goal_json)) : null; + } + + async list(query: DurableGoalListQuery): Promise { + const clauses = ['workspace_id = ?']; + const parameters: Array = [query.workspaceId]; + if (query.states?.length) { + clauses.push(`state IN (${query.states.map(() => '?').join(', ')})`); + parameters.push(...query.states); + } + if (query.rootTaskId) { + clauses.push('root_task_id = ?'); + parameters.push(query.rootTaskId); + } + if (query.rootWorkflowId) { + clauses.push('root_workflow_id = ?'); + parameters.push(query.rootWorkflowId); + } + const limit = Math.min(Math.max(query.limit ?? 100, 1), 1_000); + parameters.push(limit); + const rows = this.database + .getConnection() + .prepare( + `SELECT goal_json + FROM durable_goals + WHERE ${clauses.join(' AND ')} + ORDER BY updated_at DESC + LIMIT ?` + ) + .all(...parameters) as unknown as GoalRow[]; + return rows.map((row) => DurableGoalRecordSchema.parse(JSON.parse(row.goal_json))); + } + + async compareAndSet( + input: DurableGoalCompareAndSetInput + ): Promise { + const connection = this.database.getConnection(); + connection.exec('BEGIN IMMEDIATE'); + try { + const row = connection + .prepare('SELECT goal_json FROM durable_goals WHERE id = ?') + .get(input.id) as GoalRow | undefined; + if (!row) { + connection.exec('COMMIT'); + return { updated: false, reason: 'not-found' }; + } + const current = DurableGoalRecordSchema.parse(JSON.parse(row.goal_json)); + if (current.revision !== input.expectedRevision) { + connection.exec('COMMIT'); + return { record: current, updated: false, reason: 'stale-revision' }; + } + if (input.next.revision !== input.expectedRevision + 1 || input.next.id !== input.id) { + connection.exec('COMMIT'); + return { record: current, updated: false, reason: 'invalid-revision' }; + } + const next = DurableGoalRecordSchema.parse(input.next); + const result = connection + .prepare( + `UPDATE durable_goals + SET root_task_id = ?, root_workflow_id = ?, state = ?, revision = ?, + goal_json = ?, updated_at = ? + WHERE id = ? AND revision = ?` + ) + .run( + rootTaskId(next), + next.root.kind === 'workflow' ? next.root.workflowId : null, + next.state, + next.revision, + JSON.stringify(next), + next.updatedAt, + next.id, + input.expectedRevision + ); + if (result.changes !== 1) { + throw new Error('Durable goal compare-and-set changed unexpectedly.'); + } + connection.exec('COMMIT'); + return { record: next, updated: true }; + } catch (error) { + connection.exec('ROLLBACK'); + throw error; + } + } +} + +function rootTaskId(record: DurableGoalRecord): string | null { + return record.root.kind === 'task' ? record.root.taskId : (record.root.taskId ?? null); +} diff --git a/server/src/storage/sqlite/migrations.ts b/server/src/storage/sqlite/migrations.ts index 8287c524..441c78ec 100644 --- a/server/src/storage/sqlite/migrations.ts +++ b/server/src/storage/sqlite/migrations.ts @@ -1379,6 +1379,44 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [ ON admission_queue(state, lease_expires_at); `, }, + { + version: 27, + name: '0027_durable_goals', + up: ` + CREATE TABLE durable_goals ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + root_task_id TEXT, + root_workflow_id TEXT, + state TEXT NOT NULL CHECK ( + state IN ( + 'active', + 'paused', + 'blocked', + 'awaiting-approval', + 'usage-limited', + 'budget-limited', + 'complete', + 'cancelled', + 'failed' + ) + ), + revision INTEGER NOT NULL CHECK (revision > 0), + goal_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX idx_durable_goals_workspace_state_updated + ON durable_goals(workspace_id, state, updated_at DESC); + + CREATE INDEX idx_durable_goals_root_task + ON durable_goals(root_task_id, updated_at DESC); + + CREATE INDEX idx_durable_goals_root_workflow + ON durable_goals(root_workflow_id, updated_at DESC); + `, + }, ]; export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] { diff --git a/server/src/storage/sqlite/sqlite-storage.ts b/server/src/storage/sqlite/sqlite-storage.ts index 3a49f9d1..06aa08b2 100644 --- a/server/src/storage/sqlite/sqlite-storage.ts +++ b/server/src/storage/sqlite/sqlite-storage.ts @@ -15,6 +15,7 @@ import { SqliteRunEventRepository } from './run-event-repository.js'; import { SqliteRunApprovalRepository } from './run-approval-repository.js'; import { SqlitePhaseTransitionRepository } from './phase-transition-repository.js'; import { SqliteRunSupervisorRepository } from './run-supervisor-repository.js'; +import { SqliteDurableGoalRepository } from './durable-goal-repository.js'; import { SqliteAdmissionReservationRepository } from './admission-reservation-repository.js'; import { SqliteToolControlPlaneRepository } from './tool-control-plane-repository.js'; import { createDefaultConfig, normalizeAppConfig } from '../../services/config-service.js'; @@ -39,6 +40,7 @@ export class SqliteStorageProvider implements StorageProvider { readonly runApprovals: SqliteRunApprovalRepository; readonly phaseTransitions: SqlitePhaseTransitionRepository; readonly runSupervisors: SqliteRunSupervisorRepository; + readonly durableGoals: SqliteDurableGoalRepository; readonly admissionReservations: SqliteAdmissionReservationRepository; readonly toolControlPlane: SqliteToolControlPlaneRepository; @@ -64,6 +66,7 @@ export class SqliteStorageProvider implements StorageProvider { this.runApprovals = new SqliteRunApprovalRepository(this.sqlite); this.phaseTransitions = new SqlitePhaseTransitionRepository(this.sqlite); this.runSupervisors = new SqliteRunSupervisorRepository(this.sqlite); + this.durableGoals = new SqliteDurableGoalRepository(this.sqlite); this.admissionReservations = new SqliteAdmissionReservationRepository(this.sqlite); this.toolControlPlane = new SqliteToolControlPlaneRepository(this.sqlite); } diff --git a/shared/src/types/durable-goal.types.ts b/shared/src/types/durable-goal.types.ts new file mode 100644 index 00000000..d719a1a4 --- /dev/null +++ b/shared/src/types/durable-goal.types.ts @@ -0,0 +1,131 @@ +import type { AgentBudgetLimits, AgentBudgetUsage } from './agent-budget.types.js'; + +export const DURABLE_GOAL_SCHEMA_VERSION = 'durable-goal/v1' as const; + +export const DURABLE_GOAL_STATES = [ + 'active', + 'paused', + 'blocked', + 'awaiting-approval', + 'usage-limited', + 'budget-limited', + 'complete', + 'cancelled', + 'failed', +] as const; + +export type DurableGoalState = (typeof DURABLE_GOAL_STATES)[number]; +export type DurableGoalTerminalState = Extract< + DurableGoalState, + 'complete' | 'cancelled' | 'failed' +>; + +export const DURABLE_GOAL_CONTINUATION_MODES = ['manual', 'automatic'] as const; +export type DurableGoalContinuationMode = (typeof DURABLE_GOAL_CONTINUATION_MODES)[number]; + +export type DurableGoalRoot = + | { + kind: 'task'; + taskId: string; + } + | { + kind: 'workflow'; + workflowId: string; + taskId?: string; + }; + +export interface DurableGoalContinuationPolicy { + mode: DurableGoalContinuationMode; + maxTurns?: number; + maxRollovers?: number; + compactAfterTokens?: number; + requireApprovalForRollover?: boolean; +} + +export interface DurableGoalCompletionRequirement { + id: string; + description: string; + required: boolean; + verificationKind: 'test' | 'build' | 'artifact' | 'operator' | 'external' | 'other'; +} + +export interface DurableGoalCompletionEvidence { + requirementId: string; + evidenceId: string; + summary: string; + verifier: string; + verifiedAt: string; +} + +export interface DurableGoalBlocker { + id: string; + code: string; + summary: string; + attempts: number; + nextSafeAction: string; + requiredAuthority?: string; + externalStateChange?: string; + recordedAt: string; +} + +export interface DurableGoalRunLink { + taskId: string; + attemptId?: string; + workflowRunId?: string; + conversationId?: string; + parentAttemptId?: string; + linkedAt: string; +} + +export interface DurableGoalTransition { + revision: number; + from: DurableGoalState; + to: DurableGoalState; + actorId: string; + reason: string; + recordedAt: string; +} + +export interface DurableGoalRecord { + schemaVersion: typeof DURABLE_GOAL_SCHEMA_VERSION; + id: string; + workspaceId: string; + objective: string; + constraints: string[]; + acceptanceCriteria: string[]; + root: DurableGoalRoot; + state: DurableGoalState; + revision: number; + continuation: DurableGoalContinuationPolicy; + budgets?: AgentBudgetLimits; + usage: AgentBudgetUsage; + currentRun?: DurableGoalRunLink; + continuationChain: DurableGoalRunLink[]; + blockers: DurableGoalBlocker[]; + completionRequirements: DurableGoalCompletionRequirement[]; + completionEvidence: DurableGoalCompletionEvidence[]; + transitions: DurableGoalTransition[]; + terminalReason?: string; + createdAt: string; + updatedAt: string; +} + +export interface DurableGoalListQuery { + workspaceId: string; + states?: DurableGoalState[]; + rootTaskId?: string; + rootWorkflowId?: string; + limit?: number; +} + +export interface DurableGoalCompareAndSetInput { + id: string; + expectedRevision: number; + next: DurableGoalRecord; +} + +export interface DurableGoalCompareAndSetResult { + record?: DurableGoalRecord; + updated: boolean; + reason?: 'not-found' | 'stale-revision' | 'invalid-revision'; +} diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 203922a5..517f9e35 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -24,6 +24,7 @@ export * from './drift.types.js'; export * from './decision.types.js'; export * from './run-session.types.js'; export * from './run-supervisor.types.js'; +export * from './durable-goal.types.js'; export * from './admission-control.types.js'; export * from './evaluation.types.js'; export * from './harness-conformance.types.js';