feat: add durable goal state core (#1081)

This commit is contained in:
Brad Groux 2026-07-25 20:45:31 -05:00 committed by GitHub
parent eb83f92be8
commit e15bd320e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1405 additions and 4 deletions

View file

@ -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

View file

@ -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<DurableGoalRepository>
): Promise<void> {
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<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-durable-goals-'));
roots.push(root);
return root;
}

View file

@ -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<string, DurableGoalRecord>();
async create(record: DurableGoalRecord): Promise<DurableGoalRecord> {
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<DurableGoalRecord | null> {
const record = this.records.get(id);
return record ? structuredClone(record) : null;
}
async list(query: DurableGoalListQuery): Promise<DurableGoalRecord[]> {
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<DurableGoalCompareAndSetResult> {
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);
});
});

View file

@ -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);

View file

@ -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<DurableGoalRecord> = 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<string>();
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<string>();
const evidencedRequirements = new Set<string>();
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.',
});
}
});

View file

@ -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<DurableGoalState>(['complete', 'cancelled', 'failed']);
const ALLOWED_TRANSITIONS: Readonly<Record<DurableGoalState, readonly DurableGoalState[]>> = {
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<DurableGoalRunLink, 'linkedAt'> & { 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<DurableGoalRecord> {
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<DurableGoalRecord> {
const record = await this.repository.get(id);
if (!record) throw new NotFoundError('Durable goal not found.');
return record;
}
async list(query: DurableGoalListQuery): Promise<DurableGoalRecord[]> {
return this.repository.list(query);
}
async transition(id: string, input: TransitionDurableGoalInput): Promise<DurableGoalRecord> {
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<DurableGoalRecord> {
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<DurableGoalRecord> {
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,
});
}
}

View file

@ -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<DurableGoalRecord> {
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<DurableGoalRecord | null> {
return this.materialize(await this.readSnapshots()).get(id) ?? null;
}
async list(query: DurableGoalListQuery): Promise<DurableGoalRecord[]> {
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<DurableGoalCompareAndSetResult> {
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<void> {
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<DurableGoalRecord[]> {
let handle: Awaited<ReturnType<typeof open>> | 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<string, DurableGoalRecord> {
const byId = new Map<string, DurableGoalRecord>();
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<void> {
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();
}
}
}

View file

@ -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
);

View file

@ -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,

View file

@ -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<RunSupervisorCompareAndSetResult>;
}
// ---------------------------------------------------------------------------
// Durable Goal Repository
// ---------------------------------------------------------------------------
export interface DurableGoalRepository {
/** Persist one newly allocated durable goal at revision 1. */
create(record: DurableGoalRecord): Promise<DurableGoalRecord>;
/** Return one durable goal, or null when it does not exist. */
get(id: string): Promise<DurableGoalRecord | null>;
/** Query materialized durable goals. */
list(query: DurableGoalListQuery): Promise<DurableGoalRecord[]>;
/** Replace one goal with a revision-guarded compare-and-set operation. */
compareAndSet(input: DurableGoalCompareAndSetInput): Promise<DurableGoalCompareAndSetResult>;
}
// ---------------------------------------------------------------------------
// 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;

View file

@ -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<DurableGoalRecord> {
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<DurableGoalRecord | null> {
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<DurableGoalRecord[]> {
const clauses = ['workspace_id = ?'];
const parameters: Array<string | number> = [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<DurableGoalCompareAndSetResult> {
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);
}

View file

@ -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[] {

View file

@ -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);
}

View file

@ -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';
}

View file

@ -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';