mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: add durable goal state core (#1081)
This commit is contained in:
parent
eb83f92be8
commit
e15bd320e8
15 changed files with 1405 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
106
server/src/__tests__/durable-goal-repository.test.ts
Normal file
106
server/src/__tests__/durable-goal-repository.test.ts
Normal 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;
|
||||
}
|
||||
282
server/src/__tests__/durable-goal-service.test.ts
Normal file
282
server/src/__tests__/durable-goal-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
220
server/src/schemas/durable-goal-schemas.ts
Normal file
220
server/src/schemas/durable-goal-schemas.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
});
|
||||
291
server/src/services/durable-goal-service.ts
Normal file
291
server/src/services/durable-goal-service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
158
server/src/storage/durable-goal-repository.ts
Normal file
158
server/src/storage/durable-goal-repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
135
server/src/storage/sqlite/durable-goal-repository.ts
Normal file
135
server/src/storage/sqlite/durable-goal-repository.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
131
shared/src/types/durable-goal.types.ts
Normal file
131
shared/src/types/durable-goal.types.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue