mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Co-authored-by: bradgroux <brad@digitalmeld.io>
This commit is contained in:
parent
566ec0f7fe
commit
b704ab92d0
32 changed files with 2173 additions and 56 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added the versioned `task-envelope/v1` and `completion-result/v1` contracts,
|
||||
canonical envelope digests, immutable per-attempt persistence, launch HEAD and
|
||||
dirty-file attribution with staged-blob and worktree SHA-256 fingerprints,
|
||||
bounded evidence/output/verification/side-effect schemas, and explicit
|
||||
`forbidden`, `allowed`, or `required` commit policy with run, task, and
|
||||
legacy-setting precedence. Baseline capture retries unstable repositories,
|
||||
path side effects are clamped to the effective sandbox, authoritative run
|
||||
contracts reject generic task PATCH mutation, oversized legacy workspace IDs
|
||||
normalize deterministically, and task create/update policy is available over
|
||||
REST, CLI, and MCP. Agent start and status APIs, task storage, attempt history,
|
||||
and logs now carry the exact envelope used for the run (#891).
|
||||
- Added versioned, evidence-backed provider runtime manifests for Codex CLI,
|
||||
Codex SDK, Hermes, and OpenClaw with bounded identity and conformance probes,
|
||||
race-safe version-skew cache invalidation, canonical immutable run snapshots
|
||||
|
|
|
|||
|
|
@ -56,6 +56,27 @@ describe('vk agent runtime capability controls', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('forwards an explicit run commit policy', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['start', 'task_1', '--agent', 'codex', '--commit-policy', 'forbidden', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent: 'codex',
|
||||
profileId: undefined,
|
||||
requiredRuntimeCapabilities: undefined,
|
||||
commitPolicy: 'forbidden',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces authoritative fail-closed stop errors from the API', async () => {
|
||||
mockApi
|
||||
.mockResolvedValueOnce({ running: true, attemptId: 'attempt_1' })
|
||||
|
|
|
|||
65
cli/src/__tests__/task-execution-policy.test.ts
Normal file
65
cli/src/__tests__/task-execution-policy.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const { mockApi, mockFindTask } = vi.hoisted(() => ({
|
||||
mockApi: vi.fn(),
|
||||
mockFindTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api: mockApi }));
|
||||
vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
|
||||
|
||||
import { registerTaskCommands } from '../commands/tasks.js';
|
||||
|
||||
describe('vk task execution policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.mockResolvedValue({
|
||||
id: 'task_1',
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
created: '2026-07-16T00:00:00.000Z',
|
||||
updated: '2026-07-16T00:00:00.000Z',
|
||||
});
|
||||
mockFindTask.mockResolvedValue({ id: 'task_1' });
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('forwards a task commit policy on create', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerTaskCommands(program);
|
||||
|
||||
await program.parseAsync(['create', 'Policy task', '--commit-policy', 'forbidden', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
executionPolicy: { commitPolicy: 'forbidden' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a task commit policy on update', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerTaskCommands(program);
|
||||
|
||||
await program.parseAsync(['update', 'task_1', '--commit-policy', 'required', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/tasks/task_1', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ executionPolicy: { commitPolicy: 'required' } }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,10 @@ export function registerAgentCommands(program: Command): void {
|
|||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
|
|
@ -57,6 +61,7 @@ export function registerAgentCommands(program: Command): void {
|
|||
agent: options.profile ? undefined : options.agent,
|
||||
profileId: options.profile,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export function registerTaskCommands(program: Command): void {
|
|||
.option('-d, --description <desc>', 'Task description')
|
||||
.option('--priority <priority>', 'Priority (low, medium, high)', 'medium')
|
||||
.option('-s, --status <status>', 'Initial status')
|
||||
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (title, options) => {
|
||||
try {
|
||||
|
|
@ -107,6 +108,9 @@ export function registerTaskCommands(program: Command): void {
|
|||
description: options.description || '',
|
||||
priority: options.priority,
|
||||
status: options.status,
|
||||
executionPolicy: options.commitPolicy
|
||||
? { commitPolicy: options.commitPolicy }
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -132,6 +136,7 @@ export function registerTaskCommands(program: Command): void {
|
|||
.option('-S, --sprint <sprint>', 'Sprint name or ID')
|
||||
.option('--priority <priority>', 'New priority')
|
||||
.option('--title <title>', 'New title')
|
||||
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
|
|
@ -149,6 +154,9 @@ export function registerTaskCommands(program: Command): void {
|
|||
if (options.sprint) updates.sprint = options.sprint;
|
||||
if (options.priority) updates.priority = options.priority;
|
||||
if (options.title) updates.title = options.title;
|
||||
if (options.commitPolicy) {
|
||||
updates.executionPolicy = { commitPolicy: options.commitPolicy };
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.error(chalk.yellow('No updates specified'));
|
||||
|
|
|
|||
|
|
@ -74,6 +74,37 @@ routing. Provider version/build changes invalidate the readiness cache and
|
|||
force a new conformance probe; active controls continue to use the immutable
|
||||
snapshot persisted for that attempt.
|
||||
|
||||
## Task Envelopes And Commit Policy
|
||||
|
||||
Every launch also persists a provider-neutral `task-envelope/v1` snapshot. It
|
||||
binds the task and attempt identity, objective, background, constraints,
|
||||
acceptance criteria, worktree identity, launch manifest, expected outputs,
|
||||
verification gates, evidence requirements, and allowed side effects to one
|
||||
canonical `sha256:` digest. The worktree baseline records `HEAD` plus every
|
||||
dirty file that existed before launch, including its staged index blob and
|
||||
worktree-content SHA-256. Capture retries when HEAD, status, or fingerprints
|
||||
move and fails closed after three unstable attempts, so later completion
|
||||
evidence cannot claim pre-existing changes.
|
||||
|
||||
Commit behavior is explicit instead of implied by a shared prompt:
|
||||
|
||||
- `forbidden` does not authorize a commit.
|
||||
- `allowed` authorizes a commit but does not require one. This is the compatible
|
||||
default for existing tasks.
|
||||
- `required` requires completion evidence for a commit created after the launch
|
||||
baseline.
|
||||
|
||||
A one-off `commitPolicy` start value overrides `task.executionPolicy`, which
|
||||
overrides the legacy `features.agents.autoCommitOnComplete` setting. Legacy
|
||||
`true` maps to `required`; `false` or an absent value maps to `allowed`.
|
||||
Requested filesystem, process, commit, and artifact scopes are intersected
|
||||
with the effective worktree sandbox; ancestor requests such as `/` are clamped
|
||||
to the assigned worktree and disjoint paths are rejected.
|
||||
The start response, active status response, task attempt/history, and Markdown
|
||||
run log expose the same immutable envelope. Provider-owned rendering and
|
||||
normalized completion enforcement land in the ordered follow-up work for
|
||||
parent issue #860.
|
||||
|
||||
Sandbox launch checks resolve every preset rule through the same manifest.
|
||||
Settings dry-runs send the digest of the newest matching manifest registered by
|
||||
a live host; the server resolves that digest rather than trusting a
|
||||
|
|
|
|||
|
|
@ -1927,7 +1927,8 @@ runtime capabilities:
|
|||
{
|
||||
"profileId": "docs-reviewer",
|
||||
"sandboxPresetId": "codex-repo-contained",
|
||||
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"]
|
||||
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"],
|
||||
"commitPolicy": "allowed"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1939,6 +1940,12 @@ capabilities plus caller, profile, sandbox, and budget requirements must be
|
|||
mutated. Failure returns `409 Conflict` with `requiredCapabilities`, reasons,
|
||||
manifest identity, and remediation.
|
||||
|
||||
`commitPolicy` accepts `forbidden`, `allowed`, or `required`. A run value
|
||||
overrides `task.executionPolicy.commitPolicy`, then the legacy
|
||||
`features.agents.autoCommitOnComplete` setting. Legacy `true` maps to
|
||||
`required`; `false` or an absent value maps to the compatible `allowed`
|
||||
default. Unknown policy fields or values return `400 Validation failed`.
|
||||
|
||||
`GET /api/agents/:taskId/status` returns the active manifest and its derived
|
||||
controls:
|
||||
|
||||
|
|
@ -1948,6 +1955,18 @@ controls:
|
|||
"attemptId": "attempt_123",
|
||||
"provider": "codex-cli",
|
||||
"providerRuntimeManifest": { "digest": "sha256:..." },
|
||||
"taskEnvelope": {
|
||||
"schemaVersion": "task-envelope/v1",
|
||||
"digest": "sha256:...",
|
||||
"commitPolicy": "allowed",
|
||||
"workspace": {
|
||||
"baseline": {
|
||||
"headSha": "0123456789abcdef0123456789abcdef01234567",
|
||||
"dirty": false,
|
||||
"files": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"manifestDigest": "sha256:...",
|
||||
"probeState": "ready",
|
||||
|
|
@ -2031,6 +2050,39 @@ Task and trace responses can therefore include the immutable
|
|||
}
|
||||
```
|
||||
|
||||
### Task Envelope On Attempts
|
||||
|
||||
The start API builds the immutable envelope only after readiness, runtime
|
||||
manifest, and sandbox decisions succeed, but before provider execution. The
|
||||
same `taskEnvelope` is returned by start/status and persisted on the current
|
||||
attempt and attempt history. Its digest covers the provider-neutral task,
|
||||
workspace baseline, policy, expected outputs, gates, and launch-manifest
|
||||
reference. Dirty baseline entries include `indexBlobHash` and
|
||||
`worktreeSha256`, allowing completion attribution to distinguish staged and
|
||||
unstaged pre-launch content. Baseline capture is sequential, retries up to
|
||||
three times when HEAD, status, or fingerprints move, and fails closed if the
|
||||
worktree never stabilizes. Existing attempt records without an envelope remain
|
||||
readable.
|
||||
|
||||
Task create/update payloads may set reusable defaults under
|
||||
`executionPolicy`:
|
||||
|
||||
```json
|
||||
{
|
||||
"executionPolicy": {
|
||||
"commitPolicy": "forbidden",
|
||||
"allowedSideEffects": [{ "kind": "filesystem-write", "scope": "." }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run-time allowed side effects are intersected with the effective sandbox and
|
||||
manifest posture; a task policy cannot grant a capability that launch policy
|
||||
does not authorize. Path scopes wider than the assigned worktree are clamped to
|
||||
the worktree, while disjoint path scopes are dropped. Generic task PATCH calls
|
||||
cannot set `attempt.taskEnvelope` or `attempt.completionResult`; only the
|
||||
launch and finalization services may persist those authoritative contracts.
|
||||
|
||||
The full manifest contains one entry for every known runtime and sandbox
|
||||
capability. A provider version/build change invalidates cached conformance
|
||||
evidence. Failed probes and unknown versions are not positively cached.
|
||||
|
|
|
|||
|
|
@ -241,16 +241,18 @@ Create a new task.
|
|||
```bash
|
||||
vk create "Implement OAuth login"
|
||||
vk create "Fix button alignment" --type code --priority high --project my-app
|
||||
vk create "Audit without commits" --commit-policy forbidden
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Description |
|
||||
| ------------ | ----------------------------------------- |
|
||||
| `--type` | Task type (code, research, content, etc.) |
|
||||
| `--priority` | Priority level (low, medium, high) |
|
||||
| `--project` | Project name |
|
||||
| `--json` | Output as JSON |
|
||||
| Flag | Description |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| `--type` | Task type (code, research, content, etc.) |
|
||||
| `--priority` | Priority level (low, medium, high) |
|
||||
| `--project` | Project name |
|
||||
| `--commit-policy` | Task commit policy (`forbidden`, `allowed`, `required`) |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -261,18 +263,20 @@ Update task fields.
|
|||
```bash
|
||||
vk update abc123 --status review
|
||||
vk update abc123 --title "New title" --priority high
|
||||
vk update abc123 --commit-policy required
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Description |
|
||||
| ------------ | -------------- |
|
||||
| `--status` | New status |
|
||||
| `--title` | New title |
|
||||
| `--priority` | New priority |
|
||||
| `--type` | New type |
|
||||
| `--project` | New project |
|
||||
| `--json` | Output as JSON |
|
||||
| Flag | Description |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| `--status` | New status |
|
||||
| `--title` | New title |
|
||||
| `--priority` | New priority |
|
||||
| `--type` | New type |
|
||||
| `--project` | New project |
|
||||
| `--commit-policy` | Task commit policy (`forbidden`, `allowed`, `required`) |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -455,6 +459,7 @@ Require one or more capabilities before launch:
|
|||
```bash
|
||||
vk start TASK-001 --agent codex \
|
||||
--require-capability tool.mcp output.structured \
|
||||
--commit-policy allowed \
|
||||
--json
|
||||
```
|
||||
|
||||
|
|
@ -463,6 +468,11 @@ profile, sandbox, and budget requirements. The server returns a structured
|
|||
conflict and the CLI exits non-zero when any capability is unsupported,
|
||||
unknown, missing, or backed by an invalid/failed manifest.
|
||||
|
||||
`--commit-policy <forbidden|allowed|required>` sets the policy for this run.
|
||||
It overrides a task default and the legacy auto-commit setting. Omitting the
|
||||
flag keeps existing tasks compatible: commits are allowed but not required
|
||||
unless a task or legacy setting explicitly requires one.
|
||||
|
||||
Use `vk agents:status TASK-001 --json` to inspect the persisted manifest and
|
||||
capability-derived `controls` set. `vk stop` does not infer support from the
|
||||
agent name. It resolves the current `attemptId` from status and includes it in
|
||||
|
|
|
|||
|
|
@ -291,14 +291,14 @@ These are set in `server/.env`, not in the MCP client config:
|
|||
|
||||
### Task Management (6 tools)
|
||||
|
||||
| Tool | Description | Required Inputs | Key Options |
|
||||
| -------------- | --------------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||
| `list_tasks` | List all tasks, optionally filtered | _(none)_ | `status`, `type`, `project`, `sprint` |
|
||||
| `get_task` | Get task by ID (supports partial match) | `id` | — |
|
||||
| `create_task` | Create a new task | `title` | `type`, `priority`, `project`, `sprint` |
|
||||
| `update_task` | Update task fields | `id` | `title`, `description`, `status`, `type`, `priority`, `project`, `sprint` |
|
||||
| `archive_task` | Archive a completed task | `id` | — |
|
||||
| `delete_task` | Permanently delete a task | `id` | — |
|
||||
| Tool | Description | Required Inputs | Key Options |
|
||||
| -------------- | --------------------------------------- | --------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `list_tasks` | List all tasks, optionally filtered | _(none)_ | `status`, `type`, `project`, `sprint` |
|
||||
| `get_task` | Get task by ID (supports partial match) | `id` | — |
|
||||
| `create_task` | Create a new task | `title` | `type`, `priority`, `project`, `sprint`, `commitPolicy` |
|
||||
| `update_task` | Update task fields | `id` | `title`, `description`, `status`, `type`, `priority`, `project`, `sprint`, `commitPolicy` |
|
||||
| `archive_task` | Archive a completed task | `id` | — |
|
||||
| `delete_task` | Permanently delete a task | `id` | — |
|
||||
|
||||
Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or `list_comments` when an assistant needs the full task or comment payload.
|
||||
|
||||
|
|
@ -326,7 +326,8 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
|
|||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "rubicon",
|
||||
"sprint": "sprint-1"
|
||||
"sprint": "sprint-1",
|
||||
"commitPolicy": "required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -351,6 +352,7 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
|
|||
- **status:** `todo` · `in-progress` · `blocked` · `done`
|
||||
- **type:** `code` · `research` · `content` · `automation`
|
||||
- **priority:** `low` · `medium` · `high`
|
||||
- **commitPolicy:** `forbidden` · `allowed` · `required`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -358,7 +360,7 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
|
|||
|
||||
| Tool | Description | Required Inputs | Key Options |
|
||||
| ------------- | ------------------------------ | --------------- | --------------------------------------------------------------------- |
|
||||
| `start_agent` | Start a coding agent on a task | `id` | `agent`; `requiredRuntimeCapabilities` |
|
||||
| `start_agent` | Start a coding agent on a task | `id` | `agent`; `requiredRuntimeCapabilities`; `commitPolicy` |
|
||||
| `stop_agent` | Stop a running agent | `id` | Resolves status and binds the stop to that exact attempt and manifest |
|
||||
|
||||
> **Constraints:** Only works on tasks with `type: "code"` that already have a git worktree attached.
|
||||
|
|
@ -374,7 +376,8 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
|
|||
"arguments": {
|
||||
"id": "abc123",
|
||||
"agent": "claude-code",
|
||||
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"]
|
||||
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"],
|
||||
"commitPolicy": "allowed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -384,6 +387,9 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
|
|||
The required capability list is forwarded unchanged to the authoritative
|
||||
launch API. Unsupported, unknown, missing, failed-probe, or invalid manifest
|
||||
evidence fails closed before provider work starts.
|
||||
`commitPolicy` accepts `forbidden`, `allowed`, or `required` and overrides the
|
||||
task and legacy setting for that run. The launch result is bound to the exact
|
||||
persisted `task-envelope/v1` snapshot and pre-launch worktree baseline.
|
||||
|
||||
**Stop a running agent:**
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,26 @@ describe('MCP agent runtime capability controls', () => {
|
|||
expect(start?.inputSchema.properties.requiredRuntimeCapabilities).toMatchObject({
|
||||
type: 'array',
|
||||
});
|
||||
expect(start?.inputSchema.properties.commitPolicy).toMatchObject({
|
||||
enum: ['forbidden', 'allowed', 'required'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an explicit run commit policy', async () => {
|
||||
await handleAgentTool('start_agent', {
|
||||
id: 'task_1',
|
||||
agent: 'claude-code',
|
||||
commitPolicy: 'required',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent: 'claude-code',
|
||||
requiredRuntimeCapabilities: undefined,
|
||||
commitPolicy: 'required',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards required capabilities to the authoritative launch API', async () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ vi.mock('../utils/find.js', () => ({
|
|||
}));
|
||||
|
||||
import { handleCommentTool } from '../tools/comments.js';
|
||||
import { handleTaskTool } from '../tools/tasks.js';
|
||||
import { handleTaskTool, taskTools } from '../tools/tasks.js';
|
||||
|
||||
function task(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
|
|
@ -59,6 +59,28 @@ describe('MCP write response contract', () => {
|
|||
expect(text).not.toContain('Full task details');
|
||||
});
|
||||
|
||||
it('exposes and forwards task-level commit policy on create_task', async () => {
|
||||
mocks.api.mockResolvedValueOnce(task());
|
||||
|
||||
await handleTaskTool('create_task', {
|
||||
title: 'Policy task',
|
||||
commitPolicy: 'forbidden',
|
||||
});
|
||||
|
||||
expect(
|
||||
taskTools.find((tool) => tool.name === 'create_task')?.inputSchema.properties
|
||||
).toHaveProperty('commitPolicy');
|
||||
expect(mocks.api).toHaveBeenCalledWith('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
priority: 'medium',
|
||||
executionPolicy: { commitPolicy: 'forbidden' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns changed fields for update_task without echoing comments', async () => {
|
||||
mocks.findTask.mockResolvedValueOnce(task());
|
||||
mocks.api.mockResolvedValueOnce(
|
||||
|
|
@ -84,6 +106,25 @@ describe('MCP write response contract', () => {
|
|||
expect(text).not.toContain('{');
|
||||
});
|
||||
|
||||
it('exposes and forwards task-level commit policy on update_task', async () => {
|
||||
mocks.findTask.mockResolvedValueOnce(task());
|
||||
mocks.api.mockResolvedValueOnce(task());
|
||||
|
||||
const result = await handleTaskTool('update_task', {
|
||||
id: 'abc123',
|
||||
commitPolicy: 'required',
|
||||
});
|
||||
|
||||
expect(
|
||||
taskTools.find((tool) => tool.name === 'update_task')?.inputSchema.properties
|
||||
).toHaveProperty('commitPolicy');
|
||||
expect(mocks.api).toHaveBeenCalledWith('/api/tasks/task_20260612_abc123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ executionPolicy: { commitPolicy: 'required' } }),
|
||||
});
|
||||
expect(result.content[0].text).toContain('fields: commitPolicy');
|
||||
});
|
||||
|
||||
it('returns the added comment id and count for add_comment', async () => {
|
||||
mocks.api.mockResolvedValueOnce(
|
||||
task({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const StartAgentSchema = z.object({
|
|||
)
|
||||
.max(64)
|
||||
.optional(),
|
||||
commitPolicy: z.enum(['forbidden', 'allowed', 'required']).optional(),
|
||||
});
|
||||
|
||||
const TaskIdSchema = z.object({
|
||||
|
|
@ -41,6 +42,11 @@ export const agentTools = [
|
|||
items: { type: 'string' },
|
||||
description: 'Provider runtime capabilities that must be evidenced before launch',
|
||||
},
|
||||
commitPolicy: {
|
||||
type: 'string',
|
||||
enum: ['forbidden', 'allowed', 'required'],
|
||||
description: 'Commit policy override for this run',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
|
|
@ -64,7 +70,7 @@ export const agentTools = [
|
|||
export async function handleAgentTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'start_agent': {
|
||||
const { id, agent, requiredRuntimeCapabilities } = StartAgentSchema.parse(args);
|
||||
const { id, agent, requiredRuntimeCapabilities, commitPolicy } = StartAgentSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
|
|
@ -90,7 +96,7 @@ export async function handleAgentTool(name: string, args: any): Promise<any> {
|
|||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent, requiredRuntimeCapabilities }),
|
||||
body: JSON.stringify({ agent, requiredRuntimeCapabilities, commitPolicy }),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const CreateTaskSchema = z.object({
|
|||
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
project: z.string().optional(),
|
||||
sprint: z.string().optional(),
|
||||
commitPolicy: z.enum(['forbidden', 'allowed', 'required']).optional(),
|
||||
});
|
||||
|
||||
const UpdateTaskSchema = z.object({
|
||||
|
|
@ -29,6 +30,7 @@ const UpdateTaskSchema = z.object({
|
|||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
sprint: z.string().optional(),
|
||||
commitPolicy: z.enum(['forbidden', 'allowed', 'required']).optional(),
|
||||
});
|
||||
|
||||
const TaskIdSchema = z.object({
|
||||
|
|
@ -120,6 +122,11 @@ export const taskTools = [
|
|||
type: 'string',
|
||||
description: 'Sprint ID',
|
||||
},
|
||||
commitPolicy: {
|
||||
type: 'string',
|
||||
enum: ['forbidden', 'allowed', 'required'],
|
||||
description: 'Task-level commit policy used for future agent runs',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
|
|
@ -164,6 +171,11 @@ export const taskTools = [
|
|||
type: 'string',
|
||||
description: 'New sprint ID',
|
||||
},
|
||||
commitPolicy: {
|
||||
type: 'string',
|
||||
enum: ['forbidden', 'allowed', 'required'],
|
||||
description: 'Task-level commit policy used for future agent runs',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
|
|
@ -244,10 +256,13 @@ export async function handleTaskTool(name: string, args: any): Promise<any> {
|
|||
}
|
||||
|
||||
case 'create_task': {
|
||||
const params = CreateTaskSchema.parse(args);
|
||||
const { commitPolicy, ...params } = CreateTaskSchema.parse(args);
|
||||
const task = await api<Task>('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
executionPolicy: commitPolicy ? { commitPolicy } : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -266,7 +281,7 @@ export async function handleTaskTool(name: string, args: any): Promise<any> {
|
|||
}
|
||||
|
||||
case 'update_task': {
|
||||
const { id, ...updates } = UpdateTaskSchema.parse(args);
|
||||
const { id, commitPolicy, ...updates } = UpdateTaskSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
|
|
@ -278,14 +293,17 @@ export async function handleTaskTool(name: string, args: any): Promise<any> {
|
|||
|
||||
const updated = await api<Task>(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates),
|
||||
body: JSON.stringify({
|
||||
...updates,
|
||||
executionPolicy: commitPolicy ? { commitPolicy } : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Task updated: ${updated.id}; fields: ${changedFieldList(updates)}; comments: ${commentCount(updated)}`,
|
||||
text: `Task updated: ${updated.id}; fields: ${changedFieldList({ ...updates, commitPolicy })}; comments: ${commentCount(updated)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ import { AgentReadinessError, ClawdbotAgentService } from '../services/clawdbot-
|
|||
import type { ThreadEvent } from '@openai/codex-sdk';
|
||||
import type { AgentConfig, Task } from '@veritas-kanban/shared';
|
||||
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
|
||||
import { TaskEnvelopeService } from '../services/task-envelope-service.js';
|
||||
|
||||
const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'codex');
|
||||
|
||||
|
|
@ -129,7 +130,19 @@ type TestableClawdbotAgentService = ClawdbotAgentService & {
|
|||
};
|
||||
|
||||
function testableService(tmpDir: string): TestableClawdbotAgentService {
|
||||
const service = new ClawdbotAgentService() as unknown as TestableClawdbotAgentService;
|
||||
const taskEnvelopes = new TaskEnvelopeService({
|
||||
captureLaunchBaseline: async (_worktreePath, capturedAt) => ({
|
||||
capturedAt,
|
||||
headSha: 'a'.repeat(40),
|
||||
dirty: false,
|
||||
files: [],
|
||||
}),
|
||||
});
|
||||
const service = new ClawdbotAgentService(
|
||||
undefined,
|
||||
undefined,
|
||||
taskEnvelopes
|
||||
) as unknown as TestableClawdbotAgentService;
|
||||
service.logsDir = tmpDir;
|
||||
return service;
|
||||
}
|
||||
|
|
@ -330,6 +343,17 @@ describe('ClawdbotAgentService Codex providers', () => {
|
|||
providerVersion: 'codex-cli 0.144.0',
|
||||
});
|
||||
expect(task.attempt?.providerRuntimeManifest?.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(task.attempt?.taskEnvelope).toMatchObject({
|
||||
schemaVersion: 'task-envelope/v1',
|
||||
commitPolicy: 'allowed',
|
||||
workspace: {
|
||||
baseline: { headSha: 'a'.repeat(40), dirty: false, files: [] },
|
||||
},
|
||||
launchManifest: {
|
||||
digest: task.attempt?.providerRuntimeManifest?.digest,
|
||||
},
|
||||
});
|
||||
expect(task.attempt?.taskEnvelope?.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(task.attempts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: task.attempt?.id,
|
||||
|
|
@ -351,6 +375,7 @@ describe('ClawdbotAgentService Codex providers', () => {
|
|||
expect(log).toContain(
|
||||
`Provider manifest:** ${task.attempt?.providerRuntimeManifest?.digest}`
|
||||
);
|
||||
expect(log).toContain(`Task envelope:** ${task.attempt?.taskEnvelope?.digest}`);
|
||||
expect(mockStartStep).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'stream',
|
||||
|
|
@ -395,6 +420,23 @@ describe('ClawdbotAgentService Codex providers', () => {
|
|||
}
|
||||
);
|
||||
|
||||
it('persists a run-level commit policy override in the immutable task envelope', async () => {
|
||||
const fixture = await fs.readFile(path.join(fixtureDir, 'success.jsonl'), 'utf-8');
|
||||
mockSpawn.mockReturnValue(createFakeChild(fixture));
|
||||
const service = testableService(tmpDir);
|
||||
|
||||
const status = await service.startAgent(task.id, 'codex', { commitPolicy: 'forbidden' });
|
||||
|
||||
expect(status.taskEnvelope.commitPolicy).toBe('forbidden');
|
||||
expect(status.taskEnvelope.allowedSideEffects).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: 'git-commit' })])
|
||||
);
|
||||
expect(task.attempt?.taskEnvelope?.digest).toBe(status.taskEnvelope.digest);
|
||||
await waitFor(async () => {
|
||||
expect(await service.getAgentStatus(task.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trust Codex file events without a persisted runtime snapshot', async () => {
|
||||
const service = testableService(tmpDir);
|
||||
const logPath = path.join(tmpDir, 'codex.md');
|
||||
|
|
|
|||
|
|
@ -124,9 +124,29 @@ describe('agent local capability enforcement', () => {
|
|||
sandboxPresetId: undefined,
|
||||
budget: undefined,
|
||||
requiredRuntimeCapabilities: undefined,
|
||||
commitPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates and forwards an explicit run commit policy', async () => {
|
||||
const app = createApp(auth({ clientMode: 'desktop-local', capabilities: ['desktop:local'] }));
|
||||
const response = await request(app)
|
||||
.post('/api/agents/task_1/start')
|
||||
.send({ agent: 'codex', commitPolicy: 'forbidden' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockStartAgent).toHaveBeenCalledWith(
|
||||
'task_1',
|
||||
'codex',
|
||||
expect.objectContaining({ commitPolicy: 'forbidden' })
|
||||
);
|
||||
|
||||
const invalid = await request(app)
|
||||
.post('/api/agents/task_1/start')
|
||||
.send({ agent: 'codex', commitPolicy: 'sometimes' });
|
||||
expect(invalid.status).toBe(400);
|
||||
});
|
||||
|
||||
it('validates and forwards required runtime capabilities', async () => {
|
||||
const app = createApp(auth({ clientMode: 'desktop-local', capabilities: ['desktop:local'] }));
|
||||
const response = await request(app)
|
||||
|
|
|
|||
|
|
@ -317,6 +317,75 @@ describe('Tasks Routes (actual module)', () => {
|
|||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects generic PATCH attempts that mutate authoritative run contracts', async () => {
|
||||
for (const field of ['taskEnvelope', 'completionResult']) {
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/t1')
|
||||
.send({
|
||||
attempt: {
|
||||
id: 'attempt_forged',
|
||||
agent: 'codex',
|
||||
status: 'running',
|
||||
[field]: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves authoritative run contracts when patching the same attempt', async () => {
|
||||
const taskEnvelope = { digest: 'immutable-envelope' };
|
||||
const completionResult = { status: 'success' };
|
||||
mockTaskService.getTask.mockResolvedValue({
|
||||
id: 't1',
|
||||
title: 'Old',
|
||||
status: 'in-progress',
|
||||
attempt: {
|
||||
id: 'attempt_1',
|
||||
agent: 'codex',
|
||||
status: 'running',
|
||||
taskEnvelope,
|
||||
completionResult,
|
||||
},
|
||||
});
|
||||
mockTaskService.updateTask.mockImplementation(async (_id, input) => input);
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/t1')
|
||||
.send({ attempt: { id: 'attempt_1', agent: 'codex', status: 'complete' } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockTaskService.updateTask).toHaveBeenCalledWith(
|
||||
't1',
|
||||
expect.objectContaining({
|
||||
attempt: expect.objectContaining({ taskEnvelope, completionResult }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects replacing an attempt that owns authoritative run contracts', async () => {
|
||||
mockTaskService.getTask.mockResolvedValue({
|
||||
id: 't1',
|
||||
title: 'Old',
|
||||
status: 'in-progress',
|
||||
attempt: {
|
||||
id: 'attempt_1',
|
||||
agent: 'codex',
|
||||
status: 'running',
|
||||
taskEnvelope: { digest: 'immutable-envelope' },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/t1')
|
||||
.send({ attempt: { id: 'attempt_2', agent: 'codex', status: 'running' } });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for missing task on getTask', async () => {
|
||||
mockTaskService.getTask.mockResolvedValue(null);
|
||||
const res = await request(app).patch('/api/tasks/nonexistent').send({ title: 'Updated' });
|
||||
|
|
|
|||
|
|
@ -142,14 +142,17 @@ describe('Tasks Routes', () => {
|
|||
});
|
||||
|
||||
it('should create a task with all fields', async () => {
|
||||
const res = await request(app).post('/api/tasks').send({
|
||||
title: 'Full Task',
|
||||
description: 'Detailed description',
|
||||
type: 'research',
|
||||
priority: 'high',
|
||||
project: 'test-project',
|
||||
sprint: 'US-100',
|
||||
});
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send({
|
||||
title: 'Full Task',
|
||||
description: 'Detailed description',
|
||||
type: 'research',
|
||||
priority: 'high',
|
||||
project: 'test-project',
|
||||
sprint: 'US-100',
|
||||
executionPolicy: { commitPolicy: 'forbidden' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe('Full Task');
|
||||
|
|
@ -158,6 +161,7 @@ describe('Tasks Routes', () => {
|
|||
expect(res.body.priority).toBe('high');
|
||||
expect(res.body.project).toBe('test-project');
|
||||
expect(res.body.sprint).toBe('US-100');
|
||||
expect(res.body.executionPolicy).toEqual({ commitPolicy: 'forbidden' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -195,6 +199,16 @@ describe('Tasks Routes', () => {
|
|||
expect(res.body.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('persists task execution policy updates', async () => {
|
||||
const task = await taskService.createTask({ title: 'Policy Task' });
|
||||
const updated = await request(app)
|
||||
.patch(`/api/tasks/${task.id}`)
|
||||
.send({ executionPolicy: { commitPolicy: 'required' } });
|
||||
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.executionPolicy).toEqual({ commitPolicy: 'required' });
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/nonexistent_id')
|
||||
|
|
|
|||
425
server/src/__tests__/task-envelope-service.test.ts
Normal file
425
server/src/__tests__/task-envelope-service.test.ts
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
COMPLETION_RESULT_SCHEMA_VERSION,
|
||||
TASK_ENVELOPE_SCHEMA_VERSION,
|
||||
type CompletionResult,
|
||||
type Task,
|
||||
type TaskLaunchBaseline,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
|
||||
import {
|
||||
CompletionResultSchema,
|
||||
TaskExecutionPolicySchema,
|
||||
TaskEnvelopeSchema,
|
||||
parseCompletionResultForEnvelope,
|
||||
} from '../schemas/task-envelope-schemas.js';
|
||||
import {
|
||||
GitCompletionEvidenceSource,
|
||||
TaskEnvelopeService,
|
||||
resolveTaskCommitPolicy,
|
||||
type CompletionEvidenceSource,
|
||||
} from '../services/task-envelope-service.js';
|
||||
import { verifyTaskEnvelopeDigest } from '../utils/task-envelope-digest.js';
|
||||
|
||||
const createdAt = '2026-07-16T12:00:00.000Z';
|
||||
const baseline: TaskLaunchBaseline = {
|
||||
capturedAt: createdAt,
|
||||
headSha: 'a'.repeat(40),
|
||||
dirty: true,
|
||||
files: [
|
||||
{
|
||||
path: 'pre-existing.txt',
|
||||
status: 'modified',
|
||||
indexBlobHash: 'b'.repeat(40),
|
||||
worktreeSha256: 'c'.repeat(64),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const evidenceSource: CompletionEvidenceSource = {
|
||||
captureLaunchBaseline: async () => structuredClone(baseline),
|
||||
};
|
||||
|
||||
function task(): Task {
|
||||
return {
|
||||
id: 'task_20260716_contract',
|
||||
title: 'Define a task envelope',
|
||||
description: 'Replace transport-specific instructions with a durable contract.',
|
||||
type: 'code',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
project: 'veritas-kanban',
|
||||
created: createdAt,
|
||||
updated: createdAt,
|
||||
git: {
|
||||
repo: 'veritas-kanban',
|
||||
branch: 'feat/task-envelope',
|
||||
baseBranch: 'main',
|
||||
worktreePath: '/tmp/veritas-kanban-task',
|
||||
},
|
||||
subtasks: [
|
||||
{
|
||||
id: 'subtask-1',
|
||||
title: 'Contract',
|
||||
completed: false,
|
||||
created: createdAt,
|
||||
acceptanceCriteria: ['The envelope is versioned.'],
|
||||
},
|
||||
],
|
||||
verificationSteps: [{ id: 'verify-1', description: 'Run focused tests', checked: false }],
|
||||
observations: [
|
||||
{
|
||||
id: 'observation-1',
|
||||
type: 'context',
|
||||
content: 'Existing callbacks must remain readable.',
|
||||
score: 8,
|
||||
timestamp: createdAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function envelope(commitPolicy: 'forbidden' | 'allowed' | 'required' = 'allowed') {
|
||||
return new TaskEnvelopeService(evidenceSource).build({
|
||||
task: task(),
|
||||
attemptId: 'attempt_contract',
|
||||
createdAt,
|
||||
worktreePath: '/tmp/veritas-kanban-task',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'artifact.write': 'supported' },
|
||||
}),
|
||||
commitPolicy,
|
||||
networkAccessEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
function completionResult(
|
||||
taskEnvelope: Awaited<ReturnType<typeof envelope>>,
|
||||
status: CompletionResult['status']
|
||||
): CompletionResult {
|
||||
return {
|
||||
schemaVersion: COMPLETION_RESULT_SCHEMA_VERSION,
|
||||
taskEnvelopeSchemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
|
||||
taskEnvelopeDigest: taskEnvelope.digest,
|
||||
taskId: taskEnvelope.subject.id,
|
||||
attemptId: taskEnvelope.attempt.id,
|
||||
providerRuntimeManifestDigest: taskEnvelope.launchManifest.digest,
|
||||
status,
|
||||
summary: `Fixture ${status}`,
|
||||
error: status === 'failed' ? 'Fixture failure' : null,
|
||||
blockers:
|
||||
status === 'blocked'
|
||||
? [{ code: 'fixture', summary: 'Blocked', detail: 'Fixture blocker', retryable: true }]
|
||||
: [],
|
||||
evidence:
|
||||
status === 'success'
|
||||
? [
|
||||
{
|
||||
id: 'evidence-terminal',
|
||||
kind: 'provider-output',
|
||||
source: 'harness',
|
||||
summary: 'Process exited successfully.',
|
||||
reference: null,
|
||||
requirementIds: ['terminal-state'],
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 'evidence-verification',
|
||||
kind: 'verification',
|
||||
source: 'harness',
|
||||
summary: 'Focused tests passed.',
|
||||
reference: null,
|
||||
requirementIds: ['verification'],
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
changedFiles: [],
|
||||
artifacts: [],
|
||||
verification:
|
||||
status === 'success'
|
||||
? [
|
||||
{
|
||||
gateId: 'verify-1',
|
||||
status: 'passed',
|
||||
summary: 'Focused tests passed.',
|
||||
evidenceIds: ['evidence-verification'],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
sideEffects: [],
|
||||
continuation: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskEnvelopeService', () => {
|
||||
it('builds a strict, immutable, digest-bound envelope with launch attribution', async () => {
|
||||
const result = await envelope('allowed');
|
||||
|
||||
expect(result.schemaVersion).toBe(TASK_ENVELOPE_SCHEMA_VERSION);
|
||||
expect(result.workspace.baseline).toEqual(baseline);
|
||||
expect(result.subject.acceptanceCriteria).toEqual(['The envelope is versioned.']);
|
||||
expect(result.subject.background).toContain('Existing callbacks must remain readable.');
|
||||
expect(result.allowedSideEffects.map((item) => item.kind)).toEqual([
|
||||
'filesystem-write',
|
||||
'process-execute',
|
||||
'git-commit',
|
||||
'network-egress',
|
||||
'artifact-write',
|
||||
]);
|
||||
expect(result.completionContract.evidenceRequirements.map((item) => item.id)).toEqual([
|
||||
'terminal-state',
|
||||
'verification',
|
||||
]);
|
||||
expect(TaskEnvelopeSchema.safeParse(result).success).toBe(true);
|
||||
expect(verifyTaskEnvelopeDigest(result)).toBe(true);
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.workspace.baseline.files)).toBe(true);
|
||||
|
||||
const tampered = structuredClone(result);
|
||||
tampered.commitPolicy = 'required';
|
||||
expect(TaskEnvelopeSchema.safeParse(tampered).success).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves run, task, legacy, and compatible default commit policy precedence', () => {
|
||||
expect(TaskExecutionPolicySchema.safeParse({ commitPolicy: 'sometimes' }).success).toBe(false);
|
||||
expect(
|
||||
resolveTaskCommitPolicy({
|
||||
runPolicy: 'forbidden',
|
||||
taskPolicy: { commitPolicy: 'required' },
|
||||
legacyAutoCommitOnComplete: true,
|
||||
})
|
||||
).toBe('forbidden');
|
||||
expect(
|
||||
resolveTaskCommitPolicy({
|
||||
taskPolicy: { commitPolicy: 'required' },
|
||||
legacyAutoCommitOnComplete: false,
|
||||
})
|
||||
).toBe('required');
|
||||
expect(resolveTaskCommitPolicy({ legacyAutoCommitOnComplete: true })).toBe('required');
|
||||
expect(resolveTaskCommitPolicy({ legacyAutoCommitOnComplete: false })).toBe('allowed');
|
||||
expect(resolveTaskCommitPolicy({})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('rejects contradictory commit and side-effect/output policy', async () => {
|
||||
const service = new TaskEnvelopeService(evidenceSource);
|
||||
const base = {
|
||||
task: task(),
|
||||
attemptId: 'attempt_policy',
|
||||
createdAt,
|
||||
worktreePath: '/tmp/veritas-kanban-task',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.build({
|
||||
...base,
|
||||
commitPolicy: 'required',
|
||||
executionPolicy: {
|
||||
allowedSideEffects: [{ kind: 'filesystem-write', scope: 'assigned worktree' }],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(/must authorize the git-commit side effect/);
|
||||
|
||||
await expect(
|
||||
service.build({
|
||||
...base,
|
||||
commitPolicy: 'forbidden',
|
||||
executionPolicy: {
|
||||
expectedOutputs: [
|
||||
{
|
||||
id: 'contradictory-commit',
|
||||
kind: 'commit',
|
||||
description: 'Create a commit.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(/cannot require a commit output/);
|
||||
});
|
||||
|
||||
it('clamps requested filesystem scopes to the effective worktree sandbox', async () => {
|
||||
const result = await new TaskEnvelopeService(evidenceSource).build({
|
||||
task: task(),
|
||||
attemptId: 'attempt_scope',
|
||||
createdAt,
|
||||
worktreePath: '/tmp/veritas-kanban-task',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture({
|
||||
capabilityStates: { 'artifact.write': 'supported' },
|
||||
}),
|
||||
commitPolicy: 'required',
|
||||
executionPolicy: {
|
||||
allowedSideEffects: [
|
||||
{ kind: 'filesystem-write', scope: '/' },
|
||||
{ kind: 'process-execute', scope: '/tmp' },
|
||||
{ kind: 'git-commit', scope: '/' },
|
||||
{ kind: 'artifact-write', scope: '/etc' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.allowedSideEffects).toEqual([
|
||||
{ kind: 'filesystem-write', scope: '/tmp/veritas-kanban-task' },
|
||||
{ kind: 'process-execute', scope: '/tmp/veritas-kanban-task' },
|
||||
{ kind: 'git-commit', scope: '/tmp/veritas-kanban-task' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes oversized legacy project names into stable workspace IDs', async () => {
|
||||
const service = new TaskEnvelopeService(evidenceSource);
|
||||
const oversizedProject = `legacy-${'x'.repeat(300)}`;
|
||||
const input = {
|
||||
task: { ...task(), project: oversizedProject },
|
||||
attemptId: 'attempt_workspace',
|
||||
createdAt,
|
||||
worktreePath: '/tmp/veritas-kanban-task',
|
||||
providerRuntimeManifest: providerRuntimeManifestFixture(),
|
||||
commitPolicy: 'allowed' as const,
|
||||
};
|
||||
|
||||
const first = await service.build(input);
|
||||
const second = await service.build(input);
|
||||
|
||||
expect(first.workspace.workspaceId).toHaveLength(160);
|
||||
expect(first.workspace.workspaceId).toBe(second.workspace.workspaceId);
|
||||
expect(first.workspace.workspaceId).toMatch(/^legacy-x+~[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('validates all completion statuses and envelope-bound success evidence', async () => {
|
||||
const taskEnvelope = await envelope('allowed');
|
||||
for (const status of ['success', 'blocked', 'failed', 'interrupted', 'partial'] as const) {
|
||||
expect(CompletionResultSchema.safeParse(completionResult(taskEnvelope, status)).success).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
expect(
|
||||
parseCompletionResultForEnvelope(completionResult(taskEnvelope, 'success'), taskEnvelope)
|
||||
).toMatchObject({ status: 'success' });
|
||||
|
||||
const missingVerification = completionResult(taskEnvelope, 'success');
|
||||
missingVerification.verification = [];
|
||||
expect(() => parseCompletionResultForEnvelope(missingVerification, taskEnvelope)).toThrow(
|
||||
/Missing passed verification evidence/
|
||||
);
|
||||
|
||||
const wrongEvidenceKind = completionResult(taskEnvelope, 'success');
|
||||
wrongEvidenceKind.evidence[0].kind = 'file-change';
|
||||
expect(() => parseCompletionResultForEnvelope(wrongEvidenceKind, taskEnvelope)).toThrow(
|
||||
/Missing verified completion evidence/
|
||||
);
|
||||
|
||||
const unboundVerification = completionResult(taskEnvelope, 'success');
|
||||
unboundVerification.verification[0].evidenceIds = ['missing-evidence'];
|
||||
expect(() => parseCompletionResultForEnvelope(unboundVerification, taskEnvelope)).toThrow(
|
||||
/Missing passed verification evidence/
|
||||
);
|
||||
|
||||
const wrongEnvelope = completionResult(taskEnvelope, 'success');
|
||||
wrongEnvelope.taskEnvelopeDigest = `sha256:${'f'.repeat(64)}`;
|
||||
expect(() => parseCompletionResultForEnvelope(wrongEnvelope, taskEnvelope)).toThrow(
|
||||
/task envelope digest/
|
||||
);
|
||||
});
|
||||
|
||||
it('captures the committed HEAD and pre-existing dirty files from a real worktree', async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'veritas-task-envelope-'));
|
||||
const git = simpleGit(directory);
|
||||
await git.init();
|
||||
await git.addConfig('user.name', 'Veritas Test');
|
||||
await git.addConfig('user.email', 'veritas@example.com');
|
||||
await git.addConfig('commit.gpgsign', 'false');
|
||||
await writeFile(path.join(directory, 'tracked.txt'), 'baseline\n');
|
||||
await git.add('tracked.txt');
|
||||
await git.commit('baseline');
|
||||
const headSha = (await git.revparse(['HEAD'])).trim();
|
||||
await writeFile(path.join(directory, 'tracked.txt'), 'changed before launch\n');
|
||||
await writeFile(path.join(directory, 'untracked.txt'), 'pre-existing\n');
|
||||
|
||||
const captured = await new GitCompletionEvidenceSource().captureLaunchBaseline(
|
||||
directory,
|
||||
createdAt
|
||||
);
|
||||
|
||||
expect(captured.headSha).toBe(headSha);
|
||||
expect(captured.dirty).toBe(true);
|
||||
const trackedIndexHash = (await git.raw(['ls-files', '--stage', 'tracked.txt']))
|
||||
.trim()
|
||||
.split(/\s+/)[1];
|
||||
expect(captured.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'tracked.txt',
|
||||
status: 'modified',
|
||||
indexBlobHash: trackedIndexHash,
|
||||
worktreeSha256: createHash('sha256').update('changed before launch\n').digest('hex'),
|
||||
},
|
||||
{
|
||||
path: 'untracked.txt',
|
||||
status: 'untracked',
|
||||
indexBlobHash: null,
|
||||
worktreeSha256: createHash('sha256').update('pre-existing\n').digest('hex'),
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a baseline capture when HEAD changes and then returns a coherent snapshot', async () => {
|
||||
const heads = ['a'.repeat(40), 'b'.repeat(40), ...Array(3).fill('c'.repeat(40))];
|
||||
const git = {
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
revparse: vi.fn(async () => heads.shift() ?? 'c'.repeat(40)),
|
||||
status: vi.fn(async () => cleanStatus()),
|
||||
};
|
||||
const source = new GitCompletionEvidenceSource(
|
||||
() => git as unknown as Pick<SimpleGit, 'raw' | 'revparse' | 'status'>
|
||||
);
|
||||
|
||||
const captured = await source.captureLaunchBaseline('/tmp/unused', createdAt);
|
||||
|
||||
expect(captured.headSha).toBe('c'.repeat(40));
|
||||
expect(git.revparse).toHaveBeenCalledTimes(5);
|
||||
expect(git.status).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('fails closed after bounded attempts when the worktree baseline never stabilizes', async () => {
|
||||
let revision = 0;
|
||||
const git = {
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
revparse: vi.fn(async () => `${revision++}`.padStart(40, 'a')),
|
||||
status: vi.fn(async () => cleanStatus()),
|
||||
};
|
||||
const source = new GitCompletionEvidenceSource(
|
||||
() => git as unknown as Pick<SimpleGit, 'raw' | 'revparse' | 'status'>
|
||||
);
|
||||
|
||||
await expect(source.captureLaunchBaseline('/tmp/unused', createdAt)).rejects.toThrow(
|
||||
/changed while capturing the launch baseline after 3 attempts/
|
||||
);
|
||||
expect(git.revparse).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
function cleanStatus(): StatusResult {
|
||||
return {
|
||||
not_added: [],
|
||||
conflicted: [],
|
||||
created: [],
|
||||
deleted: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
staged: [],
|
||||
files: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
current: 'main',
|
||||
tracking: null,
|
||||
detached: false,
|
||||
isClean: () => true,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { getTaskService } from '../services/task-service.js';
|
|||
import type {
|
||||
AgentType,
|
||||
ProviderRuntimeCapabilityId,
|
||||
TaskCommitPolicy,
|
||||
TokenTelemetryEvent,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
|
|
@ -14,6 +15,7 @@ import { requireLocalAgentCapability } from '../middleware/local-agent-capabilit
|
|||
import { AgentBudgetPolicySchema } from '../schemas/agent-budget-schemas.js';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { ProviderRuntimeCapabilityIdSchema } from '../schemas/provider-runtime-manifest-schemas.js';
|
||||
import { TaskCommitPolicySchema } from '../schemas/task-envelope-schemas.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ const startAgentSchema = z.object({
|
|||
sandboxPresetId: z.string().trim().min(1).max(80).optional(),
|
||||
budget: AgentBudgetPolicySchema.optional(),
|
||||
requiredRuntimeCapabilities: z.array(ProviderRuntimeCapabilityIdSchema).max(64).optional(),
|
||||
commitPolicy: TaskCommitPolicySchema.optional(),
|
||||
});
|
||||
|
||||
const completeAgentSchema = z.object({
|
||||
|
|
@ -68,16 +71,25 @@ router.post(
|
|||
let sandboxPresetId: string | undefined;
|
||||
let budget: z.infer<typeof AgentBudgetPolicySchema> | undefined;
|
||||
let requiredRuntimeCapabilities: ProviderRuntimeCapabilityId[] | undefined;
|
||||
let commitPolicy: TaskCommitPolicy | undefined;
|
||||
try {
|
||||
({ agent, profileId, overrideReason, sandboxPresetId, budget, requiredRuntimeCapabilities } =
|
||||
startAgentSchema.parse(req.body) as {
|
||||
agent?: AgentType;
|
||||
profileId?: string;
|
||||
overrideReason?: string;
|
||||
sandboxPresetId?: string;
|
||||
budget?: z.infer<typeof AgentBudgetPolicySchema>;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
});
|
||||
({
|
||||
agent,
|
||||
profileId,
|
||||
overrideReason,
|
||||
sandboxPresetId,
|
||||
budget,
|
||||
requiredRuntimeCapabilities,
|
||||
commitPolicy,
|
||||
} = startAgentSchema.parse(req.body) as {
|
||||
agent?: AgentType;
|
||||
profileId?: string;
|
||||
overrideReason?: string;
|
||||
sandboxPresetId?: string;
|
||||
budget?: z.infer<typeof AgentBudgetPolicySchema>;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
commitPolicy?: TaskCommitPolicy;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.issues);
|
||||
|
|
@ -92,6 +104,7 @@ router.post(
|
|||
sandboxPresetId,
|
||||
budget,
|
||||
requiredRuntimeCapabilities,
|
||||
commitPolicy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentReadinessError) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { auditLog } from '../services/audit-service.js';
|
|||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { actorFromRequest, assertFreshRevision, setRevisionHeaders } from '../utils/concurrency.js';
|
||||
import type { TaskIdentityDiagnostics } from '../services/task-identity-diagnostics.js';
|
||||
import { TaskExecutionPolicySchema } from '../schemas/task-envelope-schemas.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const taskService = getTaskService();
|
||||
|
|
@ -63,6 +64,7 @@ const createTaskSchema = z.object({
|
|||
project: z.string().optional(),
|
||||
sprint: z.string().optional(),
|
||||
agent: z.string().max(50).optional(), // "auto" | agent type slug
|
||||
executionPolicy: TaskExecutionPolicySchema.optional(),
|
||||
reviewScores: reviewScoresSchema.optional(),
|
||||
reviewComments: z.array(reviewCommentSchema).optional(),
|
||||
});
|
||||
|
|
@ -89,6 +91,9 @@ const attemptSchema = z
|
|||
cloudUrl: z.string().max(500).optional(),
|
||||
cloudTarget: z.string().max(240).optional(),
|
||||
orchestration: z.unknown().optional(),
|
||||
// Authoritative run contracts are written only by launch/finalization services.
|
||||
taskEnvelope: z.never().optional(),
|
||||
completionResult: z.never().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
|
@ -164,6 +169,7 @@ const updateTaskSchema = z.object({
|
|||
project: z.string().optional(),
|
||||
sprint: z.string().optional(),
|
||||
agent: z.string().max(50).optional(),
|
||||
executionPolicy: TaskExecutionPolicySchema.optional(),
|
||||
git: gitSchema,
|
||||
github: githubSchema,
|
||||
attempt: attemptSchema,
|
||||
|
|
@ -681,6 +687,27 @@ router.patch(
|
|||
if (!oldTask) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
const authoritativeAttempt = oldTask.attempt;
|
||||
if (
|
||||
input.attempt &&
|
||||
authoritativeAttempt &&
|
||||
(authoritativeAttempt.taskEnvelope || authoritativeAttempt.completionResult)
|
||||
) {
|
||||
if (input.attempt.id !== authoritativeAttempt.id) {
|
||||
throw new ValidationError(
|
||||
'Generic task updates cannot replace an attempt with an authoritative run contract'
|
||||
);
|
||||
}
|
||||
input.attempt = {
|
||||
...input.attempt,
|
||||
...(authoritativeAttempt.taskEnvelope
|
||||
? { taskEnvelope: authoritativeAttempt.taskEnvelope }
|
||||
: {}),
|
||||
...(authoritativeAttempt.completionResult
|
||||
? { completionResult: authoritativeAttempt.completionResult }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
assertFreshRevision(req, 'task', oldTask.id, oldTask);
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { AgentBudgetPolicySchema } from './agent-budget-schemas.js';
|
||||
import { TaskCommitPolicySchema } from './task-envelope-schemas.js';
|
||||
|
||||
const AgentTypeSchema = z.string().min(1).max(50);
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ export const StartAgentBodySchema = z.object({
|
|||
overrideReason: z.string().trim().min(8).max(1000).optional(),
|
||||
sandboxPresetId: z.string().trim().min(1).max(80).optional(),
|
||||
budget: AgentBudgetPolicySchema.optional(),
|
||||
commitPolicy: TaskCommitPolicySchema.optional(),
|
||||
});
|
||||
|
||||
export type StartAgentBody = z.infer<typeof StartAgentBodySchema>;
|
||||
|
|
|
|||
368
server/src/schemas/task-envelope-schemas.ts
Normal file
368
server/src/schemas/task-envelope-schemas.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
COMPLETION_RESULT_SCHEMA_VERSION,
|
||||
TASK_ARTIFACT_KINDS,
|
||||
TASK_CHANGED_FILE_STATUSES,
|
||||
TASK_COMMIT_POLICIES,
|
||||
TASK_COMPLETION_STATUSES,
|
||||
TASK_CONTINUATION_KINDS,
|
||||
TASK_ENVELOPE_SCHEMA_VERSION,
|
||||
TASK_EVIDENCE_KINDS,
|
||||
TASK_EVIDENCE_SOURCES,
|
||||
TASK_EXPECTED_OUTPUT_KINDS,
|
||||
TASK_SIDE_EFFECT_KINDS,
|
||||
TASK_VERIFICATION_STATUSES,
|
||||
type CompletionResult,
|
||||
type TaskEnvelope,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { verifyTaskEnvelopeDigest } from '../utils/task-envelope-digest.js';
|
||||
|
||||
const digestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
||||
const idSchema = z.string().trim().min(1).max(160);
|
||||
const shortTextSchema = z.string().trim().min(1).max(500);
|
||||
const textSchema = z.string().trim().min(1).max(20_000);
|
||||
const pathSchema = z.string().trim().min(1).max(4096);
|
||||
const isoDateSchema = z.string().datetime();
|
||||
|
||||
export const TaskCommitPolicySchema = z.enum(TASK_COMMIT_POLICIES);
|
||||
|
||||
export const TaskAllowedSideEffectSchema = z
|
||||
.object({
|
||||
kind: z.enum(TASK_SIDE_EFFECT_KINDS),
|
||||
scope: z.string().trim().min(1).max(4096),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TaskExpectedOutputSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
kind: z.enum(TASK_EXPECTED_OUTPUT_KINDS),
|
||||
description: textSchema,
|
||||
required: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TaskExecutionPolicySchema = z
|
||||
.object({
|
||||
commitPolicy: TaskCommitPolicySchema.optional(),
|
||||
allowedSideEffects: z.array(TaskAllowedSideEffectSchema).max(32).optional(),
|
||||
expectedOutputs: z.array(TaskExpectedOutputSchema).max(64).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TaskEnvelopeSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(TASK_ENVELOPE_SCHEMA_VERSION),
|
||||
digest: digestSchema,
|
||||
subject: z
|
||||
.object({
|
||||
id: idSchema,
|
||||
title: z.string().trim().min(1).max(500),
|
||||
objective: textSchema,
|
||||
background: z.array(z.string().trim().min(1).max(4000)).max(64),
|
||||
constraints: z.array(z.string().trim().min(1).max(4000)).max(128),
|
||||
acceptanceCriteria: z.array(z.string().trim().min(1).max(4000)).max(256),
|
||||
})
|
||||
.strict(),
|
||||
attempt: z
|
||||
.object({
|
||||
id: idSchema,
|
||||
createdAt: isoDateSchema,
|
||||
})
|
||||
.strict(),
|
||||
workspace: z
|
||||
.object({
|
||||
workspaceId: idSchema,
|
||||
worktreeId: idSchema,
|
||||
repo: shortTextSchema,
|
||||
branch: shortTextSchema,
|
||||
baseBranch: shortTextSchema,
|
||||
worktreePath: pathSchema,
|
||||
baseline: z
|
||||
.object({
|
||||
capturedAt: isoDateSchema,
|
||||
headSha: z.string().regex(/^[a-f0-9]{40,64}$/),
|
||||
dirty: z.boolean(),
|
||||
files: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
path: pathSchema,
|
||||
status: z.enum(TASK_CHANGED_FILE_STATUSES),
|
||||
indexBlobHash: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{40,64}$/)
|
||||
.nullable(),
|
||||
worktreeSha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.nullable(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(1000),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
commitPolicy: TaskCommitPolicySchema,
|
||||
allowedSideEffects: z.array(TaskAllowedSideEffectSchema).max(32),
|
||||
expectedOutputs: z.array(TaskExpectedOutputSchema).max(64),
|
||||
verificationGates: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idSchema,
|
||||
description: textSchema,
|
||||
required: z.boolean(),
|
||||
evidenceRequired: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(256),
|
||||
launchManifest: z
|
||||
.object({
|
||||
schemaVersion: idSchema,
|
||||
digest: digestSchema,
|
||||
provider: idSchema,
|
||||
adapter: idSchema,
|
||||
protocolVersion: idSchema,
|
||||
})
|
||||
.strict(),
|
||||
completionContract: z
|
||||
.object({
|
||||
schemaVersion: z.literal(COMPLETION_RESULT_SCHEMA_VERSION),
|
||||
evidenceRequirements: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idSchema,
|
||||
kind: z.enum(TASK_EVIDENCE_KINDS),
|
||||
description: textSchema,
|
||||
required: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(128),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((envelope, ctx) => {
|
||||
if (!verifyTaskEnvelopeDigest(envelope)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['digest'],
|
||||
message: 'Task envelope digest does not match the canonical payload',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const CompletionResultSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(COMPLETION_RESULT_SCHEMA_VERSION),
|
||||
taskEnvelopeSchemaVersion: z.literal(TASK_ENVELOPE_SCHEMA_VERSION),
|
||||
taskEnvelopeDigest: digestSchema,
|
||||
taskId: idSchema,
|
||||
attemptId: idSchema,
|
||||
providerRuntimeManifestDigest: digestSchema,
|
||||
status: z.enum(TASK_COMPLETION_STATUSES),
|
||||
summary: textSchema,
|
||||
error: z.string().trim().min(1).max(20_000).nullable(),
|
||||
blockers: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
code: idSchema,
|
||||
summary: shortTextSchema,
|
||||
detail: textSchema,
|
||||
retryable: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(64),
|
||||
evidence: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idSchema,
|
||||
kind: z.enum(TASK_EVIDENCE_KINDS),
|
||||
source: z.enum(TASK_EVIDENCE_SOURCES),
|
||||
summary: textSchema,
|
||||
reference: z.string().trim().min(1).max(4096).nullable(),
|
||||
requirementIds: z.array(idSchema).max(64),
|
||||
verified: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(512),
|
||||
changedFiles: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
path: pathSchema,
|
||||
status: z.enum(TASK_CHANGED_FILE_STATUSES),
|
||||
previousPath: pathSchema.nullable(),
|
||||
verified: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(2000),
|
||||
artifacts: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idSchema,
|
||||
kind: z.enum(TASK_ARTIFACT_KINDS),
|
||||
name: shortTextSchema,
|
||||
reference: z.string().trim().min(1).max(4096),
|
||||
mediaType: z.string().trim().min(1).max(200).nullable(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.nullable(),
|
||||
verified: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(256),
|
||||
verification: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
gateId: idSchema,
|
||||
status: z.enum(TASK_VERIFICATION_STATUSES),
|
||||
summary: textSchema,
|
||||
evidenceIds: z.array(idSchema).max(128),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(256),
|
||||
sideEffects: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
kind: z.enum(TASK_SIDE_EFFECT_KINDS),
|
||||
description: textSchema,
|
||||
target: z.string().trim().min(1).max(4096).nullable(),
|
||||
authorized: z.boolean(),
|
||||
verified: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(256),
|
||||
continuation: z
|
||||
.object({
|
||||
provider: idSchema,
|
||||
kind: z.enum(TASK_CONTINUATION_KINDS),
|
||||
reference: z.string().trim().min(1).max(4096),
|
||||
})
|
||||
.strict()
|
||||
.nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, ctx) => {
|
||||
if (result.status === 'success') {
|
||||
if (result.error !== null || result.blockers.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['status'],
|
||||
message: 'Successful completion cannot include an error or blockers',
|
||||
});
|
||||
}
|
||||
if (!result.evidence.some((item) => item.verified)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['evidence'],
|
||||
message: 'Successful completion requires verified evidence',
|
||||
});
|
||||
}
|
||||
if (result.verification.some((item) => item.status !== 'passed')) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['verification'],
|
||||
message: 'Successful completion cannot include an unpassed verification result',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (result.status === 'blocked' && result.blockers.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['blockers'],
|
||||
message: 'Blocked completion requires at least one blocker',
|
||||
});
|
||||
}
|
||||
if (result.status === 'failed' && result.error === null) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['error'],
|
||||
message: 'Failed completion requires an error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function parseTaskEnvelope(value: unknown): TaskEnvelope {
|
||||
return TaskEnvelopeSchema.parse(value) as TaskEnvelope;
|
||||
}
|
||||
|
||||
export function parseCompletionResult(value: unknown): CompletionResult {
|
||||
return CompletionResultSchema.parse(value) as CompletionResult;
|
||||
}
|
||||
|
||||
export function parseCompletionResultForEnvelope(
|
||||
value: unknown,
|
||||
envelope: TaskEnvelope
|
||||
): CompletionResult {
|
||||
const result = parseCompletionResult(value);
|
||||
const mismatches = [
|
||||
result.taskId === envelope.subject.id || 'task ID',
|
||||
result.attemptId === envelope.attempt.id || 'attempt ID',
|
||||
result.taskEnvelopeDigest === envelope.digest || 'task envelope digest',
|
||||
result.providerRuntimeManifestDigest === envelope.launchManifest.digest || 'manifest digest',
|
||||
].filter((item): item is string => typeof item === 'string');
|
||||
if (mismatches.length > 0) {
|
||||
throw new z.ZodError(
|
||||
mismatches.map((field) => ({
|
||||
code: 'custom' as const,
|
||||
path: [field],
|
||||
message: `Completion result does not match the task envelope ${field}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
const verifiedEvidence = result.evidence.filter((item) => item.verified);
|
||||
const verifiedEvidenceIds = new Set(verifiedEvidence.map((item) => item.id));
|
||||
const missingEvidence = envelope.completionContract.evidenceRequirements.filter(
|
||||
(requirement) =>
|
||||
requirement.required &&
|
||||
!verifiedEvidence.some(
|
||||
(evidence) =>
|
||||
evidence.kind === requirement.kind && evidence.requirementIds.includes(requirement.id)
|
||||
)
|
||||
);
|
||||
const verificationByGate = new Map(result.verification.map((item) => [item.gateId, item]));
|
||||
const missingVerification = envelope.verificationGates.filter((gate) => {
|
||||
if (!gate.required) return false;
|
||||
const verification = verificationByGate.get(gate.id);
|
||||
if (!verification || verification.status !== 'passed') return true;
|
||||
if (!gate.evidenceRequired) return false;
|
||||
return !verification.evidenceIds.some((evidenceId) => verifiedEvidenceIds.has(evidenceId));
|
||||
});
|
||||
if (missingEvidence.length > 0 || missingVerification.length > 0) {
|
||||
throw new z.ZodError([
|
||||
...missingEvidence.map((requirement) => ({
|
||||
code: 'custom' as const,
|
||||
path: ['evidence', requirement.id],
|
||||
message: `Missing verified completion evidence: ${requirement.description}`,
|
||||
})),
|
||||
...missingVerification.map((gate) => ({
|
||||
code: 'custom' as const,
|
||||
path: ['verification', gate.id],
|
||||
message: `Missing passed verification evidence: ${gate.description}`,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -62,6 +62,8 @@ import type {
|
|||
ProviderRuntimeControlAction,
|
||||
ProviderRuntimeControlSet,
|
||||
ProviderRuntimeManifest,
|
||||
TaskCommitPolicy,
|
||||
TaskEnvelope,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { ConflictError } from '../middleware/error-handler.js';
|
||||
|
|
@ -83,6 +85,7 @@ import {
|
|||
BASELINE_LAUNCH_CAPABILITIES,
|
||||
providerRuntimeControls,
|
||||
} from './provider-runtime-control-service.js';
|
||||
import { resolveTaskCommitPolicy, TaskEnvelopeService } from './task-envelope-service.js';
|
||||
const log = createLogger('clawdbot-agent-service');
|
||||
|
||||
const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
|
|
@ -137,6 +140,7 @@ export interface AgentStatus {
|
|||
provider?: ExecutableAgentProvider;
|
||||
model?: string;
|
||||
providerRuntimeManifest: ProviderRuntimeManifest;
|
||||
taskEnvelope: TaskEnvelope;
|
||||
controls: ProviderRuntimeControlSet;
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +156,7 @@ export interface AgentStartOptions {
|
|||
sandboxPresetId?: string;
|
||||
budget?: AgentBudgetPolicy;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
commitPolicy?: TaskCommitPolicy;
|
||||
}
|
||||
|
||||
export interface AgentMessageOptions {
|
||||
|
|
@ -193,6 +198,7 @@ interface PendingAgent {
|
|||
budgetStopped?: boolean;
|
||||
agentProfile?: AgentProfileLaunchMetadata;
|
||||
providerRuntimeManifest: ProviderRuntimeManifest;
|
||||
taskEnvelope: TaskEnvelope;
|
||||
threadId?: string;
|
||||
abortController?: AbortController;
|
||||
process?: ChildProcessWithoutNullStreams;
|
||||
|
|
@ -234,16 +240,19 @@ export class ClawdbotAgentService {
|
|||
private taskService: TaskService;
|
||||
private agentHealth: AgentHealthChecker;
|
||||
private providerRuntimeManifests: ProviderRuntimeManifestService;
|
||||
private taskEnvelopes: TaskEnvelopeService;
|
||||
private logsDir: string;
|
||||
|
||||
constructor(
|
||||
agentHealth?: AgentHealthChecker,
|
||||
providerRuntimeManifests = new ProviderRuntimeManifestService()
|
||||
providerRuntimeManifests = new ProviderRuntimeManifestService(),
|
||||
taskEnvelopes = new TaskEnvelopeService()
|
||||
) {
|
||||
this.configService = new ConfigService();
|
||||
this.taskService = new TaskService();
|
||||
this.agentHealth = agentHealth || new AgentHealthService();
|
||||
this.providerRuntimeManifests = providerRuntimeManifests;
|
||||
this.taskEnvelopes = taskEnvelopes;
|
||||
this.logsDir = getLogsDir();
|
||||
this.ensureLogsDir();
|
||||
}
|
||||
|
|
@ -525,6 +534,23 @@ export class ClawdbotAgentService {
|
|||
const attemptId = `attempt_${nanoid(8)}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
const logPath = path.join(this.logsDir, `${taskId}_${attemptId}.md`);
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const commitPolicy = resolveTaskCommitPolicy({
|
||||
runPolicy: options.commitPolicy,
|
||||
taskPolicy: task.executionPolicy,
|
||||
legacyAutoCommitOnComplete: config.features?.agents.autoCommitOnComplete,
|
||||
});
|
||||
const taskEnvelope = await this.taskEnvelopes.build({
|
||||
task,
|
||||
attemptId,
|
||||
createdAt: startedAt,
|
||||
worktreePath,
|
||||
providerRuntimeManifest,
|
||||
commitPolicy,
|
||||
profileInstructions: profileLaunch?.instructions,
|
||||
networkAccessEnabled: sandboxPolicy.result.effective.networkAccessEnabled,
|
||||
executionPolicy: task.executionPolicy,
|
||||
});
|
||||
|
||||
// Create event emitter for status updates
|
||||
const emitter = new EventEmitter();
|
||||
|
|
@ -540,6 +566,7 @@ export class ClawdbotAgentService {
|
|||
model: launchAgentConfig?.model,
|
||||
agentProfile: profileLaunch?.metadata,
|
||||
providerRuntimeManifest,
|
||||
taskEnvelope,
|
||||
budget: budgetPolicy
|
||||
? {
|
||||
...budgetService.initialState(budgetPolicy),
|
||||
|
|
@ -558,7 +585,6 @@ export class ClawdbotAgentService {
|
|||
validatePathSegment(attemptId);
|
||||
|
||||
// Build the task prompt for Clawdbot
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const taskPrompt = this.buildTaskPrompt(
|
||||
task,
|
||||
worktreePath,
|
||||
|
|
@ -569,7 +595,7 @@ export class ClawdbotAgentService {
|
|||
|
||||
// Initialize log file (ensure it stays within logs dir)
|
||||
ensureWithinBase(this.logsDir, logPath);
|
||||
await this.initLogFile(logPath, task, agent, taskPrompt, providerRuntimeManifest);
|
||||
await this.initLogFile(logPath, task, agent, taskPrompt, providerRuntimeManifest, taskEnvelope);
|
||||
|
||||
// Update task with attempt info
|
||||
const attempt: TaskAttempt = {
|
||||
|
|
@ -582,6 +608,7 @@ export class ClawdbotAgentService {
|
|||
budget: pendingAgents.get(taskId)?.budget,
|
||||
agentProfile: profileLaunch?.metadata,
|
||||
providerRuntimeManifest,
|
||||
taskEnvelope,
|
||||
};
|
||||
|
||||
await this.taskService.updateTask(taskId, {
|
||||
|
|
@ -677,6 +704,7 @@ export class ClawdbotAgentService {
|
|||
provider,
|
||||
model: launchAgentConfig?.model,
|
||||
providerRuntimeManifest,
|
||||
taskEnvelope,
|
||||
controls: providerRuntimeControls(providerRuntimeManifest),
|
||||
};
|
||||
}
|
||||
|
|
@ -830,6 +858,7 @@ export class ClawdbotAgentService {
|
|||
budget: pending.budget,
|
||||
agentProfile: pending.agentProfile,
|
||||
providerRuntimeManifest: pending.providerRuntimeManifest,
|
||||
taskEnvelope: pending.taskEnvelope,
|
||||
};
|
||||
return (pending.preparedCompletion = {
|
||||
status,
|
||||
|
|
@ -2581,6 +2610,7 @@ export class ClawdbotAgentService {
|
|||
provider: pending.provider,
|
||||
model: pending.model,
|
||||
providerRuntimeManifest: pending.providerRuntimeManifest,
|
||||
taskEnvelope: pending.taskEnvelope,
|
||||
controls: providerRuntimeControls(pending.providerRuntimeManifest),
|
||||
};
|
||||
}
|
||||
|
|
@ -2807,7 +2837,8 @@ If you encounter errors, call with \`success: false\` and include the error mess
|
|||
task: Task,
|
||||
agent: AgentType,
|
||||
prompt: string,
|
||||
providerRuntimeManifest: ProviderRuntimeManifest
|
||||
providerRuntimeManifest: ProviderRuntimeManifest,
|
||||
taskEnvelope: TaskEnvelope
|
||||
): Promise<void> {
|
||||
const header = `# Agent Log: ${task.title}
|
||||
|
||||
|
|
@ -2816,6 +2847,7 @@ If you encounter errors, call with \`success: false\` and include the error mess
|
|||
**Started:** ${new Date().toISOString()}
|
||||
**Worktree:** ${task.git?.worktreePath}
|
||||
**Provider manifest:** ${providerRuntimeManifest.digest}
|
||||
**Task envelope:** ${taskEnvelope.digest}
|
||||
|
||||
<details><summary>Provider runtime manifest</summary>
|
||||
|
||||
|
|
@ -2825,6 +2857,14 @@ ${JSON.stringify(providerRuntimeManifest, null, 2)}
|
|||
|
||||
</details>
|
||||
|
||||
<details><summary>Task envelope</summary>
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(taskEnvelope, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
## Task Prompt
|
||||
|
||||
\`\`\`
|
||||
|
|
|
|||
489
server/src/services/task-envelope-service.ts
Normal file
489
server/src/services/task-envelope-service.ts
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git';
|
||||
import {
|
||||
COMPLETION_RESULT_SCHEMA_VERSION,
|
||||
TASK_ENVELOPE_SCHEMA_VERSION,
|
||||
findProviderRuntimeCapability,
|
||||
type ProviderRuntimeManifest,
|
||||
type Task,
|
||||
type TaskAllowedSideEffect,
|
||||
type TaskCommitPolicy,
|
||||
type TaskEnvelope,
|
||||
type TaskEvidenceRequirement,
|
||||
type TaskExecutionPolicy,
|
||||
type TaskExpectedOutput,
|
||||
type TaskLaunchBaseline,
|
||||
type TaskLaunchBaselineFile,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { parseTaskEnvelope } from '../schemas/task-envelope-schemas.js';
|
||||
import {
|
||||
calculateTaskEnvelopeDigest,
|
||||
type TaskEnvelopePayload,
|
||||
} from '../utils/task-envelope-digest.js';
|
||||
import { sha256WorktreeEntry } from '../utils/worktree-fingerprint.js';
|
||||
|
||||
const MAX_BASELINE_FILES = 1000;
|
||||
const BASELINE_GIT_TIMEOUT_MS = 10_000;
|
||||
const MAX_BASELINE_CAPTURE_ATTEMPTS = 3;
|
||||
const INDEX_PATHSPEC_CHUNK_LENGTH = 48_000;
|
||||
const WORKTREE_HASH_CONCURRENCY = 8;
|
||||
|
||||
type BaselineGitClient = Pick<SimpleGit, 'raw' | 'revparse' | 'status'>;
|
||||
type BaselineGitFactory = (worktreePath: string) => BaselineGitClient;
|
||||
|
||||
function createBaselineGit(worktreePath: string): BaselineGitClient {
|
||||
return simpleGit({
|
||||
baseDir: worktreePath,
|
||||
maxConcurrentProcesses: 1,
|
||||
timeout: { block: BASELINE_GIT_TIMEOUT_MS },
|
||||
});
|
||||
}
|
||||
|
||||
export interface CompletionEvidenceSource {
|
||||
captureLaunchBaseline(worktreePath: string, capturedAt: string): Promise<TaskLaunchBaseline>;
|
||||
}
|
||||
|
||||
export class GitCompletionEvidenceSource implements CompletionEvidenceSource {
|
||||
constructor(private readonly gitFactory: BaselineGitFactory = createBaselineGit) {}
|
||||
|
||||
async captureLaunchBaseline(
|
||||
worktreePath: string,
|
||||
capturedAt: string
|
||||
): Promise<TaskLaunchBaseline> {
|
||||
const git = this.gitFactory(worktreePath);
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_BASELINE_CAPTURE_ATTEMPTS; attempt++) {
|
||||
const headBefore = (await git.revparse(['HEAD'])).trim();
|
||||
const statusBefore = await git.status(['--untracked-files=all']);
|
||||
assertBaselineFileLimit(statusBefore);
|
||||
const filesBefore = await captureBaselineFiles(git, worktreePath, statusBefore);
|
||||
|
||||
const headAfter = (await git.revparse(['HEAD'])).trim();
|
||||
const statusAfter = await git.status(['--untracked-files=all']);
|
||||
assertBaselineFileLimit(statusAfter);
|
||||
if (
|
||||
headBefore !== headAfter ||
|
||||
statusSignature(statusBefore) !== statusSignature(statusAfter)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filesAfter = await captureBaselineFiles(git, worktreePath, statusAfter);
|
||||
const headFinal = (await git.revparse(['HEAD'])).trim();
|
||||
const statusFinal = await git.status(['--untracked-files=all']);
|
||||
assertBaselineFileLimit(statusFinal);
|
||||
if (
|
||||
headAfter !== headFinal ||
|
||||
statusSignature(statusAfter) !== statusSignature(statusFinal) ||
|
||||
JSON.stringify(filesBefore) !== JSON.stringify(filesAfter)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
capturedAt,
|
||||
headSha: headFinal,
|
||||
dirty: !statusFinal.isClean(),
|
||||
files: filesAfter,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Task worktree changed while capturing the launch baseline after ${MAX_BASELINE_CAPTURE_ATTEMPTS} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveTaskCommitPolicyInput {
|
||||
runPolicy?: TaskCommitPolicy;
|
||||
taskPolicy?: TaskExecutionPolicy;
|
||||
legacyAutoCommitOnComplete?: boolean;
|
||||
}
|
||||
|
||||
export function resolveTaskCommitPolicy(input: ResolveTaskCommitPolicyInput): TaskCommitPolicy {
|
||||
return (
|
||||
input.runPolicy ??
|
||||
input.taskPolicy?.commitPolicy ??
|
||||
(input.legacyAutoCommitOnComplete ? 'required' : 'allowed')
|
||||
);
|
||||
}
|
||||
|
||||
export interface BuildTaskEnvelopeInput {
|
||||
task: Task;
|
||||
attemptId: string;
|
||||
createdAt: string;
|
||||
worktreePath: string;
|
||||
providerRuntimeManifest: ProviderRuntimeManifest;
|
||||
commitPolicy: TaskCommitPolicy;
|
||||
profileInstructions?: string;
|
||||
networkAccessEnabled?: boolean;
|
||||
executionPolicy?: TaskExecutionPolicy;
|
||||
}
|
||||
|
||||
export class TaskEnvelopeService {
|
||||
constructor(
|
||||
private readonly evidenceSource: CompletionEvidenceSource = new GitCompletionEvidenceSource()
|
||||
) {}
|
||||
|
||||
async build(input: BuildTaskEnvelopeInput): Promise<TaskEnvelope> {
|
||||
const baseline = await this.evidenceSource.captureLaunchBaseline(
|
||||
input.worktreePath,
|
||||
input.createdAt
|
||||
);
|
||||
const payload: TaskEnvelopePayload = {
|
||||
schemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
|
||||
subject: {
|
||||
id: input.task.id,
|
||||
title: input.task.title,
|
||||
objective: input.task.title,
|
||||
background: compactStrings([
|
||||
input.task.description,
|
||||
...(input.task.observations ?? [])
|
||||
.filter(
|
||||
(observation) => observation.type === 'context' || observation.type === 'decision'
|
||||
)
|
||||
.map((observation) => observation.content),
|
||||
]).slice(0, 64),
|
||||
constraints: compactStrings([
|
||||
input.profileInstructions,
|
||||
`Operate only inside the assigned worktree: ${input.worktreePath}`,
|
||||
]).slice(0, 128),
|
||||
acceptanceCriteria: compactStrings(
|
||||
(input.task.subtasks ?? []).flatMap((subtask) => subtask.acceptanceCriteria ?? [])
|
||||
).slice(0, 256),
|
||||
},
|
||||
attempt: {
|
||||
id: input.attemptId,
|
||||
createdAt: input.createdAt,
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: normalizeWorkspaceId(
|
||||
input.task.project?.trim() || input.task.git?.repo || input.task.id
|
||||
),
|
||||
worktreeId: input.task.id,
|
||||
repo: input.task.git?.repo || 'unknown',
|
||||
branch: input.task.git?.branch || 'unknown',
|
||||
baseBranch: input.task.git?.baseBranch || 'unknown',
|
||||
worktreePath: input.worktreePath,
|
||||
baseline,
|
||||
},
|
||||
commitPolicy: input.commitPolicy,
|
||||
allowedSideEffects: buildAllowedSideEffects(input),
|
||||
expectedOutputs: buildExpectedOutputs(input),
|
||||
verificationGates: (input.task.verificationSteps ?? []).slice(0, 256).map((step) => ({
|
||||
id: step.id,
|
||||
description: step.description,
|
||||
required: true,
|
||||
evidenceRequired: true,
|
||||
})),
|
||||
launchManifest: {
|
||||
schemaVersion: input.providerRuntimeManifest.schemaVersion,
|
||||
digest: input.providerRuntimeManifest.digest,
|
||||
provider: input.providerRuntimeManifest.provider,
|
||||
adapter: input.providerRuntimeManifest.adapter,
|
||||
protocolVersion: input.providerRuntimeManifest.protocolVersion,
|
||||
},
|
||||
completionContract: {
|
||||
schemaVersion: COMPLETION_RESULT_SCHEMA_VERSION,
|
||||
evidenceRequirements: buildEvidenceRequirements(input),
|
||||
},
|
||||
};
|
||||
const envelope = parseTaskEnvelope({
|
||||
...payload,
|
||||
digest: calculateTaskEnvelopeDigest(payload),
|
||||
});
|
||||
return immutableClone(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAllowedSideEffects(input: BuildTaskEnvelopeInput): TaskAllowedSideEffect[] {
|
||||
const defaults: TaskAllowedSideEffect[] = [
|
||||
{ kind: 'filesystem-write', scope: input.worktreePath },
|
||||
{ kind: 'process-execute', scope: input.worktreePath },
|
||||
];
|
||||
if (input.commitPolicy !== 'forbidden') {
|
||||
defaults.push({ kind: 'git-commit', scope: input.worktreePath });
|
||||
}
|
||||
if (input.networkAccessEnabled) {
|
||||
defaults.push({ kind: 'network-egress', scope: 'sandbox policy' });
|
||||
}
|
||||
const artifactCapability = findProviderRuntimeCapability(
|
||||
input.providerRuntimeManifest,
|
||||
'artifact.write'
|
||||
);
|
||||
if (artifactCapability?.state === 'supported') {
|
||||
defaults.push({ kind: 'artifact-write', scope: input.worktreePath });
|
||||
}
|
||||
|
||||
const requested = input.executionPolicy?.allowedSideEffects ?? defaults;
|
||||
const effective = uniqueBy(
|
||||
requested.flatMap((sideEffect) =>
|
||||
defaults
|
||||
.filter((sandboxEffect) => sandboxEffect.kind === sideEffect.kind)
|
||||
.flatMap((sandboxEffect) => {
|
||||
const scope = clampSideEffectScope(sideEffect, sandboxEffect);
|
||||
return scope ? [{ ...sideEffect, scope }] : [];
|
||||
})
|
||||
),
|
||||
(sideEffect) => `${sideEffect.kind}:${sideEffect.scope}`
|
||||
).slice(0, 32);
|
||||
if (
|
||||
input.commitPolicy === 'required' &&
|
||||
!effective.some((sideEffect) => sideEffect.kind === 'git-commit')
|
||||
) {
|
||||
throw new Error('Required commit policy must authorize the git-commit side effect');
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
function buildExpectedOutputs(input: BuildTaskEnvelopeInput): TaskExpectedOutput[] {
|
||||
const configured = input.executionPolicy?.expectedOutputs;
|
||||
const outputs: TaskExpectedOutput[] = [...(configured ?? [])];
|
||||
if (!outputs.some((output) => output.id === 'completion-summary')) {
|
||||
outputs.unshift({
|
||||
id: 'completion-summary',
|
||||
kind: 'text',
|
||||
description: 'A concise summary of the completed work and remaining risks.',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.commitPolicy === 'required' &&
|
||||
!outputs.some((output) => output.kind === 'commit' && output.required)
|
||||
) {
|
||||
outputs.push({
|
||||
id: 'git-commit',
|
||||
kind: 'commit',
|
||||
description: 'At least one new commit attributable to this attempt.',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.commitPolicy === 'forbidden' &&
|
||||
outputs.some((output) => output.kind === 'commit' && output.required)
|
||||
) {
|
||||
throw new Error('Forbidden commit policy cannot require a commit output');
|
||||
}
|
||||
for (const deliverable of input.task.deliverables ?? []) {
|
||||
outputs.push({
|
||||
id: `deliverable-${deliverable.id}`,
|
||||
kind: deliverable.path ? 'file' : 'artifact',
|
||||
description: deliverable.title,
|
||||
required: deliverable.status !== 'accepted',
|
||||
});
|
||||
}
|
||||
return uniqueBy(outputs, (output) => output.id).slice(0, 64);
|
||||
}
|
||||
|
||||
function buildEvidenceRequirements(input: BuildTaskEnvelopeInput): TaskEvidenceRequirement[] {
|
||||
const requirements: TaskEvidenceRequirement[] = [
|
||||
{
|
||||
id: 'terminal-state',
|
||||
kind: 'provider-output',
|
||||
description: 'Harness-verified provider terminal state from the native transport.',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
if ((input.task.verificationSteps?.length ?? 0) > 0) {
|
||||
requirements.push({
|
||||
id: 'verification',
|
||||
kind: 'verification',
|
||||
description: 'Harness-verified evidence for every required verification gate.',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
if (input.commitPolicy === 'required') {
|
||||
requirements.push({
|
||||
id: 'commit',
|
||||
kind: 'commit',
|
||||
description: 'Harness-verified commit created after the launch baseline.',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
return requirements;
|
||||
}
|
||||
|
||||
function mapBaselineFile(
|
||||
file: StatusResult['files'][number],
|
||||
indexBlobHash: string | null,
|
||||
worktreeSha256: string | null
|
||||
): TaskLaunchBaselineFile {
|
||||
const code = `${file.index}${file.working_dir}`;
|
||||
let status: TaskLaunchBaselineFile['status'] = 'modified';
|
||||
if (code.includes('?')) status = 'untracked';
|
||||
else if (code.includes('R')) status = 'renamed';
|
||||
else if (code.includes('D')) status = 'deleted';
|
||||
else if (code.includes('A')) status = 'added';
|
||||
return { path: file.path, status, indexBlobHash, worktreeSha256 };
|
||||
}
|
||||
|
||||
function assertBaselineFileLimit(status: StatusResult): void {
|
||||
if (status.files.length > MAX_BASELINE_FILES) {
|
||||
throw new Error(
|
||||
`Task worktree baseline has ${status.files.length} changed files; maximum is ${MAX_BASELINE_FILES}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function statusSignature(status: StatusResult): string {
|
||||
return JSON.stringify({
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
current: status.current,
|
||||
detached: status.detached,
|
||||
tracking: status.tracking,
|
||||
files: status.files
|
||||
.map((file) => ({
|
||||
from: file.from ?? null,
|
||||
path: file.path,
|
||||
index: file.index,
|
||||
workingDir: file.working_dir,
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
`${left.path}\0${left.from ?? ''}`.localeCompare(`${right.path}\0${right.from ?? ''}`)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function captureBaselineFiles(
|
||||
git: BaselineGitClient,
|
||||
worktreePath: string,
|
||||
status: StatusResult
|
||||
): Promise<TaskLaunchBaselineFile[]> {
|
||||
const statusFiles = [...status.files].sort((left, right) =>
|
||||
`${left.path}\0${left.from ?? ''}`.localeCompare(`${right.path}\0${right.from ?? ''}`)
|
||||
);
|
||||
const indexBlobHashes = await readIndexBlobHashes(
|
||||
git,
|
||||
statusFiles.map((file) => file.path)
|
||||
);
|
||||
const result: TaskLaunchBaselineFile[] = [];
|
||||
|
||||
for (let offset = 0; offset < statusFiles.length; offset += WORKTREE_HASH_CONCURRENCY) {
|
||||
const batch = statusFiles.slice(offset, offset + WORKTREE_HASH_CONCURRENCY);
|
||||
result.push(
|
||||
...(await Promise.all(
|
||||
batch.map(async (file) =>
|
||||
mapBaselineFile(
|
||||
file,
|
||||
indexBlobHashes.get(file.path) ?? null,
|
||||
await sha256WorktreeEntry(worktreePath, file.path)
|
||||
)
|
||||
)
|
||||
))
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readIndexBlobHashes(
|
||||
git: BaselineGitClient,
|
||||
paths: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const hashes = new Map<string, string>();
|
||||
for (const pathChunk of chunkPathspecs(paths)) {
|
||||
const output = await git.raw([
|
||||
'ls-files',
|
||||
'--stage',
|
||||
'-z',
|
||||
'--',
|
||||
...pathChunk.map((filePath) => `:(literal)${filePath}`),
|
||||
]);
|
||||
for (const record of output.split('\0')) {
|
||||
if (!record) continue;
|
||||
const tabIndex = record.indexOf('\t');
|
||||
if (tabIndex < 0) continue;
|
||||
const [mode, objectId, stage] = record.slice(0, tabIndex).split(/\s+/);
|
||||
const filePath = record.slice(tabIndex + 1);
|
||||
if (!mode || !/^[a-f0-9]{40,64}$/.test(objectId ?? '') || !stage || !filePath) continue;
|
||||
if (stage === '0' || !hashes.has(filePath)) hashes.set(filePath, objectId);
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
function chunkPathspecs(paths: string[]): string[][] {
|
||||
const chunks: string[][] = [];
|
||||
let chunk: string[] = [];
|
||||
let length = 0;
|
||||
for (const filePath of paths) {
|
||||
if (chunk.length > 0 && length + filePath.length + 1 > INDEX_PATHSPEC_CHUNK_LENGTH) {
|
||||
chunks.push(chunk);
|
||||
chunk = [];
|
||||
length = 0;
|
||||
}
|
||||
chunk.push(filePath);
|
||||
length += filePath.length + 1;
|
||||
}
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const PATH_SCOPED_SIDE_EFFECTS = new Set<TaskAllowedSideEffect['kind']>([
|
||||
'filesystem-write',
|
||||
'process-execute',
|
||||
'git-commit',
|
||||
'artifact-write',
|
||||
]);
|
||||
|
||||
function clampSideEffectScope(
|
||||
requested: TaskAllowedSideEffect,
|
||||
sandboxEffect: TaskAllowedSideEffect
|
||||
): string | null {
|
||||
if (!PATH_SCOPED_SIDE_EFFECTS.has(requested.kind)) return requested.scope;
|
||||
|
||||
const sandboxRoot = path.resolve(sandboxEffect.scope);
|
||||
const requestedPath = path.resolve(sandboxRoot, requested.scope);
|
||||
if (isPathWithin(sandboxRoot, requestedPath)) return requestedPath;
|
||||
if (isPathWithin(requestedPath, sandboxRoot)) return sandboxRoot;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathWithin(basePath: string, targetPath: string): boolean {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceId(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length <= 160) return normalized;
|
||||
const suffix = createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
||||
return `${normalized.slice(0, 143).trimEnd()}~${suffix}`;
|
||||
}
|
||||
|
||||
function compactStrings(values: Array<string | undefined>): string[] {
|
||||
return values.flatMap((value) => {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) return [];
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < normalized.length; offset += 4000) {
|
||||
chunks.push(normalized.slice(offset, offset + 4000));
|
||||
}
|
||||
return chunks;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueBy<T>(values: T[], key: (value: T) => string): T[] {
|
||||
const seen = new Set<string>();
|
||||
return values.filter((value) => {
|
||||
const valueKey = key(value);
|
||||
if (seen.has(valueKey)) return false;
|
||||
seen.add(valueKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function immutableClone<T>(value: T): T {
|
||||
return deepFreeze(structuredClone(value));
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
|
|
@ -656,6 +656,7 @@ export class TaskService {
|
|||
sprint: data.sprint,
|
||||
agent: data.agent,
|
||||
agents: data.agents,
|
||||
executionPolicy: data.executionPolicy,
|
||||
created: data.created || new Date().toISOString(),
|
||||
updated: data.updated || new Date().toISOString(),
|
||||
git: data.git,
|
||||
|
|
@ -837,6 +838,7 @@ export class TaskService {
|
|||
project: input.project,
|
||||
sprint: input.sprint,
|
||||
agent: input.agent, // Pre-assigned agent (or "auto" for routing)
|
||||
executionPolicy: input.executionPolicy,
|
||||
subtasks: input.subtasks, // Include subtasks from template
|
||||
blockedBy: input.blockedBy, // Include dependencies from blueprint
|
||||
created: now,
|
||||
|
|
|
|||
13
server/src/utils/task-envelope-digest.ts
Normal file
13
server/src/utils/task-envelope-digest.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import type { TaskEnvelope } from '@veritas-kanban/shared';
|
||||
|
||||
export type TaskEnvelopePayload = Omit<TaskEnvelope, 'digest'>;
|
||||
|
||||
export function calculateTaskEnvelopeDigest(envelope: TaskEnvelopePayload): string {
|
||||
return `sha256:${createHash('sha256').update(JSON.stringify(envelope)).digest('hex')}`;
|
||||
}
|
||||
|
||||
export function verifyTaskEnvelopeDigest(envelope: TaskEnvelope): boolean {
|
||||
const { digest: _digest, ...payload } = envelope;
|
||||
return envelope.digest === calculateTaskEnvelopeDigest(payload);
|
||||
}
|
||||
45
server/src/utils/worktree-fingerprint.ts
Normal file
45
server/src/utils/worktree-fingerprint.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, readlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ensureWithinBase } from './sanitize.js';
|
||||
|
||||
function isMissingOrRaced(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
return code === 'ENOENT' || code === 'ELOOP' || code === 'ENOTDIR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash one worktree entry without following symlinks outside the worktree.
|
||||
* Symlinks are hashed as their link-target text, matching Git's blob semantics.
|
||||
*/
|
||||
export async function sha256WorktreeEntry(
|
||||
worktreePath: string,
|
||||
relativePath: string
|
||||
): Promise<string | null> {
|
||||
const absolutePath = ensureWithinBase(worktreePath, path.resolve(worktreePath, relativePath));
|
||||
|
||||
try {
|
||||
const stat = await lstat(absolutePath);
|
||||
const hash = createHash('sha256');
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
return hash.update(await readlink(absolutePath), 'utf8').digest('hex');
|
||||
}
|
||||
if (!stat.isFile()) return null;
|
||||
|
||||
const handle = await open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const openedStat = await handle.stat();
|
||||
if (!openedStat.isFile()) return null;
|
||||
const stream = handle.createReadStream({ autoClose: false });
|
||||
for await (const chunk of stream) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingOrRaced(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,3 +47,4 @@ export * from './queue-monitor.types.js';
|
|||
export * from './watcher-policy.types.js';
|
||||
export * from './evidence.types.js';
|
||||
export * from './time-breakdown.types.js';
|
||||
export * from './task-envelope.types.js';
|
||||
|
|
|
|||
238
shared/src/types/task-envelope.types.ts
Normal file
238
shared/src/types/task-envelope.types.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
export const TASK_ENVELOPE_SCHEMA_VERSION = 'task-envelope/v1' as const;
|
||||
export const COMPLETION_RESULT_SCHEMA_VERSION = 'completion-result/v1' as const;
|
||||
|
||||
export const TASK_COMMIT_POLICIES = ['forbidden', 'allowed', 'required'] as const;
|
||||
export type TaskCommitPolicy = (typeof TASK_COMMIT_POLICIES)[number];
|
||||
|
||||
/** Optional task-level defaults. Run-level values take precedence at launch. */
|
||||
export interface TaskExecutionPolicy {
|
||||
commitPolicy?: TaskCommitPolicy;
|
||||
allowedSideEffects?: TaskAllowedSideEffect[];
|
||||
expectedOutputs?: TaskExpectedOutput[];
|
||||
}
|
||||
|
||||
export const TASK_COMPLETION_STATUSES = [
|
||||
'success',
|
||||
'blocked',
|
||||
'failed',
|
||||
'interrupted',
|
||||
'partial',
|
||||
] as const;
|
||||
export type TaskCompletionStatus = (typeof TASK_COMPLETION_STATUSES)[number];
|
||||
|
||||
export const TASK_SIDE_EFFECT_KINDS = [
|
||||
'filesystem-write',
|
||||
'process-execute',
|
||||
'network-egress',
|
||||
'git-commit',
|
||||
'external-write',
|
||||
'artifact-write',
|
||||
'task-mutate',
|
||||
] as const;
|
||||
export type TaskSideEffectKind = (typeof TASK_SIDE_EFFECT_KINDS)[number];
|
||||
|
||||
export const TASK_EXPECTED_OUTPUT_KINDS = ['text', 'file', 'artifact', 'commit', 'other'] as const;
|
||||
export type TaskExpectedOutputKind = (typeof TASK_EXPECTED_OUTPUT_KINDS)[number];
|
||||
|
||||
export const TASK_EVIDENCE_KINDS = [
|
||||
'provider-output',
|
||||
'process-exit',
|
||||
'stream-event',
|
||||
'callback',
|
||||
'verification',
|
||||
'file-change',
|
||||
'artifact',
|
||||
'commit',
|
||||
'other',
|
||||
] as const;
|
||||
export type TaskEvidenceKind = (typeof TASK_EVIDENCE_KINDS)[number];
|
||||
|
||||
export const TASK_EVIDENCE_SOURCES = ['provider', 'harness'] as const;
|
||||
export type TaskEvidenceSource = (typeof TASK_EVIDENCE_SOURCES)[number];
|
||||
|
||||
export const TASK_VERIFICATION_STATUSES = ['passed', 'failed', 'skipped', 'unknown'] as const;
|
||||
export type TaskVerificationStatus = (typeof TASK_VERIFICATION_STATUSES)[number];
|
||||
|
||||
export const TASK_CHANGED_FILE_STATUSES = [
|
||||
'added',
|
||||
'modified',
|
||||
'deleted',
|
||||
'renamed',
|
||||
'untracked',
|
||||
] as const;
|
||||
export type TaskChangedFileStatus = (typeof TASK_CHANGED_FILE_STATUSES)[number];
|
||||
|
||||
export const TASK_ARTIFACT_KINDS = ['file', 'log', 'report', 'url', 'other'] as const;
|
||||
export type TaskArtifactKind = (typeof TASK_ARTIFACT_KINDS)[number];
|
||||
|
||||
export const TASK_CONTINUATION_KINDS = ['thread', 'session', 'run', 'other'] as const;
|
||||
export type TaskContinuationKind = (typeof TASK_CONTINUATION_KINDS)[number];
|
||||
|
||||
export interface TaskLaunchBaselineFile {
|
||||
path: string;
|
||||
status: TaskChangedFileStatus;
|
||||
/** Git object ID for the staged index entry, or null when no index entry exists. */
|
||||
indexBlobHash: string | null;
|
||||
/** SHA-256 of the worktree entry bytes, or null when the entry does not exist/is not a file. */
|
||||
worktreeSha256: string | null;
|
||||
}
|
||||
|
||||
export interface TaskLaunchBaseline {
|
||||
capturedAt: string;
|
||||
headSha: string;
|
||||
dirty: boolean;
|
||||
files: TaskLaunchBaselineFile[];
|
||||
}
|
||||
|
||||
export interface TaskEnvelopeSubject {
|
||||
id: string;
|
||||
title: string;
|
||||
objective: string;
|
||||
background: string[];
|
||||
constraints: string[];
|
||||
acceptanceCriteria: string[];
|
||||
}
|
||||
|
||||
export interface TaskEnvelopeAttempt {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TaskEnvelopeWorkspace {
|
||||
workspaceId: string;
|
||||
worktreeId: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
worktreePath: string;
|
||||
baseline: TaskLaunchBaseline;
|
||||
}
|
||||
|
||||
export interface TaskAllowedSideEffect {
|
||||
kind: TaskSideEffectKind;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface TaskExpectedOutput {
|
||||
id: string;
|
||||
kind: TaskExpectedOutputKind;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface TaskVerificationGate {
|
||||
id: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
evidenceRequired: boolean;
|
||||
}
|
||||
|
||||
export interface TaskEvidenceRequirement {
|
||||
id: string;
|
||||
kind: TaskEvidenceKind;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface TaskLaunchManifestReference {
|
||||
schemaVersion: string;
|
||||
digest: string;
|
||||
provider: string;
|
||||
adapter: string;
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
export interface TaskCompletionContract {
|
||||
schemaVersion: typeof COMPLETION_RESULT_SCHEMA_VERSION;
|
||||
evidenceRequirements: TaskEvidenceRequirement[];
|
||||
}
|
||||
|
||||
/** Immutable, provider-neutral input captured before an agent run starts. */
|
||||
export interface TaskEnvelope {
|
||||
schemaVersion: typeof TASK_ENVELOPE_SCHEMA_VERSION;
|
||||
digest: string;
|
||||
subject: TaskEnvelopeSubject;
|
||||
attempt: TaskEnvelopeAttempt;
|
||||
workspace: TaskEnvelopeWorkspace;
|
||||
commitPolicy: TaskCommitPolicy;
|
||||
allowedSideEffects: TaskAllowedSideEffect[];
|
||||
expectedOutputs: TaskExpectedOutput[];
|
||||
verificationGates: TaskVerificationGate[];
|
||||
launchManifest: TaskLaunchManifestReference;
|
||||
completionContract: TaskCompletionContract;
|
||||
}
|
||||
|
||||
export interface TaskCompletionBlocker {
|
||||
code: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export interface TaskCompletionEvidence {
|
||||
id: string;
|
||||
kind: TaskEvidenceKind;
|
||||
source: TaskEvidenceSource;
|
||||
summary: string;
|
||||
reference: string | null;
|
||||
requirementIds: string[];
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface TaskCompletionVerification {
|
||||
gateId: string;
|
||||
status: TaskVerificationStatus;
|
||||
summary: string;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface TaskCompletionChangedFile {
|
||||
path: string;
|
||||
status: TaskChangedFileStatus;
|
||||
previousPath: string | null;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface TaskCompletionArtifact {
|
||||
id: string;
|
||||
kind: TaskArtifactKind;
|
||||
name: string;
|
||||
reference: string;
|
||||
mediaType: string | null;
|
||||
sha256: string | null;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface TaskCompletionSideEffect {
|
||||
kind: TaskSideEffectKind;
|
||||
description: string;
|
||||
target: string | null;
|
||||
authorized: boolean;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface TaskContinuationHandle {
|
||||
provider: string;
|
||||
kind: TaskContinuationKind;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
/** Normalized terminal result returned by every provider transport. */
|
||||
export interface CompletionResult {
|
||||
schemaVersion: typeof COMPLETION_RESULT_SCHEMA_VERSION;
|
||||
taskEnvelopeSchemaVersion: typeof TASK_ENVELOPE_SCHEMA_VERSION;
|
||||
taskEnvelopeDigest: string;
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
providerRuntimeManifestDigest: string;
|
||||
status: TaskCompletionStatus;
|
||||
summary: string;
|
||||
error: string | null;
|
||||
blockers: TaskCompletionBlocker[];
|
||||
evidence: TaskCompletionEvidence[];
|
||||
changedFiles: TaskCompletionChangedFile[];
|
||||
artifacts: TaskCompletionArtifact[];
|
||||
verification: TaskCompletionVerification[];
|
||||
sideEffects: TaskCompletionSideEffect[];
|
||||
continuation: TaskContinuationHandle | null;
|
||||
}
|
||||
|
|
@ -62,6 +62,8 @@ export interface TaskAttempt {
|
|||
budget?: import('./agent-budget.types.js').AgentBudgetState;
|
||||
agentProfile?: import('./agent-profile-package.types.js').AgentProfileLaunchMetadata;
|
||||
providerRuntimeManifest?: import('./provider-runtime.types.js').ProviderRuntimeManifest;
|
||||
taskEnvelope?: import('./task-envelope.types.js').TaskEnvelope;
|
||||
completionResult?: import('./task-envelope.types.js').CompletionResult;
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
|
|
@ -234,6 +236,8 @@ export interface Task {
|
|||
agent?: AgentType | 'auto';
|
||||
// Multi-agent assignment — multiple agents collaborating on a task
|
||||
agents?: AgentType[];
|
||||
// Provider-neutral run policy defaults. Launch-time overrides take precedence.
|
||||
executionPolicy?: import('./task-envelope.types.js').TaskExecutionPolicy;
|
||||
|
||||
// Code task specific
|
||||
git?: TaskGit;
|
||||
|
|
@ -377,6 +381,7 @@ export interface CreateTaskInput {
|
|||
updatedBy?: string;
|
||||
agent?: AgentType | 'auto'; // Pre-assign an agent (or "auto" for routing engine)
|
||||
agents?: AgentType[]; // Multi-agent assignment
|
||||
executionPolicy?: import('./task-envelope.types.js').TaskExecutionPolicy;
|
||||
subtasks?: Subtask[]; // Can be provided when creating from a template
|
||||
blockedBy?: string[]; // Can be provided when creating from a blueprint
|
||||
reviewScores?: [number, number, number, number]; // Optional 4x10 scores
|
||||
|
|
@ -395,6 +400,7 @@ export interface UpdateTaskInput {
|
|||
updatedBy?: string;
|
||||
agent?: AgentType | 'auto';
|
||||
agents?: AgentType[];
|
||||
executionPolicy?: import('./task-envelope.types.js').TaskExecutionPolicy;
|
||||
git?: Partial<TaskGit>;
|
||||
github?: TaskGitHub;
|
||||
delegatedWork?: import('./workspace-capability.types.js').TaskDelegatedWorkLink[];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
AgentHostPreviewRequest,
|
||||
AgentType,
|
||||
ProviderRuntimeCapabilityId,
|
||||
TaskCommitPolicy,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export interface StartAgentInput {
|
||||
|
|
@ -19,6 +20,7 @@ export interface StartAgentInput {
|
|||
sandboxPresetId?: string;
|
||||
budget?: AgentBudgetPolicy;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
commitPolicy?: TaskCommitPolicy;
|
||||
}
|
||||
|
||||
export interface AgentApprovalRequest {
|
||||
|
|
@ -69,6 +71,7 @@ export function useStartAgent() {
|
|||
sandboxPresetId,
|
||||
budget,
|
||||
requiredRuntimeCapabilities,
|
||||
commitPolicy,
|
||||
}: StartAgentInput) =>
|
||||
api.agent.start(taskId, {
|
||||
agent,
|
||||
|
|
@ -77,6 +80,7 @@ export function useStartAgent() {
|
|||
sandboxPresetId,
|
||||
budget,
|
||||
requiredRuntimeCapabilities,
|
||||
commitPolicy,
|
||||
}),
|
||||
onSuccess: (_, { taskId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] });
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
ProviderRuntimeManifest,
|
||||
ProviderRuntimeCapabilityId,
|
||||
ProviderRuntimeControlSet,
|
||||
TaskCommitPolicy,
|
||||
TaskEnvelope,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { API_BASE, apiFetch } from './helpers';
|
||||
|
||||
|
|
@ -22,6 +24,7 @@ export interface StartAgentRequest {
|
|||
sandboxPresetId?: string;
|
||||
budget?: AgentBudgetPolicy;
|
||||
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
|
||||
commitPolicy?: TaskCommitPolicy;
|
||||
}
|
||||
|
||||
export const worktreeApi = {
|
||||
|
|
@ -250,6 +253,7 @@ export interface AgentStatus {
|
|||
provider?: string;
|
||||
model?: string;
|
||||
providerRuntimeManifest: ProviderRuntimeManifest;
|
||||
taskEnvelope: TaskEnvelope;
|
||||
controls: ProviderRuntimeControlSet;
|
||||
}
|
||||
|
||||
|
|
@ -263,6 +267,7 @@ export interface AgentStatusResponse {
|
|||
provider?: string;
|
||||
model?: string;
|
||||
providerRuntimeManifest?: ProviderRuntimeManifest;
|
||||
taskEnvelope?: TaskEnvelope;
|
||||
controls?: ProviderRuntimeControlSet;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue