mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: add durable phase transition controls (#1038)
This commit is contained in:
parent
dcd4e61f66
commit
1c5d44a2af
28 changed files with 2772 additions and 24 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -189,9 +189,13 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
|
|||
profile, sandbox, tool-catalog, and launch-policy authority only through
|
||||
`phase-capability-service.ts`; never union scopes or infer missing
|
||||
dimensions. The plan artifact exception is one harness-owned exact path and
|
||||
never implies general filesystem write authority. Until #1035, #1036, and
|
||||
#1033 land, this compiler is a contract foundation rather than active
|
||||
transition or runtime enforcement.
|
||||
never implies general filesystem write authority. Active phase changes go
|
||||
only through `phase-transition-service.ts` with exact attempt, sequence,
|
||||
evidence-digest, and launch-manifest compare-and-set guards. Authority
|
||||
expansion requires an exact-action approval; an emergency override requires
|
||||
`admin:manage`, expires within 24 hours, and is durably reverted. Until #1036
|
||||
and #1033 land, launch propagation and provider/tool enforcement remain
|
||||
separate delivery boundaries.
|
||||
- Credential-bound tool servers persist only exact definition/scope digests and
|
||||
safe target names in `run-tool-catalog/v1`. Discovery strips their source
|
||||
environment/header values, native provider injection omits them, and
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added the append-only `phase-transition-record/v1` journal for active runs.
|
||||
Every transition is bound to the exact attempt, prior sequence, compiled
|
||||
phase evidence, and launch-manifest digest. Narrowing transitions apply
|
||||
immediately, while authority expansion uses the existing exact-action
|
||||
approval broker. Administrators can apply an auditable emergency expansion
|
||||
for at most 24 hours; expiry durably restores the prior evidence. File and
|
||||
SQLite repositories preserve restart recovery and idempotency, and new REST
|
||||
and CLI controls expose current state, history, transitions, and approval
|
||||
decisions (#1035).
|
||||
- Added the provider-neutral `phase-capability-profile/v1` foundation with
|
||||
strict schemas and built-in explore, plan, implement, verify, and publish
|
||||
profiles. A pure compiler now intersects phase requests with parent, agent,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
- [Agent Providers](docs/AGENT-PROVIDERS.md) — evidence-backed Buzz, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, OpenClaw, and optional model profiles.
|
||||
- [v6 Agent Runtime Control Plane](docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md) — authority, adapter, lifecycle, approval, tool, credential, Buzz, and certification boundaries.
|
||||
- [Phase Capability Profiles](docs/architecture/PHASE-CAPABILITY-PROFILES.md) — versioned execution-phase authority contracts, deterministic intersections, exact-path plan artifacts, and current delivery boundaries.
|
||||
- [Phase Transition Journal](docs/architecture/PHASE-TRANSITION-JOURNAL.md) — durable compare-and-set transitions, approval and override controls, restart recovery, REST, and CLI operations.
|
||||
- [OpenAI Codex Integration Roadmap](docs/CODEX-INTEGRATION.md) — optional local execution, SDK sessions, cloud delegation, MCP setup, workflows, telemetry, and release QA.
|
||||
- [Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) — authority model, HermesAgent roster, QA evidence gate, and GitHub-backed task templates.
|
||||
- [Codex Integration SOP](docs/SOP-codex-integration.md) & [Codex Workflow Examples](docs/EXAMPLES-codex-workflows.md) — operational playbooks for using Codex as a first-class Veritas agent.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const { mockApi, mockFindTask } = vi.hoisted(() => ({
|
||||
|
|
@ -11,6 +14,14 @@ vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
|
|||
|
||||
import { registerAgentCommands } from '../commands/agents.js';
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('vk agent runtime capability controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -135,6 +146,165 @@ describe('vk agent runtime capability controls', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reads durable phase state for one exact attempt', async () => {
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['agent:phase', 'task_1', '--attempt', 'attempt_1', '--limit', '25', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/phase?attemptId=attempt_1&limit=25');
|
||||
});
|
||||
|
||||
it('binds the first phase transition to exact evidence and manifest provenance', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
|
||||
temporaryRoots.push(root);
|
||||
const fromPath = path.join(root, 'from.json');
|
||||
const targetPath = path.join(root, 'target.json');
|
||||
const fromEvidence = { digest: `sha256:${'1'.repeat(64)}` };
|
||||
const targetEvidence = { digest: `sha256:${'2'.repeat(64)}` };
|
||||
await fs.writeFile(fromPath, JSON.stringify(fromEvidence));
|
||||
await fs.writeFile(targetPath, JSON.stringify(targetEvidence));
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] }).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
current: null,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:transition-phase',
|
||||
'task_1',
|
||||
'--attempt',
|
||||
'attempt_1',
|
||||
'--operation',
|
||||
'phase-op-1',
|
||||
'--from-evidence',
|
||||
fromPath,
|
||||
'--target-evidence',
|
||||
targetPath,
|
||||
'--manifest',
|
||||
`sha256:${'3'.repeat(64)}`,
|
||||
'--reason',
|
||||
'Approved plan is ready.',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/agents/task_1/phase/transitions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: 'attempt_1',
|
||||
operationId: 'phase-op-1',
|
||||
expectedSequence: 0,
|
||||
expectedPhaseEvidenceDigest: fromEvidence.digest,
|
||||
expectedManifestDigest: `sha256:${'3'.repeat(64)}`,
|
||||
reason: 'Approved plan is ready.',
|
||||
fromEvidence,
|
||||
targetEvidence,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects partially numeric phase approval lifetimes before transition', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
|
||||
temporaryRoots.push(root);
|
||||
const fromPath = path.join(root, 'from.json');
|
||||
const targetPath = path.join(root, 'target.json');
|
||||
await fs.writeFile(fromPath, JSON.stringify({ digest: `sha256:${'1'.repeat(64)}` }));
|
||||
await fs.writeFile(targetPath, JSON.stringify({ digest: `sha256:${'2'.repeat(64)}` }));
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] });
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as typeof process.exit);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
[
|
||||
'agent:transition-phase',
|
||||
'task_1',
|
||||
'--attempt',
|
||||
'attempt_1',
|
||||
'--operation',
|
||||
'phase-op-1',
|
||||
'--from-evidence',
|
||||
fromPath,
|
||||
'--target-evidence',
|
||||
targetPath,
|
||||
'--manifest',
|
||||
`sha256:${'3'.repeat(64)}`,
|
||||
'--reason',
|
||||
'Approved plan is ready.',
|
||||
'--approval-ttl-ms',
|
||||
'1000x',
|
||||
],
|
||||
{ from: 'user' }
|
||||
)
|
||||
).rejects.toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--approval-ttl-ms must be an integer')
|
||||
);
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('decides an exact phase approval with revision and action-hash guards', async () => {
|
||||
const approval = {
|
||||
id: 'runapproval_000000000001',
|
||||
revision: 4,
|
||||
actionHash: 'a'.repeat(64),
|
||||
status: 'pending',
|
||||
};
|
||||
mockApi.mockResolvedValueOnce(approval).mockResolvedValueOnce({
|
||||
...approval,
|
||||
revision: 5,
|
||||
status: 'approved',
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:decide-phase-approval',
|
||||
approval.id,
|
||||
'--decision',
|
||||
'approve',
|
||||
'--note',
|
||||
'Expansion reviewed.',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, `/api/run-approvals/${approval.id}/decision`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: 'approved',
|
||||
expectedRevision: 4,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: 'Expansion reviewed.',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a native history fork from an explicit source attempt and turn', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import type {
|
|||
AgentProfileValidationResult,
|
||||
ConversationLifecycleRecord,
|
||||
ConversationLifecycleResult,
|
||||
PhaseCapabilityEvidence,
|
||||
PhaseTransitionRecord,
|
||||
PhaseTransitionResult,
|
||||
RunApprovalRequest,
|
||||
RunRecoveryRecord,
|
||||
RunLaunchManifestPreview,
|
||||
WorkspaceExecutionTrustDecision,
|
||||
|
|
@ -36,6 +40,20 @@ interface ConversationControlOptions {
|
|||
json?: boolean;
|
||||
}
|
||||
|
||||
interface PhaseTransitionOptions {
|
||||
attempt: string;
|
||||
operation: string;
|
||||
targetEvidence: string;
|
||||
fromEvidence?: string;
|
||||
manifest?: string;
|
||||
reason: string;
|
||||
approvalId?: string;
|
||||
approvalTtlMs?: string;
|
||||
overrideUntil?: string;
|
||||
overrideReason?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function inferProfileFormat(filePath: string): AgentProfilePackageFormat {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return extension === '.json' ? 'json' : 'yaml';
|
||||
|
|
@ -47,6 +65,10 @@ async function resolveTaskId(id: string): Promise<string> {
|
|||
return task.id;
|
||||
}
|
||||
|
||||
function readPhaseEvidence(filePath: string): PhaseCapabilityEvidence {
|
||||
return JSON.parse(readFileSync(path.resolve(filePath), 'utf8')) as PhaseCapabilityEvidence;
|
||||
}
|
||||
|
||||
function printConversationResult(
|
||||
action: string,
|
||||
result: {
|
||||
|
|
@ -69,6 +91,13 @@ function printConversationResult(
|
|||
if (result.note) console.log(chalk.dim(result.note));
|
||||
}
|
||||
|
||||
function phaseIdentityLabel(record: PhaseTransitionRecord): string {
|
||||
const identity = record.effectiveEvidence.identity;
|
||||
return identity.mode === 'legacy'
|
||||
? 'legacy'
|
||||
: `${identity.phase} (${identity.profileId}@${identity.profileVersion})`;
|
||||
}
|
||||
|
||||
function registerConversationTurnCommand(
|
||||
program: Command,
|
||||
action: ConversationTurnAction,
|
||||
|
|
@ -565,6 +594,179 @@ export function registerAgentCommands(program: Command): void {
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:phase <id>')
|
||||
.description('Show the active durable phase and transition history for an exact run')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact attempt ID')
|
||||
.option('--limit <count>', 'Maximum transition records', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { attempt: string; limit: string; json?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{
|
||||
current: PhaseTransitionRecord | null;
|
||||
history: PhaseTransitionRecord[];
|
||||
}>(
|
||||
`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}&limit=${encodeURIComponent(options.limit)}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (!result.current) {
|
||||
console.log(chalk.dim('No durable phase transition is recorded for this run'));
|
||||
} else {
|
||||
console.log(chalk.cyan(`Phase: ${phaseIdentityLabel(result.current)}`));
|
||||
console.log(chalk.dim(`Sequence: ${result.current.sequence}`));
|
||||
console.log(chalk.dim(`Evidence: ${result.current.effectiveEvidence.digest}`));
|
||||
console.log(chalk.dim(`Manifest: ${result.current.manifestDigest}`));
|
||||
if (result.current.emergencyOverride) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Emergency override expires: ${result.current.emergencyOverride.expiresAt}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:transition-phase <id>')
|
||||
.description('Apply or request approval for one compare-and-set phase transition')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.requiredOption('--operation <id>', 'Stable idempotency key for this transition')
|
||||
.requiredOption('--target-evidence <file>', 'Compiled target phase evidence JSON')
|
||||
.requiredOption('--reason <text>', 'Operator reason')
|
||||
.option('--from-evidence <file>', 'Initial phase evidence JSON for the first transition')
|
||||
.option('--manifest <digest>', 'Launch manifest digest for the first transition')
|
||||
.option('--approval-id <id>', 'Exact approval returned by the prior request')
|
||||
.option('--approval-ttl-ms <milliseconds>', 'Approval request lifetime')
|
||||
.option('--override-until <timestamp>', 'Emergency override expiry, at most 24 hours')
|
||||
.option('--override-reason <text>', 'Emergency override justification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: PhaseTransitionOptions): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const state = await api<{
|
||||
current: PhaseTransitionRecord | null;
|
||||
history: PhaseTransitionRecord[];
|
||||
}>(`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}`);
|
||||
const fromEvidence = options.fromEvidence
|
||||
? readPhaseEvidence(options.fromEvidence)
|
||||
: undefined;
|
||||
const priorEvidence = state.current?.effectiveEvidence ?? fromEvidence;
|
||||
if (!priorEvidence) {
|
||||
throw new Error('The first transition requires --from-evidence');
|
||||
}
|
||||
const manifestDigest = state.current?.manifestDigest ?? options.manifest;
|
||||
if (!manifestDigest) {
|
||||
throw new Error('The first transition requires --manifest');
|
||||
}
|
||||
if (
|
||||
(options.overrideUntil && !options.overrideReason) ||
|
||||
(!options.overrideUntil && options.overrideReason)
|
||||
) {
|
||||
throw new Error('--override-until and --override-reason must be used together');
|
||||
}
|
||||
const approvalTtlMs =
|
||||
options.approvalTtlMs && /^\d+$/.test(options.approvalTtlMs)
|
||||
? Number(options.approvalTtlMs)
|
||||
: undefined;
|
||||
if (options.approvalTtlMs && !Number.isSafeInteger(approvalTtlMs)) {
|
||||
throw new Error('--approval-ttl-ms must be an integer');
|
||||
}
|
||||
const result = await api<PhaseTransitionResult>(`/api/agents/${taskId}/phase/transitions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: options.attempt,
|
||||
operationId: options.operation,
|
||||
expectedSequence: state.current?.sequence ?? 0,
|
||||
expectedPhaseEvidenceDigest: priorEvidence.digest,
|
||||
expectedManifestDigest: manifestDigest,
|
||||
reason: options.reason,
|
||||
...(state.current ? {} : { fromEvidence: priorEvidence }),
|
||||
targetEvidence: readPhaseEvidence(options.targetEvidence),
|
||||
approvalId: options.approvalId,
|
||||
approvalTtlMs,
|
||||
...(options.overrideUntil && options.overrideReason
|
||||
? {
|
||||
emergencyOverride: {
|
||||
expiresAt: options.overrideUntil,
|
||||
justification: options.overrideReason,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.status === 'approval-required' && result.approval) {
|
||||
console.log(chalk.yellow('Phase expansion requires approval'));
|
||||
console.log(` Approval: ${result.approval.id}`);
|
||||
console.log(` Revision: ${result.approval.revision}`);
|
||||
console.log(` Action hash: ${result.approval.actionHash}`);
|
||||
console.log(
|
||||
chalk.dim('Approve it, then retry this command with the same --operation value.')
|
||||
);
|
||||
} else if (result.record) {
|
||||
console.log(chalk.green(`✓ Phase transitioned to ${phaseIdentityLabel(result.record)}`));
|
||||
console.log(chalk.dim(`Sequence: ${result.record.sequence}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:decide-phase-approval <approvalId>')
|
||||
.description('Approve or reject an exact pending phase transition')
|
||||
.requiredOption('--decision <decision>', 'approve or reject')
|
||||
.option('--note <text>', 'Decision note')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
approvalId: string,
|
||||
options: { decision: string; note?: string; json?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!['approve', 'reject'].includes(options.decision)) {
|
||||
throw new Error('--decision must be approve or reject');
|
||||
}
|
||||
const approval = await api<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approvalId)}`
|
||||
);
|
||||
const decided = await api<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approval.id)}/decision`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: options.decision === 'approve' ? 'approved' : 'rejected',
|
||||
expectedRevision: approval.revision,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: options.note,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decided, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Phase transition approval ${decided.status}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:cancel-recovery <id>')
|
||||
.description('Cancel the exact pending retry or fallback for a task')
|
||||
|
|
|
|||
|
|
@ -2402,6 +2402,49 @@ Approval-required tools are omitted from native provider configuration and use
|
|||
the mediated tool-call API below. Prompt text is never accepted as equivalent
|
||||
enforcement.
|
||||
|
||||
### Durable Phase Transitions
|
||||
|
||||
```
|
||||
GET /api/agents/:taskId/phase?attemptId=attempt_123&limit=100
|
||||
POST /api/agents/:taskId/phase/transitions
|
||||
```
|
||||
|
||||
The GET endpoint returns `current` plus bounded append-only `history` for one
|
||||
exact run. The POST body is a compare-and-set request:
|
||||
|
||||
```json
|
||||
{
|
||||
"attemptId": "attempt_123",
|
||||
"operationId": "move-to-implement",
|
||||
"expectedSequence": 1,
|
||||
"expectedPhaseEvidenceDigest": "sha256:...",
|
||||
"expectedManifestDigest": "sha256:...",
|
||||
"reason": "The approved plan is ready to implement.",
|
||||
"targetEvidence": {
|
||||
"schemaVersion": "phase-capability-evidence/v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`targetEvidence` must be the complete compiled evidence document with a valid
|
||||
content digest. The first transition also includes `fromEvidence`; subsequent
|
||||
requests must match the journal's current sequence and evidence. The active
|
||||
attempt and persisted launch-manifest digest must still match.
|
||||
|
||||
Same-authority and narrowing transitions return `201` with `status: "applied"`.
|
||||
Expansion creates an exact-action run approval and returns `202` with
|
||||
`status: "approval-required"`. After approval, retry the identical operation
|
||||
with its `approvalId`. Emergency expansion accepts
|
||||
`emergencyOverride: { justification, expiresAt }`, requires `admin:manage`, and
|
||||
cannot last more than 24 hours. Expiry appends a system-attributed restoration
|
||||
of the prior evidence.
|
||||
|
||||
Reads require `agent:read`; transition requests require `task:write`. Approval
|
||||
decisions use `/api/run-approvals/:approvalId/decision` and require an
|
||||
administrator. See
|
||||
[Phase Transition Journal](architecture/PHASE-TRANSITION-JOURNAL.md) for
|
||||
storage, idempotency, and delivery boundaries.
|
||||
|
||||
### Automatic Run Recovery
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -455,6 +455,9 @@ Manage AI agents on code tasks.
|
|||
| `vk stop <id>` | Stop a run only when its persisted manifest supports stop |
|
||||
| `vk agent:recovery <id>` | Inspect the latest retry or fallback decision |
|
||||
| `vk agent:cancel-recovery <id> --attempt <id>` | Cancel the exact pending recovery parent |
|
||||
| `vk agent:phase <id> --attempt <id>` | Read durable phase state and transition history |
|
||||
| `vk agent:transition-phase <id> ...` | Apply or request approval for one exact phase transition |
|
||||
| `vk agent:decide-phase-approval <approvalId> ...` | Approve or reject an exact pending phase expansion |
|
||||
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume the exact persisted provider conversation |
|
||||
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a provider-native follow-up turn |
|
||||
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork provider history without mutating its source |
|
||||
|
|
@ -543,6 +546,27 @@ vk agent:cancel-recovery TASK-001 --attempt attempt_parent --json
|
|||
The server rejects stale parent IDs, recoveries that already launched, and
|
||||
recoveries that are already terminal.
|
||||
|
||||
Inspect and transition the exact active phase:
|
||||
|
||||
```bash
|
||||
vk agent:phase TASK-001 --attempt attempt_123 --json
|
||||
vk agent:transition-phase TASK-001 \
|
||||
--attempt attempt_123 \
|
||||
--operation move-to-implement \
|
||||
--target-evidence ./implement-evidence.json \
|
||||
--reason "The approved plan is ready to implement." \
|
||||
--json
|
||||
```
|
||||
|
||||
The first transition also requires `--from-evidence <file>` and
|
||||
`--manifest <sha256:...>`. Later requests read the current journal record and
|
||||
automatically bind its sequence, evidence digest, and manifest. Narrowing
|
||||
applies immediately. Expansion returns an exact approval; decide it with
|
||||
`vk agent:decide-phase-approval <id> --decision approve`, then retry the same
|
||||
transition with the same `--operation` and `--approval-id`. Emergency expansion
|
||||
requires `--override-until` and `--override-reason`; the authenticated caller
|
||||
must be an administrator and the expiry cannot exceed 24 hours.
|
||||
|
||||
---
|
||||
|
||||
### Prompt Commands
|
||||
|
|
|
|||
|
|
@ -338,14 +338,18 @@ First-class support for autonomous coding agents.
|
|||
- **Team roster routing manifests** — Workspace coordinators can define enabled members, capabilities, routing rules, fallbacks, reviewers, and escalation posture before `/api/agents/route` selects an agent
|
||||
- **Workspace capability discovery** — Trusted workspace catalogs expose supported task types, SLA/queue posture, intake requirements, and delegated-work packaging so cross-workspace handoffs are explicit
|
||||
- **Agent profile packages** — Reusable YAML/JSON packages bundle role, runtime, model, prompt instructions, tools, permissions, sandbox, budget, workflow, and health metadata for portable task launches
|
||||
- **Phase capability contract** — Versioned explore, plan, implement, verify,
|
||||
and publish profiles compile a monotonic intersection across parent, agent,
|
||||
sandbox, tool-catalog, and launch policy authority. Unsupported required
|
||||
dimensions fail closed, legacy mode remains explicit, and plan artifacts
|
||||
stay bound to one harness-owned exact path. This is the model/compiler
|
||||
foundation; transition persistence, launch propagation, tool enforcement,
|
||||
and UI remain tracked in #1035, #1036, and #1033. See
|
||||
[Phase Capability Profiles](architecture/PHASE-CAPABILITY-PROFILES.md).
|
||||
- **Phase capability contract and transition journal** — Versioned explore,
|
||||
plan, implement, verify, and publish profiles compile a monotonic
|
||||
intersection across parent, agent, sandbox, tool-catalog, and launch policy
|
||||
authority. Unsupported required dimensions fail closed, legacy mode remains
|
||||
explicit, and plan artifacts stay bound to one harness-owned exact path.
|
||||
Active runs persist append-only compare-and-set transitions with actor,
|
||||
authority delta, policy, approval or override, manifest, and event evidence.
|
||||
Expansion requires exact-action approval; administrator overrides expire and
|
||||
durably restore the prior phase. Launch propagation, tool enforcement, and UI
|
||||
remain tracked in #1036 and #1033. See
|
||||
[Phase Capability Profiles](architecture/PHASE-CAPABILITY-PROFILES.md) and
|
||||
[Phase Transition Journal](architecture/PHASE-TRANSITION-JOURNAL.md).
|
||||
- **Provider runtime manifests** — Every executable adapter records a versioned, evidence-backed capability snapshot and digest on the attempt, history, trace, and log; provider version skew reruns conformance and unsupported configured providers fail closed instead of falling back to OpenClaw
|
||||
- **Cross-harness compatibility matrix** — Buzz, Grok Build, OpenAI Codex app-server, Claude Code, and GitHub Copilot CLI publish exact reviewed builds, source-availability caveats, deterministic fixture identity, capability evidence, limitations, and live support tiers through one API record consumed by Settings, `vk doctor`, telemetry, and [operator guidance](HARNESS-COMPATIBILITY.md)
|
||||
- **Harness conformance suites** — Versioned seeded scenarios compare
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
Issue #1034 establishes the provider-neutral authority contract for execution
|
||||
phases. It defines what a phase may request and how Veritas computes the
|
||||
effective result before runtime transition, propagation, and tool enforcement
|
||||
are added in the remaining #875 slices.
|
||||
effective result. Issue #1035 adds durable active-run transitions and operator
|
||||
controls; propagation and tool enforcement remain in later #875 slices.
|
||||
|
||||
This foundation is intentionally pure. It does not change an active attempt,
|
||||
The compiler remains intentionally pure. It does not mutate an active attempt,
|
||||
persist a transition, filter a tool catalog, or claim that a provider enforced
|
||||
the result.
|
||||
the result. The separate
|
||||
[Phase Transition Journal](PHASE-TRANSITION-JOURNAL.md) owns active state
|
||||
changes and their evidence.
|
||||
|
||||
## Contract
|
||||
|
||||
|
|
@ -109,12 +111,15 @@ grant.
|
|||
|
||||
## Delivery boundary
|
||||
|
||||
This issue supplies the shared types, strict schemas, built-in profiles, pure
|
||||
compiler, and focused matrix coverage. The remaining tracking-epic slices add:
|
||||
The delivered phase control plane now includes:
|
||||
|
||||
- Durable transition state, approvals, and operator controls in #1035
|
||||
- Launch, descendant, retry, resume, and handoff propagation in #1036
|
||||
- Tool enforcement, evidence surfaces, and UI in #1033
|
||||
- Shared types, strict schemas, built-in profiles, and the pure compiler from
|
||||
#1034
|
||||
- Durable transition state, approvals, emergency override expiry, restart
|
||||
recovery, REST, and CLI controls from #1035
|
||||
|
||||
Until those slices land, compiled phase evidence is an architecture contract,
|
||||
not a claim that active runs are phase-restricted.
|
||||
The remaining tracking-epic slices add launch, descendant, retry, resume, and
|
||||
handoff propagation in #1036, then tool enforcement, evidence surfaces, and UI
|
||||
in #1033. Until those slices land, a durable transition is authoritative
|
||||
Veritas state, not a claim that every provider process or tool has enforced the
|
||||
new phase.
|
||||
|
|
|
|||
128
docs/architecture/PHASE-TRANSITION-JOURNAL.md
Normal file
128
docs/architecture/PHASE-TRANSITION-JOURNAL.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Phase Transition Journal
|
||||
|
||||
Issue #1035 adds the durable state machine that moves one active run between
|
||||
compiled phase capability profiles. It builds on the
|
||||
[Phase Capability Profiles](PHASE-CAPABILITY-PROFILES.md) contract without
|
||||
claiming launch or tool enforcement that belongs to #1036 and #1033.
|
||||
|
||||
## Durable record
|
||||
|
||||
Each applied change appends one immutable `phase-transition-record/v1` record.
|
||||
The record binds:
|
||||
|
||||
- Workspace, task, active attempt, sequence, and idempotent operation ID
|
||||
- Prior and effective `phase-capability-evidence/v1` documents
|
||||
- Added and removed scopes for every changed authority dimension
|
||||
- Verified actor, reason, and policy decision
|
||||
- Exact approval or emergency-override evidence when required
|
||||
- Active `run-launch-manifest/v1` digest
|
||||
- Deterministic run-event projection reference and timestamp
|
||||
|
||||
File storage uses a locked, bounded JSONL journal. SQLite uses an append-only
|
||||
table with unique run sequence and operation constraints. Both repositories
|
||||
implement the same compare-and-set contract and recover the current phase as
|
||||
the highest sequence for the exact workspace, task, and attempt.
|
||||
|
||||
## Compare-and-set rules
|
||||
|
||||
A request supplies the expected sequence, prior phase-evidence digest, and
|
||||
launch-manifest digest. The server also verifies that the task still has the
|
||||
same running attempt, executable provider, and manifest before appending.
|
||||
|
||||
The first transition additionally supplies the exact initial compiled
|
||||
evidence. Later transitions use the journal's current evidence. Stale attempt,
|
||||
sequence, evidence, manifest, or changed reuse of an operation ID fails closed.
|
||||
An exact duplicate operation returns the original record without replaying the
|
||||
transition.
|
||||
|
||||
Evidence is validated against its content digest. A blocked result or legacy
|
||||
identity cannot become the target of an operator transition.
|
||||
|
||||
## Policy and approvals
|
||||
|
||||
The authority delta is calculated independently for filesystem read and write,
|
||||
command execution, network egress, credentials, external actions, and the plan
|
||||
artifact capability.
|
||||
|
||||
- Same-authority and narrowing transitions apply immediately.
|
||||
- Any added scope creates or reuses an exact-action request in the existing run
|
||||
approval broker.
|
||||
- Approval binds the operation, prior and target evidence digests, manifest,
|
||||
and complete authority delta.
|
||||
- Pending approval returns `approval-required`; rejected or expired approval
|
||||
fails closed.
|
||||
- Credential or external-action expansion is classified as critical risk.
|
||||
|
||||
An emergency expansion requires verified `admin:manage` authority, a reason,
|
||||
and an expiry no more than 24 hours in the future. The first read after expiry
|
||||
appends one system-attributed `override-expired` transition that restores the
|
||||
prior evidence. Concurrent readers use compare-and-set behavior, so only one
|
||||
expiry record wins.
|
||||
|
||||
## REST controls
|
||||
|
||||
Read the active phase and bounded history:
|
||||
|
||||
```http
|
||||
GET /api/agents/:taskId/phase?attemptId=attempt_123&limit=100
|
||||
```
|
||||
|
||||
Request or apply a transition:
|
||||
|
||||
```http
|
||||
POST /api/agents/:taskId/phase/transitions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"attemptId": "attempt_123",
|
||||
"operationId": "move-to-implement",
|
||||
"expectedSequence": 1,
|
||||
"expectedPhaseEvidenceDigest": "sha256:...",
|
||||
"expectedManifestDigest": "sha256:...",
|
||||
"reason": "The approved plan is ready to implement.",
|
||||
"targetEvidence": {
|
||||
"schemaVersion": "phase-capability-evidence/v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The abbreviated `targetEvidence` above represents the complete compiled
|
||||
evidence document. Reads require `agent:read`; transition requests require
|
||||
`task:write`. Expansion is not applied until an administrator resolves its
|
||||
approval. Emergency override authority is checked independently by the service.
|
||||
|
||||
## CLI controls
|
||||
|
||||
```bash
|
||||
vk agent:phase TASK-001 --attempt attempt_123 --json
|
||||
|
||||
vk agent:transition-phase TASK-001 \
|
||||
--attempt attempt_123 \
|
||||
--operation move-to-implement \
|
||||
--target-evidence ./implement-evidence.json \
|
||||
--reason "The approved plan is ready to implement." \
|
||||
--json
|
||||
|
||||
vk agent:decide-phase-approval runapproval_123 \
|
||||
--decision approve \
|
||||
--note "Reviewed the exact authority delta." \
|
||||
--json
|
||||
```
|
||||
|
||||
For the first transition, add `--from-evidence` and `--manifest`. The CLI reads
|
||||
the current durable record for later transitions and supplies its sequence,
|
||||
evidence digest, and manifest automatically. Retry an approved expansion with
|
||||
the same `--operation` value and the returned `--approval-id`.
|
||||
|
||||
Emergency override uses `--override-until` and `--override-reason` together.
|
||||
The server remains authoritative for administrator permission and maximum
|
||||
expiry.
|
||||
|
||||
## Delivery boundary
|
||||
|
||||
The journal makes phase state, approval, expiry, restart recovery, and operator
|
||||
control durable. Issue #1036 will bind compiled evidence into launch,
|
||||
descendant, retry, resume, and handoff behavior. Issue #1033 will enforce the
|
||||
active evidence at tool invocation and expose it through evidence and UI
|
||||
surfaces. Until those slices land, clients must not describe the journal alone
|
||||
as complete provider-side phase enforcement.
|
||||
|
|
@ -699,6 +699,30 @@
|
|||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Cancelling an exact pending recovery requires task read and task write access."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:agent:phase",
|
||||
"kind": "cli",
|
||||
"classification": "authenticated-read",
|
||||
"permissions": ["task:read", "agent:read"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Reading active phase state requires task and agent read access."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:agent:transition-phase",
|
||||
"kind": "cli",
|
||||
"classification": "agent-scoped",
|
||||
"permissions": ["task:read", "task:write"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Requesting a compare-and-set phase transition requires task read and write access; expansions still require exact approval."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:agent:decide-phase-approval",
|
||||
"kind": "cli",
|
||||
"classification": "owner-admin",
|
||||
"permissions": ["task:read", "admin:manage"],
|
||||
"source": "cli/src/commands/agents.ts",
|
||||
"denialReason": "Approving or rejecting an authority expansion requires administrator authority."
|
||||
},
|
||||
{
|
||||
"id": "cli:agents:agent:resume",
|
||||
"kind": "cli",
|
||||
|
|
|
|||
167
server/src/__tests__/phase-transition-repository.test.ts
Normal file
167
server/src/__tests__/phase-transition-repository.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PHASE_AUTHORITY_DIMENSIONS,
|
||||
PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
|
||||
type PhaseAuthorityDimension,
|
||||
type PhaseAuthoritySource,
|
||||
type PhaseTransitionRecord,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
compilePhaseCapabilityAuthority,
|
||||
getBuiltInPhaseCapabilityProfile,
|
||||
} from '../services/phase-capability-service.js';
|
||||
import { calculatePhaseAuthorityDelta } from '../services/phase-transition-service.js';
|
||||
import type { PhaseTransitionRepository } from '../storage/interfaces.js';
|
||||
import { FilePhaseTransitionRepository } from '../storage/phase-transition-repository.js';
|
||||
import { SqliteDatabase } from '../storage/sqlite/database.js';
|
||||
import { SqlitePhaseTransitionRepository } from '../storage/sqlite/phase-transition-repository.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
const MANIFEST_DIGEST = `sha256:${'1'.repeat(64)}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('phase transition repository parity', () => {
|
||||
it('persists append-only compare-and-set state in the file backend', async () => {
|
||||
const root = await temporaryRoot();
|
||||
const filePath = path.join(root, 'phase-transitions.jsonl');
|
||||
|
||||
await exerciseRepository(
|
||||
new FilePhaseTransitionRepository(filePath),
|
||||
async () => new FilePhaseTransitionRepository(filePath)
|
||||
);
|
||||
});
|
||||
|
||||
it('persists append-only compare-and-set state through SQLite restart', async () => {
|
||||
const root = await temporaryRoot();
|
||||
const databasePath = path.join(root, 'veritas.db');
|
||||
let database = new SqliteDatabase({ databasePath });
|
||||
database.open();
|
||||
const repository = new SqlitePhaseTransitionRepository(database);
|
||||
|
||||
await exerciseRepository(repository, async () => {
|
||||
database.close();
|
||||
database = new SqliteDatabase({ databasePath });
|
||||
database.open();
|
||||
return new SqlitePhaseTransitionRepository(database);
|
||||
});
|
||||
|
||||
database.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function exerciseRepository(
|
||||
repository: PhaseTransitionRepository,
|
||||
reopen: () => Promise<PhaseTransitionRepository>
|
||||
): Promise<void> {
|
||||
const record = transitionRecord();
|
||||
const input = {
|
||||
record,
|
||||
expectedSequence: 0,
|
||||
expectedPhaseEvidenceDigest: record.priorEvidence.digest,
|
||||
expectedManifestDigest: MANIFEST_DIGEST,
|
||||
};
|
||||
const first = await repository.append(input);
|
||||
const duplicate = await repository.append(input);
|
||||
const staleRecord = {
|
||||
...record,
|
||||
id: 'phasetransition_000000000000000002',
|
||||
operationId: 'stale-operation',
|
||||
};
|
||||
const stale = await repository.append({ ...input, record: staleRecord });
|
||||
|
||||
expect(first).toMatchObject({ appended: true, record: { sequence: 1 } });
|
||||
expect(duplicate).toMatchObject({ appended: false, record: { id: record.id } });
|
||||
expect(stale).toMatchObject({ appended: false, reason: 'stale-sequence' });
|
||||
expect(
|
||||
await repository.getByOperationId('local', 'task-1', 'attempt-1', record.operationId)
|
||||
).toEqual(record);
|
||||
expect(await repository.list(query())).toEqual([record]);
|
||||
|
||||
const restarted = await reopen();
|
||||
expect(await restarted.getCurrent('local', 'task-1', 'attempt-1')).toEqual(record);
|
||||
expect(await restarted.list(query())).toEqual([record]);
|
||||
}
|
||||
|
||||
function transitionRecord(): PhaseTransitionRecord {
|
||||
const priorEvidence = phaseEvidence('implement');
|
||||
const effectiveEvidence = phaseEvidence('plan');
|
||||
return {
|
||||
schemaVersion: PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
|
||||
id: 'phasetransition_000000000000000001',
|
||||
workspaceId: 'local',
|
||||
taskId: 'task-1',
|
||||
attemptId: 'attempt-1',
|
||||
sequence: 1,
|
||||
operationId: 'transition-1',
|
||||
priorEvidence,
|
||||
effectiveEvidence,
|
||||
authorityDelta: calculatePhaseAuthorityDelta(
|
||||
priorEvidence.effectiveAuthority,
|
||||
effectiveEvidence.effectiveAuthority
|
||||
),
|
||||
actor: {
|
||||
id: 'operator',
|
||||
type: 'user',
|
||||
authMethod: 'session',
|
||||
workspaceId: 'local',
|
||||
},
|
||||
reason: 'Narrow to planning.',
|
||||
policyDecision: 'allow',
|
||||
manifestDigest: MANIFEST_DIGEST,
|
||||
eventReference: 'phase:phasetransition_000000000000000001',
|
||||
createdAt: '2026-07-25T01:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function phaseEvidence(phase: 'plan' | 'implement') {
|
||||
return compilePhaseCapabilityAuthority({
|
||||
profile: getBuiltInPhaseCapabilityProfile(phase),
|
||||
sources: {
|
||||
parent: source('parent', 'parent'),
|
||||
agentProfile: source('agent-profile', 'agent-profile'),
|
||||
sandbox: source('sandbox', 'sandbox'),
|
||||
toolCatalog: source('tool-catalog', 'tool-catalog'),
|
||||
launchPolicy: source('launch-policy', 'launch-policy'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function source<K extends PhaseAuthoritySource['kind']>(
|
||||
id: string,
|
||||
kind: K
|
||||
): PhaseAuthoritySource & { kind: K } {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
authority: dimensions(() => ['*']),
|
||||
enforcement: dimensions(() => 'enforced' as const),
|
||||
};
|
||||
}
|
||||
|
||||
function dimensions<T>(
|
||||
value: (dimension: PhaseAuthorityDimension) => T
|
||||
): Record<PhaseAuthorityDimension, T> {
|
||||
return Object.fromEntries(
|
||||
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
|
||||
) as Record<PhaseAuthorityDimension, T>;
|
||||
}
|
||||
|
||||
function query() {
|
||||
return {
|
||||
workspaceId: 'local',
|
||||
taskId: 'task-1',
|
||||
attemptId: 'attempt-1',
|
||||
};
|
||||
}
|
||||
|
||||
async function temporaryRoot(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-phase-transitions-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
484
server/src/__tests__/phase-transition-service.test.ts
Normal file
484
server/src/__tests__/phase-transition-service.test.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
PhaseAuthorityDimension,
|
||||
PhaseAuthoritySource,
|
||||
PhaseCapabilityEvidence,
|
||||
RunLaunchManifest,
|
||||
RunApprovalRequest,
|
||||
RunApprovalStatus,
|
||||
RunEventAppendInput,
|
||||
RunEventAppendResult,
|
||||
Task,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { PHASE_AUTHORITY_DIMENSIONS } from '@veritas-kanban/shared';
|
||||
import { InMemoryPhaseTransitionRepository } from '../storage/phase-transition-repository.js';
|
||||
import {
|
||||
calculatePhaseCapabilityEvidenceDigest,
|
||||
compilePhaseCapabilityAuthority,
|
||||
getBuiltInPhaseCapabilityProfile,
|
||||
} from '../services/phase-capability-service.js';
|
||||
import {
|
||||
PhaseTransitionService,
|
||||
type PhaseTransitionActorContext,
|
||||
} from '../services/phase-transition-service.js';
|
||||
import type { CreateRunApprovalRequestInput } from '../services/run-approval-broker-service.js';
|
||||
|
||||
const MANIFEST_DIGEST = `sha256:${'1'.repeat(64)}`;
|
||||
const WORKSPACE_ID = 'local';
|
||||
const TASK_ID = 'task-1';
|
||||
const ATTEMPT_ID = 'attempt-1';
|
||||
|
||||
describe('PhaseTransitionService', () => {
|
||||
it('applies a narrowing transition immediately with durable evidence', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('implement');
|
||||
const target = evidence('plan');
|
||||
|
||||
const result = await fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, target, 'narrow-1'),
|
||||
operator()
|
||||
);
|
||||
|
||||
expect(result.status).toBe('applied');
|
||||
expect(result.record).toMatchObject({
|
||||
sequence: 1,
|
||||
operationId: 'narrow-1',
|
||||
policyDecision: 'allow',
|
||||
manifestDigest: MANIFEST_DIGEST,
|
||||
});
|
||||
expect(result.record?.authorityDelta.classification).toBe('narrowing');
|
||||
expect(result.record?.approvalId).toBeUndefined();
|
||||
expect(fixture.journal.inputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('requires exact-action approval before applying an expansion', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('plan');
|
||||
const target = evidence('implement');
|
||||
const input = request(from, target, 'expand-1');
|
||||
|
||||
const pending = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
|
||||
|
||||
expect(pending.status).toBe('approval-required');
|
||||
expect(pending.approval).toMatchObject({
|
||||
status: 'pending',
|
||||
actionClass: 'workflow',
|
||||
evidenceRevision: from.digest,
|
||||
});
|
||||
expect(await fixture.repository.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID)).toBeNull();
|
||||
|
||||
fixture.approvals.resolve('approved');
|
||||
const applied = await fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
{ ...input, approvalId: pending.approval?.id },
|
||||
operator()
|
||||
);
|
||||
|
||||
expect(applied.status).toBe('applied');
|
||||
expect(applied.record).toMatchObject({
|
||||
policyDecision: 'approved-expansion',
|
||||
approvalId: pending.approval?.id,
|
||||
});
|
||||
expect(applied.record?.authorityDelta.classification).toBe('expanding');
|
||||
});
|
||||
|
||||
it.each(['rejected', 'expired'] as const)(
|
||||
'does not apply an expansion after approval is %s',
|
||||
async (status) => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('plan');
|
||||
const target = evidence('implement');
|
||||
const input = request(from, target, `expand-${status}`);
|
||||
|
||||
await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
|
||||
fixture.approvals.resolve(status);
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator())
|
||||
).rejects.toThrow('Phase transition approval is not approved');
|
||||
expect(await fixture.repository.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('returns the original record for an exact duplicate operation', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('implement');
|
||||
const target = evidence('plan');
|
||||
const input = request(from, target, 'duplicate-1');
|
||||
|
||||
const first = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
|
||||
const duplicate = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
|
||||
const history = await fixture.repository.list({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
taskId: TASK_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
});
|
||||
|
||||
expect(duplicate.record?.id).toBe(first.record?.id);
|
||||
expect(history).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects reuse of an operation identity for changed evidence', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('implement');
|
||||
await fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, evidence('plan'), 'operation-1'),
|
||||
operator()
|
||||
);
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, evidence('verify'), 'operation-1'),
|
||||
operator()
|
||||
)
|
||||
).rejects.toThrow('Phase transition compare-and-set failed');
|
||||
});
|
||||
|
||||
it('fails closed on stale sequence, phase evidence, and manifest provenance', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('implement');
|
||||
const target = evidence('plan');
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
{ ...request(from, target, 'stale-sequence'), expectedSequence: 1 },
|
||||
operator()
|
||||
)
|
||||
).rejects.toThrow('Phase transition sequence is stale');
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
{
|
||||
...request(from, target, 'stale-evidence'),
|
||||
expectedPhaseEvidenceDigest: evidence('explore').digest,
|
||||
},
|
||||
operator()
|
||||
)
|
||||
).rejects.toThrow('Phase transition evidence is stale');
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
{
|
||||
...request(from, target, 'stale-manifest'),
|
||||
expectedManifestDigest: `sha256:${'2'.repeat(64)}`,
|
||||
},
|
||||
operator()
|
||||
)
|
||||
).rejects.toThrow('launch-manifest evidence is stale');
|
||||
});
|
||||
|
||||
it('requires administrator authority for an expiring emergency override', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('plan');
|
||||
const target = evidence('publish');
|
||||
const input = {
|
||||
...request(from, target, 'override-1'),
|
||||
emergencyOverride: {
|
||||
justification: 'Restore publication while the approval service is unavailable.',
|
||||
expiresAt: '2026-07-25T01:30:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator(false))
|
||||
).rejects.toThrow('admin:manage');
|
||||
|
||||
const applied = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator(true));
|
||||
expect(applied.record).toMatchObject({
|
||||
policyDecision: 'emergency-override',
|
||||
emergencyOverride: {
|
||||
permission: 'admin:manage',
|
||||
expiresAt: '2026-07-25T01:30:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers after restart and durably narrows an expired override exactly once', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('plan');
|
||||
const target = evidence('publish');
|
||||
await fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
{
|
||||
...request(from, target, 'override-restart'),
|
||||
emergencyOverride: {
|
||||
justification: 'Time-bounded publication recovery.',
|
||||
expiresAt: '2026-07-25T01:30:00.000Z',
|
||||
},
|
||||
},
|
||||
operator(true)
|
||||
);
|
||||
|
||||
fixture.clock.value = new Date('2026-07-25T01:31:00.000Z');
|
||||
const restarted = fixture.restart();
|
||||
const [left, right] = await Promise.all([
|
||||
restarted.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID),
|
||||
restarted.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID),
|
||||
]);
|
||||
const history = await fixture.repository.list({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
taskId: TASK_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
});
|
||||
|
||||
expect(left?.effectiveEvidence.digest).toBe(from.digest);
|
||||
expect(right?.effectiveEvidence.digest).toBe(from.digest);
|
||||
expect(left?.policyDecision).toBe('override-expired');
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history.map((record) => record.sequence)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('allows only one winner for concurrent compare-and-set transitions', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('implement');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, evidence('plan'), 'race-plan'),
|
||||
operator()
|
||||
),
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, evidence('verify'), 'race-verify'),
|
||||
operator()
|
||||
),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||
expect(
|
||||
await fixture.repository.list({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
taskId: TASK_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
})
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects blocked or content-tampered target evidence', async () => {
|
||||
const fixture = createFixture();
|
||||
const from = evidence('implement');
|
||||
const blocked = structuredClone(evidence('plan'));
|
||||
blocked.status = 'blocked';
|
||||
blocked.digest = recalculate(blocked);
|
||||
|
||||
await expect(
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, blocked, 'blocked-target'),
|
||||
operator()
|
||||
)
|
||||
).rejects.toThrow('Blocked phase evidence');
|
||||
|
||||
const tampered = structuredClone(evidence('plan'));
|
||||
tampered.effectiveAuthority['filesystem.write'] = ['<workspace>'];
|
||||
await expect(
|
||||
fixture.service.transition(
|
||||
WORKSPACE_ID,
|
||||
TASK_ID,
|
||||
request(from, tampered, 'tampered-target'),
|
||||
operator()
|
||||
)
|
||||
).rejects.toThrow('digest does not match');
|
||||
});
|
||||
});
|
||||
|
||||
function createFixture() {
|
||||
const repository = new InMemoryPhaseTransitionRepository();
|
||||
const approvals = new FakeApprovals();
|
||||
const journal = new FakeJournal();
|
||||
const clock = { value: new Date('2026-07-25T01:00:00.000Z') };
|
||||
let nextId = 1;
|
||||
const options = {
|
||||
repository,
|
||||
tasks: {
|
||||
findById: async (id: string) => (id === TASK_ID ? activeTask() : null),
|
||||
},
|
||||
approvals,
|
||||
journal,
|
||||
now: () => clock.value,
|
||||
id: () => `phasetransition_${String(nextId++).padStart(18, '0')}`,
|
||||
};
|
||||
return {
|
||||
repository,
|
||||
approvals,
|
||||
journal,
|
||||
clock,
|
||||
service: new PhaseTransitionService(options),
|
||||
restart: () => new PhaseTransitionService(options),
|
||||
};
|
||||
}
|
||||
|
||||
function activeTask(): Task {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: 'Phase transition test',
|
||||
description: 'Fixture',
|
||||
type: 'task',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
created: '2026-07-25T00:00:00.000Z',
|
||||
updated: '2026-07-25T00:00:00.000Z',
|
||||
attempt: {
|
||||
id: ATTEMPT_ID,
|
||||
agent: 'codex',
|
||||
provider: 'codex-cli',
|
||||
status: 'running',
|
||||
runLaunchManifest: { digest: MANIFEST_DIGEST } as RunLaunchManifest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(
|
||||
fromEvidence: PhaseCapabilityEvidence,
|
||||
targetEvidence: PhaseCapabilityEvidence,
|
||||
operationId: string
|
||||
) {
|
||||
return {
|
||||
attemptId: ATTEMPT_ID,
|
||||
operationId,
|
||||
expectedSequence: 0,
|
||||
expectedPhaseEvidenceDigest: fromEvidence.digest,
|
||||
expectedManifestDigest: MANIFEST_DIGEST,
|
||||
reason: `Transition for ${operationId}.`,
|
||||
fromEvidence,
|
||||
targetEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(phase: 'explore' | 'plan' | 'implement' | 'verify' | 'publish') {
|
||||
return compilePhaseCapabilityAuthority({
|
||||
profile: getBuiltInPhaseCapabilityProfile(phase),
|
||||
sources: {
|
||||
parent: source('parent', 'parent'),
|
||||
agentProfile: source('agent-profile', 'agent-profile'),
|
||||
sandbox: source('sandbox', 'sandbox'),
|
||||
toolCatalog: source('tool-catalog', 'tool-catalog'),
|
||||
launchPolicy: source('launch-policy', 'launch-policy'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function source<K extends PhaseAuthoritySource['kind']>(
|
||||
id: string,
|
||||
kind: K
|
||||
): PhaseAuthoritySource & { kind: K } {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
authority: recordForDimensions<string[]>(() => ['*']),
|
||||
enforcement: recordForDimensions(() => 'enforced' as const),
|
||||
};
|
||||
}
|
||||
|
||||
function recordForDimensions<T>(
|
||||
value: (dimension: PhaseAuthorityDimension) => T
|
||||
): Record<PhaseAuthorityDimension, T> {
|
||||
return Object.fromEntries(
|
||||
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
|
||||
) as Record<PhaseAuthorityDimension, T>;
|
||||
}
|
||||
|
||||
function operator(administrator = false): PhaseTransitionActorContext {
|
||||
return {
|
||||
actor: {
|
||||
id: 'operator-brad',
|
||||
type: 'user',
|
||||
authMethod: 'session',
|
||||
authenticatedAt: '2026-07-25T01:00:00.000Z',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
},
|
||||
administrator,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeApprovals {
|
||||
private requestValue?: RunApprovalRequest;
|
||||
|
||||
async request(input: CreateRunApprovalRequestInput): Promise<RunApprovalRequest> {
|
||||
if (this.requestValue) return this.requestValue;
|
||||
this.requestValue = {
|
||||
schemaVersion: 'run-approval/v1',
|
||||
id: 'runapproval_000000000001',
|
||||
workspaceId: input.workspaceId ?? WORKSPACE_ID,
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
provider: input.provider,
|
||||
agentId: input.agentId,
|
||||
requestKind: input.requestKind,
|
||||
actionClass: input.actionClass,
|
||||
action: input.action,
|
||||
actionHash: 'a'.repeat(64),
|
||||
details: input.details,
|
||||
resourceScope: input.resourceScope ?? [],
|
||||
riskClass: input.riskClass,
|
||||
policyReason: input.policyReason,
|
||||
evidenceRevision: input.evidenceRevision,
|
||||
providerRequestId: input.providerRequestId,
|
||||
mobileSafe: input.mobileSafe ?? false,
|
||||
status: 'pending',
|
||||
revision: 1,
|
||||
createdAt: '2026-07-25T01:00:00.000Z',
|
||||
updatedAt: '2026-07-25T01:00:00.000Z',
|
||||
expiresAt: '2026-07-25T01:05:00.000Z',
|
||||
};
|
||||
return this.requestValue;
|
||||
}
|
||||
|
||||
resolve(status: Exclude<RunApprovalStatus, 'pending' | 'cancelled'>): void {
|
||||
if (!this.requestValue) throw new Error('No approval exists.');
|
||||
this.requestValue = {
|
||||
...this.requestValue,
|
||||
status,
|
||||
revision: 2,
|
||||
updatedAt: '2026-07-25T01:01:00.000Z',
|
||||
resolution: {
|
||||
decision: status,
|
||||
actor: {
|
||||
id: 'approver',
|
||||
type: 'user',
|
||||
authMethod: 'session',
|
||||
authenticatedAt: '2026-07-25T01:01:00.000Z',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
},
|
||||
decidedAt: '2026-07-25T01:01:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FakeJournal {
|
||||
readonly inputs: RunEventAppendInput[] = [];
|
||||
|
||||
async append(input: RunEventAppendInput): Promise<RunEventAppendResult> {
|
||||
this.inputs.push(input);
|
||||
return {
|
||||
appended: true,
|
||||
event: {
|
||||
eventId: `runevt_${this.inputs.length}`,
|
||||
} as RunEventAppendResult['event'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function recalculate(evidenceValue: PhaseCapabilityEvidence): string {
|
||||
return calculatePhaseCapabilityEvidenceDigest(evidenceValue);
|
||||
}
|
||||
158
server/src/__tests__/routes/phase-transitions.test.ts
Normal file
158
server/src/__tests__/routes/phase-transitions.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
PHASE_AUTHORITY_DIMENSIONS,
|
||||
type PhaseAuthorityDimension,
|
||||
type PhaseAuthoritySource,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import {
|
||||
compilePhaseCapabilityAuthority,
|
||||
getBuiltInPhaseCapabilityProfile,
|
||||
} from '../../services/phase-capability-service.js';
|
||||
|
||||
const { mockGetCurrent, mockList, mockTransition } = vi.hoisted(() => ({
|
||||
mockGetCurrent: vi.fn(),
|
||||
mockList: vi.fn(),
|
||||
mockTransition: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/phase-transition-service.js', () => ({
|
||||
getPhaseTransitionService: () => ({
|
||||
getCurrent: mockGetCurrent,
|
||||
list: mockList,
|
||||
transition: mockTransition,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/clawdbot-agent-service.js', () => ({
|
||||
AgentReadinessError: class AgentReadinessError extends Error {},
|
||||
clawdbotAgentService: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/task-service.js', () => ({
|
||||
getTaskService: () => ({ getTask: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/telemetry-service.js', () => ({
|
||||
getTelemetryService: () => ({ emit: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/workspace-execution-trust-service.js', () => ({
|
||||
getWorkspaceExecutionTrustService: () => ({}),
|
||||
}));
|
||||
|
||||
import { agentRoutes } from '../../routes/agents.js';
|
||||
|
||||
describe('phase transition routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetCurrent.mockResolvedValue(null);
|
||||
mockList.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('reads current state and bounded history for one exact run', async () => {
|
||||
const response = await request(app())
|
||||
.get('/api/agents/task-1/phase')
|
||||
.query({ attemptId: 'attempt-1', limit: 25 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ current: null, history: [] });
|
||||
expect(mockGetCurrent).toHaveBeenCalledWith('local', 'task-1', 'attempt-1');
|
||||
expect(mockList).toHaveBeenCalledWith('local', 'task-1', 'attempt-1', 25);
|
||||
});
|
||||
|
||||
it('forwards a validated compare-and-set transition and actor authority', async () => {
|
||||
const fromEvidence = evidence('plan');
|
||||
const targetEvidence = evidence('implement');
|
||||
mockTransition.mockResolvedValue({
|
||||
status: 'approval-required',
|
||||
current: null,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
approval: { id: 'runapproval_000000000001' },
|
||||
});
|
||||
const body = {
|
||||
attemptId: 'attempt-1',
|
||||
operationId: 'transition-1',
|
||||
expectedSequence: 0,
|
||||
expectedPhaseEvidenceDigest: fromEvidence.digest,
|
||||
expectedManifestDigest: `sha256:${'1'.repeat(64)}`,
|
||||
reason: 'Move from planning into implementation.',
|
||||
fromEvidence,
|
||||
targetEvidence,
|
||||
};
|
||||
|
||||
const response = await request(app()).post('/api/agents/task-1/phase/transitions').send(body);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(mockTransition).toHaveBeenCalledWith(
|
||||
'local',
|
||||
'task-1',
|
||||
body,
|
||||
expect.objectContaining({
|
||||
administrator: true,
|
||||
actor: expect.objectContaining({
|
||||
id: 'owner',
|
||||
workspaceId: 'local',
|
||||
type: 'user',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function app() {
|
||||
const application = express();
|
||||
application.use(express.json());
|
||||
application.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
role: 'admin',
|
||||
isLocalhost: true,
|
||||
userId: 'owner',
|
||||
workspaceId: 'local',
|
||||
actorType: 'user',
|
||||
authMethod: 'session',
|
||||
authenticatedAt: '2026-07-25T01:00:00.000Z',
|
||||
permissions: ['*'],
|
||||
};
|
||||
next();
|
||||
});
|
||||
application.use('/api/agents', agentRoutes);
|
||||
application.use(errorHandler);
|
||||
return application;
|
||||
}
|
||||
|
||||
function evidence(phase: 'plan' | 'implement') {
|
||||
return compilePhaseCapabilityAuthority({
|
||||
profile: getBuiltInPhaseCapabilityProfile(phase),
|
||||
sources: {
|
||||
parent: source('parent', 'parent'),
|
||||
agentProfile: source('agent-profile', 'agent-profile'),
|
||||
sandbox: source('sandbox', 'sandbox'),
|
||||
toolCatalog: source('tool-catalog', 'tool-catalog'),
|
||||
launchPolicy: source('launch-policy', 'launch-policy'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function source<K extends PhaseAuthoritySource['kind']>(
|
||||
id: string,
|
||||
kind: K
|
||||
): PhaseAuthoritySource & { kind: K } {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
authority: dimensions(() => ['*']),
|
||||
enforcement: dimensions(() => 'enforced' as const),
|
||||
};
|
||||
}
|
||||
|
||||
function dimensions<T>(
|
||||
value: (dimension: PhaseAuthorityDimension) => T
|
||||
): Record<PhaseAuthorityDimension, T> {
|
||||
return Object.fromEntries(
|
||||
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
|
||||
) as Record<PhaseAuthorityDimension, T>;
|
||||
}
|
||||
|
|
@ -34,6 +34,17 @@ describe('shared API permission metadata', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps phase reads agent-scoped and transition requests task-write scoped', () => {
|
||||
expect(
|
||||
getApiPermissionRequirement('/api/agents/task_1/phase', { method: 'GET' }).permissions
|
||||
).toEqual(['agent:read']);
|
||||
expect(
|
||||
getApiPermissionRequirement('/api/agents/task_1/phase/transitions', {
|
||||
method: 'POST',
|
||||
}).permissions
|
||||
).toEqual(['task:write']);
|
||||
});
|
||||
|
||||
it('separates conversation steering from lifecycle mutation authority', () => {
|
||||
expect(
|
||||
getApiPermissionRequirement('/api/agents/task_1/conversation/steer', {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { asyncHandler } from '../middleware/async-handler.js';
|
|||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { requireLocalAgentCapability } from '../middleware/local-agent-capability.js';
|
||||
import { AgentBudgetPolicySchema } from '../schemas/agent-budget-schemas.js';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { hasPermission, type AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { ProviderRuntimeCapabilityIdSchema } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
import { TaskCommitPolicySchema } from '../schemas/task-envelope-schemas.js';
|
||||
import { RunEventQuerySchema } from '../schemas/run-event-schemas.js';
|
||||
|
|
@ -33,6 +33,11 @@ import {
|
|||
workspaceExecutionTrustRevokeInputSchema,
|
||||
} from '../schemas/workspace-execution-trust-schemas.js';
|
||||
import { getWorkspaceExecutionTrustService } from '../services/workspace-execution-trust-service.js';
|
||||
import { phaseTransitionRequestInputSchema } from '../schemas/phase-capability-schemas.js';
|
||||
import {
|
||||
getPhaseTransitionService,
|
||||
type PhaseTransitionActorContext,
|
||||
} from '../services/phase-transition-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const workspaceExecutionTrust = getWorkspaceExecutionTrustService();
|
||||
|
|
@ -155,6 +160,13 @@ const runControlSchema = z.object({
|
|||
attemptId: z.string().trim().min(1).max(120),
|
||||
});
|
||||
|
||||
const phaseReadQuerySchema = z
|
||||
.object({
|
||||
attemptId: z.string().trim().min(1).max(240),
|
||||
limit: z.coerce.number().int().min(1).max(1_000).default(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const conversationTurnSchema = z
|
||||
.object({
|
||||
sourceAttemptId: z.string().trim().min(1).max(120),
|
||||
|
|
@ -402,6 +414,42 @@ router.post(
|
|||
})
|
||||
);
|
||||
|
||||
// GET /api/agents/:taskId/phase - Read durable phase authority and transition history.
|
||||
router.get(
|
||||
'/:taskId/phase',
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const query = phaseReadQuerySchema.parse(req.query);
|
||||
const workspaceId = req.auth?.workspaceId || 'local';
|
||||
const service = getPhaseTransitionService();
|
||||
const current = await service.getCurrent(
|
||||
workspaceId,
|
||||
req.params.taskId as string,
|
||||
query.attemptId
|
||||
);
|
||||
const history = await service.list(
|
||||
workspaceId,
|
||||
req.params.taskId as string,
|
||||
query.attemptId,
|
||||
query.limit
|
||||
);
|
||||
res.json({ current, history });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:taskId/phase/transitions',
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const input = phaseTransitionRequestInputSchema.parse(req.body);
|
||||
const result = await getPhaseTransitionService().transition(
|
||||
req.auth?.workspaceId || 'local',
|
||||
req.params.taskId as string,
|
||||
input,
|
||||
phaseActorContext(req)
|
||||
);
|
||||
res.status(result.status === 'approval-required' ? 202 : 201).json(result);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/agents/:taskId/recovery - Read the latest durable recovery decision.
|
||||
router.get(
|
||||
'/:taskId/recovery',
|
||||
|
|
@ -798,3 +846,20 @@ function requestActor(req: AuthenticatedRequest): string {
|
|||
auth?.userId || auth?.tokenName || auth?.keyName || auth?.clientId || auth?.role || 'operator'
|
||||
);
|
||||
}
|
||||
|
||||
function phaseActorContext(req: AuthenticatedRequest): PhaseTransitionActorContext {
|
||||
const auth = req.auth;
|
||||
const id = requestActor(req);
|
||||
return {
|
||||
actor: {
|
||||
id,
|
||||
label: auth?.tokenName || auth?.keyName || auth?.clientId || auth?.userId || id,
|
||||
type: auth?.actorType,
|
||||
authMethod: auth?.authMethod,
|
||||
authenticatedAt: auth?.authenticatedAt,
|
||||
clientMode: auth?.clientMode,
|
||||
workspaceId: auth?.workspaceId || 'local',
|
||||
},
|
||||
administrator: hasPermission(auth, 'admin:manage'),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import {
|
|||
PHASE_CAPABILITY_PROFILE_SCHEMA_VERSION,
|
||||
PHASE_NAMES,
|
||||
PHASE_TRANSITION_INTENT_SCHEMA_VERSION,
|
||||
PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
|
||||
type PhaseTransitionRecord,
|
||||
type PhaseTransitionRequestInput,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { RunApprovalActorSchema, RunApprovalRequestSchema } from './run-approval-schemas.js';
|
||||
|
||||
const identifierSchema = z
|
||||
.string()
|
||||
|
|
@ -253,6 +257,122 @@ export const phaseCapabilityEvidenceSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const phaseAuthorityDeltaEntrySchema = z
|
||||
.object({
|
||||
dimension: phaseAuthorityDimensionSchema,
|
||||
addedScopes: authorityScopesSchema,
|
||||
removedScopes: authorityScopesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const phaseAuthorityDeltaSchema = z
|
||||
.object({
|
||||
classification: z.enum(['same', 'narrowing', 'expanding', 'mixed']),
|
||||
entries: z.array(phaseAuthorityDeltaEntrySchema).max(PHASE_AUTHORITY_DIMENSIONS.length),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const phaseTransitionRecordSchema: z.ZodType<PhaseTransitionRecord> = z
|
||||
.object({
|
||||
schemaVersion: z.literal(PHASE_TRANSITION_RECORD_SCHEMA_VERSION),
|
||||
id: z.string().regex(/^phasetransition_[A-Za-z0-9_-]{12,32}$/),
|
||||
workspaceId: identifierSchema,
|
||||
taskId: identifierSchema,
|
||||
attemptId: identifierSchema,
|
||||
sequence: z.number().int().positive(),
|
||||
operationId: identifierSchema,
|
||||
priorEvidence: phaseCapabilityEvidenceSchema,
|
||||
effectiveEvidence: phaseCapabilityEvidenceSchema,
|
||||
authorityDelta: phaseAuthorityDeltaSchema,
|
||||
actor: RunApprovalActorSchema,
|
||||
reason: safeTextSchema,
|
||||
policyDecision: z.enum([
|
||||
'allow',
|
||||
'approved-expansion',
|
||||
'emergency-override',
|
||||
'override-expired',
|
||||
]),
|
||||
approvalId: z
|
||||
.string()
|
||||
.regex(/^runapproval_[A-Za-z0-9_-]{12,32}$/)
|
||||
.optional(),
|
||||
emergencyOverride: z
|
||||
.object({
|
||||
permission: z.literal('admin:manage'),
|
||||
justification: safeTextSchema,
|
||||
expiresAt: z.string().datetime({ offset: true }),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
manifestDigest: digestSchema,
|
||||
eventReference: identifierSchema,
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((record, context) => {
|
||||
if (record.policyDecision === 'approved-expansion' && !record.approvalId) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['approvalId'],
|
||||
message: 'Approved expansions require the exact approval reference',
|
||||
});
|
||||
}
|
||||
if (record.policyDecision === 'emergency-override' && !record.emergencyOverride) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['emergencyOverride'],
|
||||
message: 'Emergency override transitions require expiring override evidence',
|
||||
});
|
||||
}
|
||||
if (record.policyDecision !== 'emergency-override' && record.emergencyOverride) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['emergencyOverride'],
|
||||
message: 'Only emergency override transitions may carry override evidence',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const phaseTransitionRequestInputSchema: z.ZodType<PhaseTransitionRequestInput> = z
|
||||
.object({
|
||||
attemptId: identifierSchema,
|
||||
operationId: identifierSchema,
|
||||
expectedSequence: z.number().int().nonnegative(),
|
||||
expectedPhaseEvidenceDigest: digestSchema,
|
||||
expectedManifestDigest: digestSchema,
|
||||
reason: safeTextSchema,
|
||||
fromEvidence: phaseCapabilityEvidenceSchema.optional(),
|
||||
targetEvidence: phaseCapabilityEvidenceSchema,
|
||||
approvalId: z
|
||||
.string()
|
||||
.regex(/^runapproval_[A-Za-z0-9_-]{12,32}$/)
|
||||
.optional(),
|
||||
approvalTtlMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1_000)
|
||||
.max(24 * 60 * 60 * 1_000)
|
||||
.optional(),
|
||||
emergencyOverride: z
|
||||
.object({
|
||||
justification: safeTextSchema,
|
||||
expiresAt: z.string().datetime({ offset: true }),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const phaseTransitionResultSchema = z
|
||||
.object({
|
||||
status: z.enum(['applied', 'approval-required']),
|
||||
current: phaseTransitionRecordSchema.nullable(),
|
||||
record: phaseTransitionRecordSchema.optional(),
|
||||
approval: RunApprovalRequestSchema.optional(),
|
||||
targetEvidenceDigest: digestSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
|
|
|
|||
|
|
@ -320,6 +320,15 @@ export function compilePhaseCapabilityAuthority(
|
|||
}) as PhaseCapabilityEvidence;
|
||||
}
|
||||
|
||||
export function calculatePhaseCapabilityEvidenceDigest(evidence: PhaseCapabilityEvidence): string {
|
||||
const { digest: _digest, ...payload } = evidence;
|
||||
return digestRunLaunchValue(payload);
|
||||
}
|
||||
|
||||
export function verifyPhaseCapabilityEvidenceDigest(evidence: PhaseCapabilityEvidence): boolean {
|
||||
return evidence.digest === calculatePhaseCapabilityEvidenceDigest(evidence);
|
||||
}
|
||||
|
||||
function profile(
|
||||
input: Pick<PhaseCapabilityProfile, 'id' | 'phase' | 'name' | 'description' | 'authority'> & {
|
||||
planArtifactPolicy?: PhaseCapabilityProfile['planArtifactPolicy'];
|
||||
|
|
|
|||
506
server/src/services/phase-transition-service.ts
Normal file
506
server/src/services/phase-transition-service.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
EXECUTABLE_AGENT_PROVIDERS,
|
||||
PHASE_AUTHORITY_DIMENSIONS,
|
||||
PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
|
||||
type ExecutableAgentProvider,
|
||||
type PhaseAuthority,
|
||||
type PhaseAuthorityDelta,
|
||||
type PhaseAuthorityDeltaEntry,
|
||||
type PhaseCapabilityEvidence,
|
||||
type PhaseTransitionRecord,
|
||||
type PhaseTransitionRequestInput,
|
||||
type PhaseTransitionResult,
|
||||
type RunApprovalActor,
|
||||
type RunApprovalRequest,
|
||||
type Task,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '../middleware/error-handler.js';
|
||||
import {
|
||||
phaseCapabilityEvidenceSchema,
|
||||
phaseTransitionRecordSchema,
|
||||
phaseTransitionRequestInputSchema,
|
||||
} from '../schemas/phase-capability-schemas.js';
|
||||
import type { PhaseTransitionRepository, TaskRepository } from '../storage/interfaces.js';
|
||||
import { FilePhaseTransitionRepository } from '../storage/phase-transition-repository.js';
|
||||
import { getStorage, getStorageTypeFromEnv } from '../storage/index.js';
|
||||
import { verifyPhaseCapabilityEvidenceDigest } from './phase-capability-service.js';
|
||||
import {
|
||||
getRunApprovalBrokerService,
|
||||
type RunApprovalBrokerService,
|
||||
} from './run-approval-broker-service.js';
|
||||
import { RunEventJournalService } from './run-event-journal-service.js';
|
||||
|
||||
const MAX_OVERRIDE_TTL_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
export interface PhaseTransitionActorContext {
|
||||
actor: RunApprovalActor;
|
||||
administrator: boolean;
|
||||
}
|
||||
|
||||
export interface PhaseTransitionServiceOptions {
|
||||
repository?: PhaseTransitionRepository;
|
||||
tasks?: Pick<TaskRepository, 'findById'>;
|
||||
approvals?: Pick<RunApprovalBrokerService, 'request'>;
|
||||
journal?: Pick<RunEventJournalService, 'append'>;
|
||||
now?: () => Date;
|
||||
id?: () => string;
|
||||
}
|
||||
|
||||
let fileRepository: FilePhaseTransitionRepository | undefined;
|
||||
let singleton: PhaseTransitionService | undefined;
|
||||
|
||||
function defaultRepository(): PhaseTransitionRepository {
|
||||
if (getStorageTypeFromEnv() === 'sqlite') return getStorage().phaseTransitions;
|
||||
fileRepository ??= new FilePhaseTransitionRepository();
|
||||
return fileRepository;
|
||||
}
|
||||
|
||||
export class PhaseTransitionService {
|
||||
private readonly repository: PhaseTransitionRepository;
|
||||
private readonly tasks: Pick<TaskRepository, 'findById'>;
|
||||
private readonly approvals: Pick<RunApprovalBrokerService, 'request'>;
|
||||
private readonly journal: Pick<RunEventJournalService, 'append'>;
|
||||
private readonly now: () => Date;
|
||||
private readonly id: () => string;
|
||||
|
||||
constructor(options: PhaseTransitionServiceOptions = {}) {
|
||||
this.repository = options.repository ?? defaultRepository();
|
||||
this.tasks = options.tasks ?? getStorage().tasks;
|
||||
this.approvals = options.approvals ?? getRunApprovalBrokerService();
|
||||
this.journal = options.journal ?? new RunEventJournalService();
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.id = options.id ?? (() => `phasetransition_${nanoid(18)}`);
|
||||
}
|
||||
|
||||
async getCurrent(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
const current = await this.repository.getCurrent(workspaceId, taskId, attemptId);
|
||||
if (!current?.emergencyOverride) return current;
|
||||
if (Date.parse(current.emergencyOverride.expiresAt) > this.now().getTime()) return current;
|
||||
return this.expireOverride(current);
|
||||
}
|
||||
|
||||
async list(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
limit = 100
|
||||
): Promise<PhaseTransitionRecord[]> {
|
||||
await this.getCurrent(workspaceId, taskId, attemptId);
|
||||
return this.repository.list({ workspaceId, taskId, attemptId, limit });
|
||||
}
|
||||
|
||||
async transition(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
request: PhaseTransitionRequestInput,
|
||||
actorContext: PhaseTransitionActorContext
|
||||
): Promise<PhaseTransitionResult> {
|
||||
const input = phaseTransitionRequestInputSchema.parse(request);
|
||||
if (actorContext.actor.workspaceId !== workspaceId) {
|
||||
throw new ForbiddenError('Phase transition actor does not belong to this workspace.');
|
||||
}
|
||||
const targetEvidence = verifiedEvidence(input.targetEvidence, 'Target phase evidence');
|
||||
if (targetEvidence.status === 'blocked') {
|
||||
throw new ForbiddenError('Blocked phase evidence cannot become active.', {
|
||||
evidenceDigest: targetEvidence.digest,
|
||||
blockers: targetEvidence.blockers.map((blocker) => blocker.code),
|
||||
});
|
||||
}
|
||||
if (targetEvidence.identity.mode !== 'profile') {
|
||||
throw new ValidationError('Operator transitions must target a defined phase profile.');
|
||||
}
|
||||
|
||||
const existing = await this.repository.getByOperationId(
|
||||
workspaceId,
|
||||
taskId,
|
||||
input.attemptId,
|
||||
input.operationId
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.priorEvidence.digest !== input.expectedPhaseEvidenceDigest ||
|
||||
existing.effectiveEvidence.digest !== targetEvidence.digest ||
|
||||
existing.manifestDigest !== input.expectedManifestDigest ||
|
||||
existing.sequence !== input.expectedSequence + 1
|
||||
) {
|
||||
throw transitionConflict('operation-reused', existing);
|
||||
}
|
||||
await this.projectEvent(existing);
|
||||
return {
|
||||
status: 'applied',
|
||||
current: existing,
|
||||
record: existing,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
};
|
||||
}
|
||||
|
||||
const task = await this.activeTask(taskId, input);
|
||||
const current = await this.getCurrent(workspaceId, taskId, input.attemptId);
|
||||
const priorEvidence = current
|
||||
? current.effectiveEvidence
|
||||
: input.fromEvidence
|
||||
? verifiedEvidence(input.fromEvidence, 'Initial phase evidence')
|
||||
: undefined;
|
||||
if (!priorEvidence) {
|
||||
throw new ValidationError(
|
||||
'The first transition for a run requires the exact initial phase evidence.'
|
||||
);
|
||||
}
|
||||
assertExpectedState(current, priorEvidence, input);
|
||||
|
||||
const authorityDelta = calculatePhaseAuthorityDelta(
|
||||
priorEvidence.effectiveAuthority,
|
||||
targetEvidence.effectiveAuthority
|
||||
);
|
||||
const expansion = hasExpansion(authorityDelta);
|
||||
let policyDecision: PhaseTransitionRecord['policyDecision'] = 'allow';
|
||||
let approval: RunApprovalRequest | undefined;
|
||||
let emergencyOverride: PhaseTransitionRecord['emergencyOverride'];
|
||||
|
||||
if (input.emergencyOverride) {
|
||||
if (!expansion) {
|
||||
throw new ValidationError('Emergency override is reserved for authority expansion.');
|
||||
}
|
||||
if (!actorContext.administrator) {
|
||||
throw new ForbiddenError('Emergency phase override requires admin:manage permission.');
|
||||
}
|
||||
const expiresAt = Date.parse(input.emergencyOverride.expiresAt);
|
||||
const now = this.now().getTime();
|
||||
if (
|
||||
!Number.isFinite(expiresAt) ||
|
||||
expiresAt <= now ||
|
||||
expiresAt - now > MAX_OVERRIDE_TTL_MS
|
||||
) {
|
||||
throw new ValidationError('Emergency phase override must expire within 24 hours.');
|
||||
}
|
||||
policyDecision = 'emergency-override';
|
||||
emergencyOverride = {
|
||||
permission: 'admin:manage',
|
||||
justification: input.emergencyOverride.justification,
|
||||
expiresAt: input.emergencyOverride.expiresAt,
|
||||
};
|
||||
} else if (expansion) {
|
||||
approval = await this.expansionApproval(
|
||||
task,
|
||||
workspaceId,
|
||||
input,
|
||||
priorEvidence,
|
||||
authorityDelta
|
||||
);
|
||||
if (input.approvalId && input.approvalId !== approval.id) {
|
||||
throw new ConflictError(
|
||||
'Phase transition approval does not match the requested expansion.',
|
||||
{
|
||||
expectedApprovalId: approval.id,
|
||||
receivedApprovalId: input.approvalId,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (approval.status === 'pending') {
|
||||
return {
|
||||
status: 'approval-required',
|
||||
current,
|
||||
approval,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
};
|
||||
}
|
||||
if (approval.status !== 'approved') {
|
||||
throw new ConflictError('Phase transition approval is not approved.', {
|
||||
approvalId: approval.id,
|
||||
status: approval.status,
|
||||
});
|
||||
}
|
||||
policyDecision = 'approved-expansion';
|
||||
}
|
||||
|
||||
const transitionId = this.id();
|
||||
const record = phaseTransitionRecordSchema.parse({
|
||||
schemaVersion: PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
|
||||
id: transitionId,
|
||||
workspaceId,
|
||||
taskId,
|
||||
attemptId: input.attemptId,
|
||||
sequence: input.expectedSequence + 1,
|
||||
operationId: input.operationId,
|
||||
priorEvidence,
|
||||
effectiveEvidence: targetEvidence,
|
||||
authorityDelta,
|
||||
actor: actorContext.actor,
|
||||
reason: input.reason,
|
||||
policyDecision,
|
||||
...(approval ? { approvalId: approval.id } : {}),
|
||||
...(emergencyOverride ? { emergencyOverride } : {}),
|
||||
manifestDigest: input.expectedManifestDigest,
|
||||
eventReference: phaseEventReference(transitionId),
|
||||
createdAt: this.now().toISOString(),
|
||||
});
|
||||
const result = await this.repository.append({
|
||||
record,
|
||||
expectedSequence: input.expectedSequence,
|
||||
expectedPhaseEvidenceDigest: input.expectedPhaseEvidenceDigest,
|
||||
expectedManifestDigest: input.expectedManifestDigest,
|
||||
});
|
||||
if (!result.record || result.reason) {
|
||||
throw transitionConflict(result.reason, result.record);
|
||||
}
|
||||
await this.projectEvent(result.record);
|
||||
return {
|
||||
status: 'applied',
|
||||
current: result.record,
|
||||
record: result.record,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
};
|
||||
}
|
||||
|
||||
private async activeTask(taskId: string, input: PhaseTransitionRequestInput): Promise<Task> {
|
||||
const task = await this.tasks.findById(taskId);
|
||||
if (!task) throw new NotFoundError('Task not found.');
|
||||
if (task.attempt?.id !== input.attemptId || task.attempt.status !== 'running') {
|
||||
throw new ConflictError('Phase transition does not match the active running attempt.', {
|
||||
expectedAttemptId: input.attemptId,
|
||||
activeAttemptId: task.attempt?.id,
|
||||
activeStatus: task.attempt?.status,
|
||||
});
|
||||
}
|
||||
if (task.attempt.runLaunchManifest?.digest !== input.expectedManifestDigest) {
|
||||
throw new ConflictError('Phase transition launch-manifest evidence is stale.', {
|
||||
expectedManifestDigest: input.expectedManifestDigest,
|
||||
activeManifestDigest: task.attempt.runLaunchManifest?.digest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!task.attempt.provider ||
|
||||
!EXECUTABLE_AGENT_PROVIDERS.includes(task.attempt.provider as ExecutableAgentProvider)
|
||||
) {
|
||||
throw new ConflictError('Active attempt does not identify an executable provider.');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private async expansionApproval(
|
||||
task: Task,
|
||||
workspaceId: string,
|
||||
input: PhaseTransitionRequestInput,
|
||||
priorEvidence: PhaseCapabilityEvidence,
|
||||
authorityDelta: PhaseAuthorityDelta
|
||||
): Promise<RunApprovalRequest> {
|
||||
const added = authorityDelta.entries.flatMap((entry) =>
|
||||
entry.addedScopes.map((scope) => `${entry.dimension}:${scope}`)
|
||||
);
|
||||
const critical = authorityDelta.entries.some(
|
||||
(entry) =>
|
||||
entry.addedScopes.length > 0 &&
|
||||
(entry.dimension === 'credential.access' || entry.dimension === 'external.action')
|
||||
);
|
||||
return this.approvals.request({
|
||||
workspaceId,
|
||||
taskId: task.id,
|
||||
attemptId: input.attemptId,
|
||||
provider: task.attempt?.provider as ExecutableAgentProvider,
|
||||
agentId: task.attempt?.agent ?? 'agent',
|
||||
requestKind: 'approval',
|
||||
actionClass: 'workflow',
|
||||
action: 'Expand active run phase authority',
|
||||
exactAction: {
|
||||
operationId: input.operationId,
|
||||
fromEvidenceDigest: priorEvidence.digest,
|
||||
toEvidenceDigest: input.targetEvidence.digest,
|
||||
manifestDigest: input.expectedManifestDigest,
|
||||
authorityDelta,
|
||||
},
|
||||
details: `Transition from ${identityLabel(priorEvidence)} to ${identityLabel(input.targetEvidence)}.`,
|
||||
resourceScope: added,
|
||||
riskClass: critical ? 'critical' : 'high',
|
||||
policyReason: 'Authority-expanding phase transitions require exact-action approval.',
|
||||
evidenceRevision: input.expectedPhaseEvidenceDigest,
|
||||
providerRequestId: `phase:${input.operationId}`,
|
||||
mobileSafe: false,
|
||||
ttlMs: input.approvalTtlMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async expireOverride(current: PhaseTransitionRecord): Promise<PhaseTransitionRecord> {
|
||||
const operationId = `override-expiry:${current.id}`;
|
||||
const transitionId = this.id();
|
||||
const record = phaseTransitionRecordSchema.parse({
|
||||
schemaVersion: PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
|
||||
id: transitionId,
|
||||
workspaceId: current.workspaceId,
|
||||
taskId: current.taskId,
|
||||
attemptId: current.attemptId,
|
||||
sequence: current.sequence + 1,
|
||||
operationId,
|
||||
priorEvidence: current.effectiveEvidence,
|
||||
effectiveEvidence: current.priorEvidence,
|
||||
authorityDelta: calculatePhaseAuthorityDelta(
|
||||
current.effectiveEvidence.effectiveAuthority,
|
||||
current.priorEvidence.effectiveAuthority
|
||||
),
|
||||
actor: {
|
||||
id: 'phase-override-expiry',
|
||||
label: 'Phase override expiry',
|
||||
type: 'service',
|
||||
authMethod: 'system',
|
||||
workspaceId: current.workspaceId,
|
||||
},
|
||||
reason: `Emergency phase override ${current.id} expired.`,
|
||||
policyDecision: 'override-expired',
|
||||
manifestDigest: current.manifestDigest,
|
||||
eventReference: phaseEventReference(transitionId),
|
||||
createdAt: this.now().toISOString(),
|
||||
});
|
||||
const result = await this.repository.append({
|
||||
record,
|
||||
expectedSequence: current.sequence,
|
||||
expectedPhaseEvidenceDigest: current.effectiveEvidence.digest,
|
||||
expectedManifestDigest: current.manifestDigest,
|
||||
});
|
||||
if (!result.record || result.reason) {
|
||||
const latest = await this.repository.getCurrent(
|
||||
current.workspaceId,
|
||||
current.taskId,
|
||||
current.attemptId
|
||||
);
|
||||
if (latest && latest.sequence > current.sequence) return latest;
|
||||
throw transitionConflict(result.reason, result.record);
|
||||
}
|
||||
await this.projectEvent(result.record);
|
||||
return result.record;
|
||||
}
|
||||
|
||||
private async projectEvent(record: PhaseTransitionRecord): Promise<void> {
|
||||
await this.journal.append({
|
||||
taskId: record.taskId,
|
||||
attemptId: record.attemptId,
|
||||
kind:
|
||||
record.policyDecision === 'override-expired'
|
||||
? 'phase.override-expired'
|
||||
: 'phase.transitioned',
|
||||
source: {
|
||||
provider: record.policyDecision === 'override-expired' ? 'system' : 'operator',
|
||||
adapter: 'phase-transition-service',
|
||||
},
|
||||
payload: {
|
||||
transitionId: record.id,
|
||||
sequence: record.sequence,
|
||||
operationId: record.operationId,
|
||||
fromEvidenceDigest: record.priorEvidence.digest,
|
||||
toEvidenceDigest: record.effectiveEvidence.digest,
|
||||
policyDecision: record.policyDecision,
|
||||
approvalId: record.approvalId,
|
||||
overrideExpiresAt: record.emergencyOverride?.expiresAt,
|
||||
manifestDigest: record.manifestDigest,
|
||||
},
|
||||
dedupeKey: record.eventReference,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function calculatePhaseAuthorityDelta(
|
||||
from: PhaseAuthority,
|
||||
to: PhaseAuthority
|
||||
): PhaseAuthorityDelta {
|
||||
const entries: PhaseAuthorityDeltaEntry[] = [];
|
||||
for (const dimension of PHASE_AUTHORITY_DIMENSIONS) {
|
||||
const delta = scopeDelta(from[dimension], to[dimension]);
|
||||
if (delta.addedScopes.length || delta.removedScopes.length) {
|
||||
entries.push({ dimension, ...delta });
|
||||
}
|
||||
}
|
||||
const expanded = entries.some((entry) => entry.addedScopes.length > 0);
|
||||
const narrowed = entries.some((entry) => entry.removedScopes.length > 0);
|
||||
return {
|
||||
classification:
|
||||
expanded && narrowed ? 'mixed' : expanded ? 'expanding' : narrowed ? 'narrowing' : 'same',
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function scopeDelta(
|
||||
from: string[],
|
||||
to: string[]
|
||||
): Pick<PhaseAuthorityDeltaEntry, 'addedScopes' | 'removedScopes'> {
|
||||
if (from.includes('*') && to.includes('*')) return { addedScopes: [], removedScopes: [] };
|
||||
if (from.includes('*')) return { addedScopes: [], removedScopes: ['*'] };
|
||||
if (to.includes('*')) return { addedScopes: ['*'], removedScopes: [] };
|
||||
const fromSet = new Set(from);
|
||||
const toSet = new Set(to);
|
||||
return {
|
||||
addedScopes: to.filter((scope) => !fromSet.has(scope)),
|
||||
removedScopes: from.filter((scope) => !toSet.has(scope)),
|
||||
};
|
||||
}
|
||||
|
||||
function hasExpansion(delta: PhaseAuthorityDelta): boolean {
|
||||
return delta.entries.some((entry) => entry.addedScopes.length > 0);
|
||||
}
|
||||
|
||||
function verifiedEvidence(input: PhaseCapabilityEvidence, label: string): PhaseCapabilityEvidence {
|
||||
const evidence = phaseCapabilityEvidenceSchema.parse(input);
|
||||
if (!verifyPhaseCapabilityEvidenceDigest(evidence)) {
|
||||
throw new ConflictError(`${label} digest does not match its content.`);
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
function assertExpectedState(
|
||||
current: PhaseTransitionRecord | null,
|
||||
priorEvidence: PhaseCapabilityEvidence,
|
||||
input: PhaseTransitionRequestInput
|
||||
): void {
|
||||
if ((current?.sequence ?? 0) !== input.expectedSequence) {
|
||||
throw new ConflictError('Phase transition sequence is stale.', {
|
||||
expectedSequence: input.expectedSequence,
|
||||
activeSequence: current?.sequence ?? 0,
|
||||
});
|
||||
}
|
||||
if (priorEvidence.digest !== input.expectedPhaseEvidenceDigest) {
|
||||
throw new ConflictError('Phase transition evidence is stale.', {
|
||||
expectedPhaseEvidenceDigest: input.expectedPhaseEvidenceDigest,
|
||||
activePhaseEvidenceDigest: priorEvidence.digest,
|
||||
});
|
||||
}
|
||||
if (current && current.manifestDigest !== input.expectedManifestDigest) {
|
||||
throw new ConflictError('Phase transition manifest reference is stale.', {
|
||||
expectedManifestDigest: input.expectedManifestDigest,
|
||||
activeManifestDigest: current.manifestDigest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function phaseEventReference(transitionId: string): string {
|
||||
return `phase:${transitionId}`;
|
||||
}
|
||||
|
||||
function identityLabel(evidence: PhaseCapabilityEvidence): string {
|
||||
return evidence.identity.mode === 'legacy'
|
||||
? 'legacy'
|
||||
: `${evidence.identity.profileId}@${evidence.identity.profileVersion}`;
|
||||
}
|
||||
|
||||
function transitionConflict(
|
||||
reason?: 'stale-sequence' | 'stale-phase-evidence' | 'stale-manifest' | 'operation-reused',
|
||||
record?: PhaseTransitionRecord
|
||||
): ConflictError {
|
||||
return new ConflictError('Phase transition compare-and-set failed.', {
|
||||
reason: reason ?? 'unknown',
|
||||
activeSequence: record?.sequence,
|
||||
activeEvidenceDigest: record?.effectiveEvidence.digest,
|
||||
activeManifestDigest: record?.manifestDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPhaseTransitionService(): PhaseTransitionService {
|
||||
singleton ??= new PhaseTransitionService();
|
||||
return singleton;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import type {
|
|||
TelemetryRepository,
|
||||
RunEventRepository,
|
||||
RunApprovalRepository,
|
||||
PhaseTransitionRepository,
|
||||
RunSupervisorRepository,
|
||||
ToolControlPlaneRepository,
|
||||
} from './interfaces.js';
|
||||
|
|
@ -68,6 +69,7 @@ import { ManagedListService } from '../services/managed-list-service.js';
|
|||
import { TelemetryService, type TelemetryServiceOptions } from '../services/telemetry-service.js';
|
||||
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 { FileToolControlPlaneRepository } from './tool-control-plane-repository.js';
|
||||
|
||||
|
|
@ -488,6 +490,7 @@ export interface FileStorageOptions {
|
|||
telemetryServiceOptions?: TelemetryServiceOptions;
|
||||
runEventsDir?: string;
|
||||
runApprovalsPath?: string;
|
||||
phaseTransitionsPath?: string;
|
||||
runSupervisorsPath?: string;
|
||||
toolControlPlanePath?: string;
|
||||
}
|
||||
|
|
@ -503,6 +506,7 @@ export class FileStorageProvider implements StorageProvider {
|
|||
readonly telemetry: FileTelemetryRepository;
|
||||
readonly runEvents: RunEventRepository;
|
||||
readonly runApprovals: RunApprovalRepository;
|
||||
readonly phaseTransitions: PhaseTransitionRepository;
|
||||
readonly runSupervisors: RunSupervisorRepository;
|
||||
readonly toolControlPlane: ToolControlPlaneRepository;
|
||||
|
||||
|
|
@ -556,6 +560,7 @@ export class FileStorageProvider implements StorageProvider {
|
|||
this.telemetry = new FileTelemetryRepository(this.telemetryService);
|
||||
this.runEvents = new FileRunEventRepository(options.runEventsDir);
|
||||
this.runApprovals = new FileRunApprovalRepository(options.runApprovalsPath);
|
||||
this.phaseTransitions = new FilePhaseTransitionRepository(options.phaseTransitionsPath);
|
||||
this.runSupervisors = new FileRunSupervisorRepository(options.runSupervisorsPath);
|
||||
this.toolControlPlane = new FileToolControlPlaneRepository(options.toolControlPlanePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export type {
|
|||
RunEventRepository,
|
||||
RunEventRepositoryAppendInput,
|
||||
RunApprovalRepository,
|
||||
PhaseTransitionRepository,
|
||||
ToolControlPlaneRepository,
|
||||
SetupContextRepository,
|
||||
WorkspaceFileRepository,
|
||||
|
|
@ -56,6 +57,11 @@ export {
|
|||
} from './file-storage.js';
|
||||
export { FileRunEventRepository, getRunEventsDir } from './run-event-repository.js';
|
||||
export { FileRunApprovalRepository, getRunApprovalsPath } from './run-approval-repository.js';
|
||||
export {
|
||||
FilePhaseTransitionRepository,
|
||||
InMemoryPhaseTransitionRepository,
|
||||
getPhaseTransitionsPath,
|
||||
} from './phase-transition-repository.js';
|
||||
export type { FileStorageOptions } from './file-storage.js';
|
||||
export {
|
||||
DEFAULT_SQLITE_FILENAME,
|
||||
|
|
@ -83,6 +89,7 @@ export { SqliteStatusHistoryRepository } from './sqlite/status-history-repositor
|
|||
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 {
|
||||
FileToolControlPlaneRepository,
|
||||
InMemoryToolControlPlaneRepository,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ import type {
|
|||
RunApprovalRequest,
|
||||
RunApprovalTransitionInput,
|
||||
RunApprovalTransitionResult,
|
||||
PhaseTransitionAppendInput,
|
||||
PhaseTransitionAppendResult,
|
||||
PhaseTransitionQuery,
|
||||
PhaseTransitionRecord,
|
||||
RunSupervisorCompareAndSetInput,
|
||||
RunSupervisorCompareAndSetResult,
|
||||
RunSupervisorListQuery,
|
||||
|
|
@ -372,6 +376,33 @@ export interface RunApprovalRepository {
|
|||
transition(input: RunApprovalTransitionInput): Promise<RunApprovalTransitionResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase Transition Repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PhaseTransitionRepository {
|
||||
/** Return the one materialized active phase for an exact run. */
|
||||
getCurrent(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): Promise<PhaseTransitionRecord | null>;
|
||||
|
||||
/** Resolve one run-scoped idempotency key without scanning bounded history. */
|
||||
getByOperationId(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
operationId: string
|
||||
): Promise<PhaseTransitionRecord | null>;
|
||||
|
||||
/** Return append-only transition history in ascending sequence order. */
|
||||
list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]>;
|
||||
|
||||
/** Append one transition behind exact sequence, phase, and manifest CAS guards. */
|
||||
append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Durable Run Supervisor Repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -420,6 +451,7 @@ export interface StorageProvider {
|
|||
readonly telemetry: TelemetryRepository;
|
||||
readonly runEvents: RunEventRepository;
|
||||
readonly runApprovals: RunApprovalRepository;
|
||||
readonly phaseTransitions: PhaseTransitionRepository;
|
||||
readonly runSupervisors: RunSupervisorRepository;
|
||||
readonly toolControlPlane: ToolControlPlaneRepository;
|
||||
readonly setupContext?: SetupContextRepository;
|
||||
|
|
|
|||
255
server/src/storage/phase-transition-repository.ts
Normal file
255
server/src/storage/phase-transition-repository.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { constants } from 'node:fs';
|
||||
import { lstat, mkdir, open } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
PhaseTransitionAppendInput,
|
||||
PhaseTransitionAppendResult,
|
||||
PhaseTransitionQuery,
|
||||
PhaseTransitionRecord,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { phaseTransitionRecordSchema } from '../schemas/phase-capability-schemas.js';
|
||||
import { withFileLock } from '../services/file-lock.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
import { ensureWithinBase } from '../utils/sanitize.js';
|
||||
import type { PhaseTransitionRepository } from './interfaces.js';
|
||||
|
||||
const MAX_TRANSITION_LOG_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_TRANSITION_RECORDS = 100_000;
|
||||
|
||||
export function getPhaseTransitionsPath(): string {
|
||||
return path.join(getRuntimeDir(), 'phase-transitions.jsonl');
|
||||
}
|
||||
|
||||
export class FilePhaseTransitionRepository implements PhaseTransitionRepository {
|
||||
constructor(private readonly filePath = getPhaseTransitionsPath()) {
|
||||
ensureWithinBase(path.dirname(filePath), filePath);
|
||||
}
|
||||
|
||||
async getCurrent(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
return currentFor(await this.readRecords(), workspaceId, taskId, attemptId);
|
||||
}
|
||||
|
||||
async getByOperationId(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
operationId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
const record = (await this.readRecords()).find(
|
||||
(candidate) =>
|
||||
sameRun(candidate, workspaceId, taskId, attemptId) && candidate.operationId === operationId
|
||||
);
|
||||
return record ? structuredClone(record) : null;
|
||||
}
|
||||
|
||||
async list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]> {
|
||||
const limit = Math.max(1, Math.min(1_000, Math.trunc(query.limit ?? 100)));
|
||||
return (await this.readRecords())
|
||||
.filter((record) => sameRun(record, query.workspaceId, query.taskId, query.attemptId))
|
||||
.sort((left, right) => left.sequence - right.sequence)
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
async append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult> {
|
||||
const record = phaseTransitionRecordSchema.parse(input.record);
|
||||
await this.prepareParent();
|
||||
return withFileLock(this.filePath, async () => {
|
||||
const records = await this.readRecords();
|
||||
const existing = records.find(
|
||||
(candidate) =>
|
||||
sameRun(candidate, record.workspaceId, record.taskId, record.attemptId) &&
|
||||
candidate.operationId === record.operationId
|
||||
);
|
||||
if (existing) return idempotentResult(existing, input);
|
||||
|
||||
const current = currentFor(records, record.workspaceId, record.taskId, record.attemptId);
|
||||
const conflict = compareAndSetConflict(current, input);
|
||||
if (conflict) return { record: current ?? undefined, appended: false, reason: conflict };
|
||||
if (record.sequence !== input.expectedSequence + 1) {
|
||||
return { record: current ?? undefined, appended: false, reason: 'stale-sequence' };
|
||||
}
|
||||
|
||||
await this.appendRecord(record, records);
|
||||
return { record, appended: 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('Phase transition directory is not a private regular directory.');
|
||||
}
|
||||
}
|
||||
|
||||
private async readRecords(): Promise<PhaseTransitionRecord[]> {
|
||||
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_TRANSITION_LOG_BYTES) {
|
||||
throw new Error('Phase transition 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_TRANSITION_RECORDS) {
|
||||
throw new Error('Phase transition log reached its bounded record limit.');
|
||||
}
|
||||
return lines.map((line) => phaseTransitionRecordSchema.parse(JSON.parse(line)));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||
throw new Error('Phase transition log is not a bounded regular file.', { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async appendRecord(
|
||||
record: PhaseTransitionRecord,
|
||||
existing: PhaseTransitionRecord[]
|
||||
): Promise<void> {
|
||||
if (existing.length >= MAX_TRANSITION_RECORDS) {
|
||||
throw new Error('Phase transition log reached its bounded record limit.');
|
||||
}
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
const existingBytes = existing.reduce(
|
||||
(total, candidate) => total + Buffer.byteLength(JSON.stringify(candidate), 'utf8') + 1,
|
||||
0
|
||||
);
|
||||
if (existingBytes + Buffer.byteLength(line, 'utf8') > MAX_TRANSITION_LOG_BYTES) {
|
||||
throw new Error('Phase transition 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryPhaseTransitionRepository implements PhaseTransitionRepository {
|
||||
private readonly records: PhaseTransitionRecord[] = [];
|
||||
|
||||
async getCurrent(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
return currentFor(this.records, workspaceId, taskId, attemptId);
|
||||
}
|
||||
|
||||
async getByOperationId(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
operationId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
const record = this.records.find(
|
||||
(candidate) =>
|
||||
sameRun(candidate, workspaceId, taskId, attemptId) && candidate.operationId === operationId
|
||||
);
|
||||
return record ? structuredClone(record) : null;
|
||||
}
|
||||
|
||||
async list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]> {
|
||||
const limit = Math.max(1, Math.min(1_000, Math.trunc(query.limit ?? 100)));
|
||||
return this.records
|
||||
.filter((record) => sameRun(record, query.workspaceId, query.taskId, query.attemptId))
|
||||
.sort((left, right) => left.sequence - right.sequence)
|
||||
.slice(-limit)
|
||||
.map((record) => structuredClone(record));
|
||||
}
|
||||
|
||||
async append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult> {
|
||||
const record = phaseTransitionRecordSchema.parse(input.record);
|
||||
const existing = this.records.find(
|
||||
(candidate) =>
|
||||
sameRun(candidate, record.workspaceId, record.taskId, record.attemptId) &&
|
||||
candidate.operationId === record.operationId
|
||||
);
|
||||
if (existing) return idempotentResult(existing, input);
|
||||
const current = currentFor(this.records, record.workspaceId, record.taskId, record.attemptId);
|
||||
const conflict = compareAndSetConflict(current, input);
|
||||
if (conflict) return { record: current ?? undefined, appended: false, reason: conflict };
|
||||
if (record.sequence !== input.expectedSequence + 1) {
|
||||
return { record: current ?? undefined, appended: false, reason: 'stale-sequence' };
|
||||
}
|
||||
this.records.push(structuredClone(record));
|
||||
return { record: structuredClone(record), appended: true };
|
||||
}
|
||||
}
|
||||
|
||||
function currentFor(
|
||||
records: PhaseTransitionRecord[],
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): PhaseTransitionRecord | null {
|
||||
const current = records
|
||||
.filter((record) => sameRun(record, workspaceId, taskId, attemptId))
|
||||
.sort((left, right) => right.sequence - left.sequence)[0];
|
||||
return current ? structuredClone(current) : null;
|
||||
}
|
||||
|
||||
function sameRun(
|
||||
record: PhaseTransitionRecord,
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): boolean {
|
||||
return (
|
||||
record.workspaceId === workspaceId && record.taskId === taskId && record.attemptId === attemptId
|
||||
);
|
||||
}
|
||||
|
||||
function compareAndSetConflict(
|
||||
current: PhaseTransitionRecord | null,
|
||||
input: PhaseTransitionAppendInput
|
||||
): PhaseTransitionAppendResult['reason'] | undefined {
|
||||
if ((current?.sequence ?? 0) !== input.expectedSequence) return 'stale-sequence';
|
||||
const currentDigest = current?.effectiveEvidence.digest ?? input.record.priorEvidence.digest;
|
||||
if (
|
||||
currentDigest !== input.expectedPhaseEvidenceDigest ||
|
||||
input.record.priorEvidence.digest !== input.expectedPhaseEvidenceDigest
|
||||
) {
|
||||
return 'stale-phase-evidence';
|
||||
}
|
||||
if (
|
||||
(current && current.manifestDigest !== input.expectedManifestDigest) ||
|
||||
input.record.manifestDigest !== input.expectedManifestDigest
|
||||
) {
|
||||
return 'stale-manifest';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function idempotentResult(
|
||||
existing: PhaseTransitionRecord,
|
||||
input: PhaseTransitionAppendInput
|
||||
): PhaseTransitionAppendResult {
|
||||
const same =
|
||||
existing.sequence === input.expectedSequence + 1 &&
|
||||
existing.priorEvidence.digest === input.expectedPhaseEvidenceDigest &&
|
||||
existing.effectiveEvidence.digest === input.record.effectiveEvidence.digest &&
|
||||
existing.manifestDigest === input.expectedManifestDigest;
|
||||
return {
|
||||
record: structuredClone(existing),
|
||||
appended: false,
|
||||
...(same ? {} : { reason: 'operation-reused' as const }),
|
||||
};
|
||||
}
|
||||
|
|
@ -1256,6 +1256,34 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [
|
|||
ON run_tool_catalogs(provider, created_at DESC);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 22,
|
||||
name: '0022_phase_transition_journal',
|
||||
up: `
|
||||
CREATE TABLE phase_transitions (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT 'local'
|
||||
REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||
operation_id TEXT NOT NULL,
|
||||
from_evidence_digest TEXT NOT NULL,
|
||||
to_evidence_digest TEXT NOT NULL,
|
||||
manifest_digest TEXT NOT NULL,
|
||||
transition_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (workspace_id, task_id, attempt_id, sequence),
|
||||
UNIQUE (workspace_id, task_id, attempt_id, operation_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_phase_transitions_run_sequence
|
||||
ON phase_transitions(workspace_id, task_id, attempt_id, sequence DESC);
|
||||
|
||||
CREATE INDEX idx_phase_transitions_created
|
||||
ON phase_transitions(workspace_id, created_at DESC);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] {
|
||||
|
|
|
|||
183
server/src/storage/sqlite/phase-transition-repository.ts
Normal file
183
server/src/storage/sqlite/phase-transition-repository.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import type {
|
||||
PhaseTransitionAppendInput,
|
||||
PhaseTransitionAppendResult,
|
||||
PhaseTransitionQuery,
|
||||
PhaseTransitionRecord,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { phaseTransitionRecordSchema } from '../../schemas/phase-capability-schemas.js';
|
||||
import type { PhaseTransitionRepository } from '../interfaces.js';
|
||||
import type { SqliteDatabase } from './database.js';
|
||||
|
||||
interface PhaseTransitionRow {
|
||||
transition_json: string;
|
||||
}
|
||||
|
||||
export class SqlitePhaseTransitionRepository implements PhaseTransitionRepository {
|
||||
constructor(private readonly database: SqliteDatabase) {}
|
||||
|
||||
async getCurrent(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
const row = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`SELECT transition_json
|
||||
FROM phase_transitions
|
||||
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(workspaceId, taskId, attemptId) as PhaseTransitionRow | undefined;
|
||||
return row ? phaseTransitionRecordSchema.parse(JSON.parse(row.transition_json)) : null;
|
||||
}
|
||||
|
||||
async getByOperationId(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
operationId: string
|
||||
): Promise<PhaseTransitionRecord | null> {
|
||||
const row = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`SELECT transition_json
|
||||
FROM phase_transitions
|
||||
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ? AND operation_id = ?`
|
||||
)
|
||||
.get(workspaceId, taskId, attemptId, operationId) as PhaseTransitionRow | undefined;
|
||||
return row ? phaseTransitionRecordSchema.parse(JSON.parse(row.transition_json)) : null;
|
||||
}
|
||||
|
||||
async list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]> {
|
||||
const limit = Math.max(1, Math.min(1_000, Math.trunc(query.limit ?? 100)));
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`SELECT transition_json
|
||||
FROM (
|
||||
SELECT sequence, transition_json
|
||||
FROM phase_transitions
|
||||
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY sequence ASC`
|
||||
)
|
||||
.all(
|
||||
query.workspaceId,
|
||||
query.taskId,
|
||||
query.attemptId,
|
||||
limit
|
||||
) as unknown as PhaseTransitionRow[];
|
||||
return rows.map((row) => phaseTransitionRecordSchema.parse(JSON.parse(row.transition_json)));
|
||||
}
|
||||
|
||||
async append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult> {
|
||||
const record = phaseTransitionRecordSchema.parse(input.record);
|
||||
const connection = this.database.getConnection();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const existingRow = connection
|
||||
.prepare(
|
||||
`SELECT transition_json
|
||||
FROM phase_transitions
|
||||
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ? AND operation_id = ?`
|
||||
)
|
||||
.get(record.workspaceId, record.taskId, record.attemptId, record.operationId) as
|
||||
PhaseTransitionRow | undefined;
|
||||
if (existingRow) {
|
||||
const existing = phaseTransitionRecordSchema.parse(JSON.parse(existingRow.transition_json));
|
||||
connection.exec('COMMIT');
|
||||
return idempotentResult(existing, input);
|
||||
}
|
||||
|
||||
const currentRow = connection
|
||||
.prepare(
|
||||
`SELECT transition_json
|
||||
FROM phase_transitions
|
||||
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(record.workspaceId, record.taskId, record.attemptId) as PhaseTransitionRow | undefined;
|
||||
const current = currentRow
|
||||
? phaseTransitionRecordSchema.parse(JSON.parse(currentRow.transition_json))
|
||||
: null;
|
||||
const conflict = compareAndSetConflict(current, input);
|
||||
if (conflict || record.sequence !== input.expectedSequence + 1) {
|
||||
connection.exec('COMMIT');
|
||||
return {
|
||||
record: current ?? undefined,
|
||||
appended: false,
|
||||
reason: conflict ?? 'stale-sequence',
|
||||
};
|
||||
}
|
||||
|
||||
connection
|
||||
.prepare(
|
||||
`INSERT INTO phase_transitions (
|
||||
id, workspace_id, task_id, attempt_id, sequence, operation_id,
|
||||
from_evidence_digest, to_evidence_digest, manifest_digest,
|
||||
transition_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
record.id,
|
||||
record.workspaceId,
|
||||
record.taskId,
|
||||
record.attemptId,
|
||||
record.sequence,
|
||||
record.operationId,
|
||||
record.priorEvidence.digest,
|
||||
record.effectiveEvidence.digest,
|
||||
record.manifestDigest,
|
||||
JSON.stringify(record),
|
||||
record.createdAt
|
||||
);
|
||||
connection.exec('COMMIT');
|
||||
return { record, appended: true };
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareAndSetConflict(
|
||||
current: PhaseTransitionRecord | null,
|
||||
input: PhaseTransitionAppendInput
|
||||
): PhaseTransitionAppendResult['reason'] | undefined {
|
||||
if ((current?.sequence ?? 0) !== input.expectedSequence) return 'stale-sequence';
|
||||
const currentDigest = current?.effectiveEvidence.digest ?? input.record.priorEvidence.digest;
|
||||
if (
|
||||
currentDigest !== input.expectedPhaseEvidenceDigest ||
|
||||
input.record.priorEvidence.digest !== input.expectedPhaseEvidenceDigest
|
||||
) {
|
||||
return 'stale-phase-evidence';
|
||||
}
|
||||
if (
|
||||
(current && current.manifestDigest !== input.expectedManifestDigest) ||
|
||||
input.record.manifestDigest !== input.expectedManifestDigest
|
||||
) {
|
||||
return 'stale-manifest';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function idempotentResult(
|
||||
existing: PhaseTransitionRecord,
|
||||
input: PhaseTransitionAppendInput
|
||||
): PhaseTransitionAppendResult {
|
||||
const same =
|
||||
existing.sequence === input.expectedSequence + 1 &&
|
||||
existing.priorEvidence.digest === input.expectedPhaseEvidenceDigest &&
|
||||
existing.effectiveEvidence.digest === input.record.effectiveEvidence.digest &&
|
||||
existing.manifestDigest === input.expectedManifestDigest;
|
||||
return {
|
||||
record: existing,
|
||||
appended: false,
|
||||
...(same ? {} : { reason: 'operation-reused' as const }),
|
||||
};
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { SqliteOperationalProvenanceRepository } from './provenance-repository.j
|
|||
import { SqliteSetupContextRepository } from './setup-context-repository.js';
|
||||
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 { SqliteToolControlPlaneRepository } from './tool-control-plane-repository.js';
|
||||
import { createDefaultConfig, normalizeAppConfig } from '../../services/config-service.js';
|
||||
|
|
@ -35,6 +36,7 @@ export class SqliteStorageProvider implements StorageProvider {
|
|||
readonly setupContext: SqliteSetupContextRepository;
|
||||
readonly runEvents: SqliteRunEventRepository;
|
||||
readonly runApprovals: SqliteRunApprovalRepository;
|
||||
readonly phaseTransitions: SqlitePhaseTransitionRepository;
|
||||
readonly runSupervisors: SqliteRunSupervisorRepository;
|
||||
readonly toolControlPlane: SqliteToolControlPlaneRepository;
|
||||
|
||||
|
|
@ -58,6 +60,7 @@ export class SqliteStorageProvider implements StorageProvider {
|
|||
this.setupContext = new SqliteSetupContextRepository(this.sqlite);
|
||||
this.runEvents = new SqliteRunEventRepository(this.sqlite);
|
||||
this.runApprovals = new SqliteRunApprovalRepository(this.sqlite);
|
||||
this.phaseTransitions = new SqlitePhaseTransitionRepository(this.sqlite);
|
||||
this.runSupervisors = new SqliteRunSupervisorRepository(this.sqlite);
|
||||
this.toolControlPlane = new SqliteToolControlPlaneRepository(this.sqlite);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { RunApprovalActor, RunApprovalRequest } from './run-approval.types.js';
|
||||
|
||||
export const PHASE_CAPABILITY_PROFILE_SCHEMA_VERSION = 'phase-capability-profile/v1' as const;
|
||||
export const PHASE_CAPABILITY_EVIDENCE_SCHEMA_VERSION = 'phase-capability-evidence/v1' as const;
|
||||
export const PHASE_TRANSITION_INTENT_SCHEMA_VERSION = 'phase-transition-intent/v1' as const;
|
||||
export const PHASE_TRANSITION_RECORD_SCHEMA_VERSION = 'phase-transition-record/v1' as const;
|
||||
|
||||
export const PHASE_NAMES = ['explore', 'plan', 'implement', 'verify', 'publish'] as const;
|
||||
export type PhaseName = (typeof PHASE_NAMES)[number];
|
||||
|
|
@ -156,3 +159,96 @@ export interface PhaseCapabilityCompilerInput {
|
|||
sources: PhaseCapabilityCompilerSources;
|
||||
planArtifact?: PhasePlanArtifactRequest;
|
||||
}
|
||||
|
||||
export type PhaseTransitionPolicyDecision =
|
||||
'allow' | 'approved-expansion' | 'emergency-override' | 'override-expired';
|
||||
|
||||
export interface PhaseAuthorityDeltaEntry {
|
||||
dimension: PhaseAuthorityDimension;
|
||||
addedScopes: string[];
|
||||
removedScopes: string[];
|
||||
}
|
||||
|
||||
export interface PhaseAuthorityDelta {
|
||||
classification: 'same' | 'narrowing' | 'expanding' | 'mixed';
|
||||
entries: PhaseAuthorityDeltaEntry[];
|
||||
}
|
||||
|
||||
export interface PhaseEmergencyOverrideEvidence {
|
||||
permission: 'admin:manage';
|
||||
justification: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One append-only, applied phase transition.
|
||||
*
|
||||
* `eventReference` is the deterministic run-event dedupe key. This lets an
|
||||
* idempotent retry reconcile a missing projection without mutating the
|
||||
* transition.
|
||||
*/
|
||||
export interface PhaseTransitionRecord {
|
||||
schemaVersion: typeof PHASE_TRANSITION_RECORD_SCHEMA_VERSION;
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
sequence: number;
|
||||
operationId: string;
|
||||
priorEvidence: PhaseCapabilityEvidence;
|
||||
effectiveEvidence: PhaseCapabilityEvidence;
|
||||
authorityDelta: PhaseAuthorityDelta;
|
||||
actor: RunApprovalActor;
|
||||
reason: string;
|
||||
policyDecision: PhaseTransitionPolicyDecision;
|
||||
approvalId?: string;
|
||||
emergencyOverride?: PhaseEmergencyOverrideEvidence;
|
||||
manifestDigest: string;
|
||||
eventReference: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PhaseTransitionRequestInput {
|
||||
attemptId: string;
|
||||
operationId: string;
|
||||
expectedSequence: number;
|
||||
expectedPhaseEvidenceDigest: string;
|
||||
expectedManifestDigest: string;
|
||||
reason: string;
|
||||
fromEvidence?: PhaseCapabilityEvidence;
|
||||
targetEvidence: PhaseCapabilityEvidence;
|
||||
approvalId?: string;
|
||||
approvalTtlMs?: number;
|
||||
emergencyOverride?: {
|
||||
justification: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhaseTransitionQuery {
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PhaseTransitionAppendInput {
|
||||
record: PhaseTransitionRecord;
|
||||
expectedSequence: number;
|
||||
expectedPhaseEvidenceDigest: string;
|
||||
expectedManifestDigest: string;
|
||||
}
|
||||
|
||||
export interface PhaseTransitionAppendResult {
|
||||
record?: PhaseTransitionRecord;
|
||||
appended: boolean;
|
||||
reason?: 'stale-sequence' | 'stale-phase-evidence' | 'stale-manifest' | 'operation-reused';
|
||||
}
|
||||
|
||||
export interface PhaseTransitionResult {
|
||||
status: 'applied' | 'approval-required';
|
||||
current: PhaseTransitionRecord | null;
|
||||
record?: PhaseTransitionRecord;
|
||||
approval?: RunApprovalRequest;
|
||||
targetEvidenceDigest: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,11 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
|
|||
{ methods: ['POST'], path: /^\/[^/]+\/launch-preview\/?$/, permissions: 'agent:read' },
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/message\/?$/, permissions: 'task:write' },
|
||||
{
|
||||
methods: ['POST'],
|
||||
path: /^\/[^/]+\/phase\/transitions\/?$/,
|
||||
permissions: 'task:write',
|
||||
},
|
||||
{
|
||||
methods: ['POST'],
|
||||
path: /^\/[^/]+\/conversation\/steer\/?$/,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue