mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Add shared live run sessions (#731)
This commit is contained in:
parent
43c21b71a4
commit
ca2dd7ef23
28 changed files with 2628 additions and 33 deletions
|
|
@ -148,7 +148,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
|
||||
### 🤖 Agent Orchestration
|
||||
|
||||
Spawn autonomous coding agents on tasks when you choose to connect an agent runner. Track them in real-time with the multi-agent dashboard — status indicators, expandable agent cards, model attribution. Squad Chat gives agents a shared local communication channel with system lifecycle events (spawned, completed, failed). Assign multiple agents per task, set permission levels (Intern/Specialist/Lead), and let them coordinate.
|
||||
Spawn autonomous coding agents on tasks when you choose to connect an agent runner. Track them in real-time with the multi-agent dashboard — status indicators, expandable agent cards, model attribution. Shared live run sessions let workspace members observe an active task run, co-drive with attributed messages, or fork a clean follow-up task without taking over the parent run. Squad Chat gives agents a shared local communication channel with system lifecycle events (spawned, completed, failed). Assign multiple agents per task, set permission levels (Intern/Specialist/Lead), and let them coordinate.
|
||||
|
||||

|
||||
|
||||
|
|
@ -225,6 +225,7 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Local LLM provider profiles** — Optional Ollama Local, Ollama Cloud, and LM Studio Local profiles with health metadata and routing support
|
||||
- **Agent profile packages** — Portable YAML/JSON packages that bundle role, runtime, prompt, tools, permissions, sandbox, budget, workflow, and health metadata for reusable launches
|
||||
- **Decision review sessions** — Multi-participant decision reviews with independent responses, critique rounds, final synthesis packets, work-product attachment, and decision audit links
|
||||
- **Shared live run sessions** — Create workspace-scoped view, co-drive, or fork links for active task runs; viewers receive live output and events, editors send attributed messages and mobile-safe approval responses, and forks create linked tasks without mutating the parent run
|
||||
- **Sandbox policy presets** — Built-in and custom presets for filesystem scope, network egress, environment passthrough, and credential brokering, with Settings dry-runs before agent launch
|
||||
- **Agent budget enforcement** — Workspace, agent, workflow, workflow-agent, and per-run caps for tokens, cost, tool calls, runtime, retries, and fan-out with auditable warn, approval, downgrade, pause, or cancel decisions
|
||||
- **Optional OpenClaw support** — Native integration with [OpenClaw](https://github.com/openclaw/openclaw) when you want OpenClaw to execute or wake agents
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Veritas Kanban — API Reference
|
||||
|
||||
**Version**: 3.4.0
|
||||
**Last Updated**: 2026-03-08
|
||||
**Base URL**: `http://localhost:3001/api`
|
||||
**Version**: 5.1.0
|
||||
**Last Updated**: 2026-06-18
|
||||
**Base URL**: `http://localhost:3001/api`
|
||||
**Canonical prefix**: `/api/v1` (alias: `/api`)
|
||||
|
||||
> This is the source-of-truth companion to the Swagger/OpenAPI spec. For workflow-engine-specific endpoints, see [API-WORKFLOWS.md](API-WORKFLOWS.md).
|
||||
|
|
@ -28,31 +28,32 @@
|
|||
15. [Telemetry](#telemetry)
|
||||
16. [Health](#health)
|
||||
17. [WebSocket](#websocket)
|
||||
18. [Task Verification](#task-verification)
|
||||
19. [Task Comments](#task-comments)
|
||||
20. [Task Subtasks](#task-subtasks)
|
||||
21. [Task Deliverables](#task-deliverables)
|
||||
22. [Task Archive](#task-archive)
|
||||
23. [Attachments](#attachments)
|
||||
24. [Agent Permissions](#agent-permissions)
|
||||
25. [Agent Routing](#agent-routing)
|
||||
26. [Sandbox Policies](#sandbox-policies)
|
||||
27. [Shared Resources](#shared-resources)
|
||||
28. [Skill Capability Profiles](#skill-capability-profiles-apiskillscapabilities)
|
||||
29. [Skill Security Scanner](#skill-security-scanner-apiskillssecurity)
|
||||
30. [Doc Freshness](#doc-freshness)
|
||||
31. [Cost Prediction](#cost-prediction)
|
||||
32. [Error Learning](#error-learning)
|
||||
33. [Tool Policies](#tool-policies)
|
||||
34. [Watcher Continuation Policies](#watcher-continuation-policies)
|
||||
35. [Traces](#traces)
|
||||
36. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
37. [Audit](#audit)
|
||||
38. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
39. [Common Workflows](#common-workflows)
|
||||
40. [Versioning & Deprecation](#versioning--deprecation)
|
||||
41. [Rate Limits](#rate-limits)
|
||||
42. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
18. [Shared Run Sessions](#shared-run-sessions)
|
||||
19. [Task Verification](#task-verification)
|
||||
20. [Task Comments](#task-comments)
|
||||
21. [Task Subtasks](#task-subtasks)
|
||||
22. [Task Deliverables](#task-deliverables)
|
||||
23. [Task Archive](#task-archive)
|
||||
24. [Attachments](#attachments)
|
||||
25. [Agent Permissions](#agent-permissions)
|
||||
26. [Agent Routing](#agent-routing)
|
||||
27. [Sandbox Policies](#sandbox-policies)
|
||||
28. [Shared Resources](#shared-resources)
|
||||
29. [Skill Capability Profiles](#skill-capability-profiles-apiskillscapabilities)
|
||||
30. [Skill Security Scanner](#skill-security-scanner-apiskillssecurity)
|
||||
31. [Doc Freshness](#doc-freshness)
|
||||
32. [Cost Prediction](#cost-prediction)
|
||||
33. [Error Learning](#error-learning)
|
||||
34. [Tool Policies](#tool-policies)
|
||||
35. [Watcher Continuation Policies](#watcher-continuation-policies)
|
||||
36. [Traces](#traces)
|
||||
37. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
38. [Audit](#audit)
|
||||
39. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
40. [Common Workflows](#common-workflows)
|
||||
41. [Versioning & Deprecation](#versioning--deprecation)
|
||||
42. [Rate Limits](#rate-limits)
|
||||
43. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -894,6 +895,15 @@ or `X-API-Key`. In production, do not rely on localhost bypass.
|
|||
{ "type": "chat:subscribe", "sessionId": "session-abc" }
|
||||
```
|
||||
|
||||
**Subscribe to shared run session events**:
|
||||
|
||||
```json
|
||||
{ "type": "run-session:subscribe" }
|
||||
```
|
||||
|
||||
The server confirms with `run-session:subscribed` after the connection has
|
||||
`task:read` access in the current workspace.
|
||||
|
||||
### Server → Client Messages
|
||||
|
||||
**Task change broadcast**:
|
||||
|
|
@ -925,6 +935,125 @@ or `X-API-Key`. In production, do not rely on localhost bypass.
|
|||
{ "type": "agent:status", "status": "working", "activeAgents": [ ... ] }
|
||||
```
|
||||
|
||||
**Shared run session event**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "run-session:event",
|
||||
"event": {
|
||||
"id": "run_event_abc",
|
||||
"shareId": "run_share_abc",
|
||||
"taskId": "TASK-001",
|
||||
"attemptId": "attempt_001",
|
||||
"type": "message.sent",
|
||||
"actor": { "id": "editor-1", "label": "Pair Editor" },
|
||||
"message": "Run the focused verification gate",
|
||||
"createdAt": "2026-06-18T10:00:00.000Z"
|
||||
},
|
||||
"workspaceId": "local",
|
||||
"sequence": 42,
|
||||
"timestamp": "2026-06-18T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Run Sessions
|
||||
|
||||
Workspace-scoped live sharing for active task agent runs.
|
||||
|
||||
Mounted at `/api/run-sessions`.
|
||||
|
||||
| Method | Path | Description | Permissions |
|
||||
| ------- | --------------------------------- | --------------------------------------------------------- | ------------ |
|
||||
| `GET` | `/api/run-sessions` | List shares in the current workspace. Supports filters. | `task:read` |
|
||||
| `POST` | `/api/run-sessions` | Create a view, edit, or fork share for a task run. | `task:write` |
|
||||
| `GET` | `/api/run-sessions/:id` | Read an active share snapshot. | `task:read` |
|
||||
| `GET` | `/api/run-sessions/:id/events` | Read share lifecycle, message, approval, and fork events. | `task:read` |
|
||||
| `PATCH` | `/api/run-sessions/:id` | Update permission, expiry, label, or mobile-safe classes. | `task:write` |
|
||||
| `POST` | `/api/run-sessions/:id/revoke` | Revoke a share. | `task:write` |
|
||||
| `POST` | `/api/run-sessions/:id/messages` | Send an attributed co-drive message into the run. | `task:write` |
|
||||
| `POST` | `/api/run-sessions/:id/approvals` | Record an approval response. | `task:write` |
|
||||
| `POST` | `/api/run-sessions/:id/fork` | Create a linked fork task without mutating the parent. | `task:write` |
|
||||
|
||||
### Create Share
|
||||
|
||||
```http
|
||||
POST /api/run-sessions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "TASK-001",
|
||||
"permission": "view",
|
||||
"expiresAt": "2026-06-19T10:00:00.000Z",
|
||||
"actorLabel": "Release reviewer",
|
||||
"mobileSafeApprovalClasses": ["human-review", "task-comment", "low-risk"]
|
||||
}
|
||||
```
|
||||
|
||||
`permission` is one of `view`, `edit`, or `fork`.
|
||||
|
||||
The response includes `stablePath`, `snapshot`, `mobileSafeApprovalClasses`,
|
||||
`status`, and `forkedTaskIds`. The share path is not anonymous public access; it
|
||||
still requires workspace authentication and task read/write permissions.
|
||||
|
||||
### Co-drive Message
|
||||
|
||||
```http
|
||||
POST /api/run-sessions/run_share_abc/messages
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Run the focused verification gate before release."
|
||||
}
|
||||
```
|
||||
|
||||
The server records the request actor on the resulting `message.sent` event and
|
||||
forwards the message to the active run when the backing provider exposes an
|
||||
interactive stream. If interactive stdin is unavailable, the message is still
|
||||
recorded and streamed as session history.
|
||||
|
||||
### Mobile-safe Approval
|
||||
|
||||
```http
|
||||
POST /api/run-sessions/run_share_abc/approvals
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"actionClass": "human-review",
|
||||
"response": "approved",
|
||||
"note": "Diff and focused tests look safe from mobile."
|
||||
}
|
||||
```
|
||||
|
||||
Mobile/PWA clients can respond only to approval classes listed on the share.
|
||||
Unsafe approval classes fail closed with `403`.
|
||||
|
||||
### Fork Session
|
||||
|
||||
```http
|
||||
POST /api/run-sessions/run_share_abc/fork
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Investigate forked run",
|
||||
"priority": "high",
|
||||
"reason": "Continue independently without changing the parent run."
|
||||
}
|
||||
```
|
||||
|
||||
Forking creates a new task linked to the parent task and attempt. The fork
|
||||
description includes redacted parent context and a redacted run excerpt when
|
||||
available, but it does not inherit worktrees, thread IDs, credentials, or
|
||||
local-only handles.
|
||||
|
||||
Revoked and expired shares fail closed for reads, messages, approvals, and
|
||||
forks. Share lists are scoped to the current workspace.
|
||||
|
||||
---
|
||||
|
||||
## Task Verification
|
||||
|
|
|
|||
|
|
@ -393,6 +393,16 @@ Role-based autonomy control for multi-agent teams.
|
|||
- **Approval workflows** — Configurable approval requirements per permission level
|
||||
- **API enforcement** — Permission checks on agent actions, not just UI display
|
||||
|
||||
### Shared Live Run Sessions
|
||||
|
||||
Workspace-scoped live collaboration for active task runs. Added in v5.1.
|
||||
|
||||
- **View-only shares** — Create stable `/runs/shared/:id` links from task detail so permitted workspace members can watch live output, tool-call/status events, artifacts, and share history without refresh.
|
||||
- **Co-drive access** — Upgrade a share to edit access or revoke it from task detail. Editors can send messages into the active run, and the server records the editor as the actor instead of the original operator.
|
||||
- **Mobile-safe approvals** — Paired mobile/PWA clients can respond only to approval classes marked safe for that share; unsafe classes fail closed at the API layer.
|
||||
- **Fork isolation** — Fork shares create a new linked task with redacted parent context and run excerpt. The fork does not inherit worktrees, thread IDs, credentials, or other local-only handles, and it does not change parent run state.
|
||||
- **Live delivery** — The `run-sessions` WebSocket channel fans out share, message, approval, revoke, and fork events to authorized same-workspace clients.
|
||||
|
||||
### Error Learning
|
||||
|
||||
Structured failure analysis to prevent recurring issues.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": 1,
|
||||
"lastReviewed": "2026-06-03",
|
||||
"lastReviewed": "2026-06-18",
|
||||
"notes": "Authority surface manifest for v5.0. The REST route-map entry is backed by shared/src/utils/api-permissions.ts, and CI compares it against server/src/routes/v1/index.ts.",
|
||||
"surfaces": [
|
||||
{
|
||||
|
|
@ -75,6 +75,14 @@
|
|||
"source": "server/src/services/broadcast-service.ts",
|
||||
"denialReason": "Workflow status events require workflow read access."
|
||||
},
|
||||
{
|
||||
"id": "websocket:run-session:event",
|
||||
"kind": "websocket",
|
||||
"classification": "authenticated-read",
|
||||
"permissions": ["task:read"],
|
||||
"source": "server/src/services/broadcast-service.ts",
|
||||
"denialReason": "Shared run session events require task read access in the event workspace."
|
||||
},
|
||||
{
|
||||
"id": "websocket:tasks:subscribed",
|
||||
"kind": "websocket",
|
||||
|
|
@ -91,6 +99,14 @@
|
|||
"source": "server/src/index.ts",
|
||||
"denialReason": "Workflow subscription confirmations are only sent after workflow read authorization."
|
||||
},
|
||||
{
|
||||
"id": "websocket:run-session:subscribed",
|
||||
"kind": "websocket",
|
||||
"classification": "authenticated-read",
|
||||
"permissions": ["task:read"],
|
||||
"source": "server/src/index.ts",
|
||||
"denialReason": "Shared run session subscription confirmations are only sent after task read authorization."
|
||||
},
|
||||
{
|
||||
"id": "websocket:agent:status",
|
||||
"kind": "websocket",
|
||||
|
|
@ -179,6 +195,14 @@
|
|||
"source": "server/src/index.ts",
|
||||
"denialReason": "Workflow event channel subscriptions require workflow read access in the requested workspace."
|
||||
},
|
||||
{
|
||||
"id": "websocket-inbound:run-session:subscribe",
|
||||
"kind": "websocket-inbound",
|
||||
"classification": "authenticated-read",
|
||||
"permissions": ["task:read"],
|
||||
"source": "server/src/index.ts",
|
||||
"denialReason": "Shared run session subscriptions require task read access in the requested workspace."
|
||||
},
|
||||
{
|
||||
"id": "websocket-inbound:subscribe",
|
||||
"kind": "websocket-inbound",
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
* Tests WebSocket broadcast functions for task changes and telemetry.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { AnyTelemetryEvent } from '@veritas-kanban/shared';
|
||||
import type { AnyTelemetryEvent, RunSessionEvent } from '@veritas-kanban/shared';
|
||||
import type { WebSocketServer } from 'ws';
|
||||
import {
|
||||
broadcastChatMessage,
|
||||
closeWebSocketClientsForRevokedCredential,
|
||||
initBroadcast,
|
||||
broadcastRunSessionEvent,
|
||||
broadcastTaskChange,
|
||||
broadcastTelemetryEvent,
|
||||
broadcastWorkflowStatus,
|
||||
|
|
@ -105,6 +106,23 @@ function workflowRun(status: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function runSessionEvent(type: RunSessionEvent['type'] = 'message.sent'): RunSessionEvent {
|
||||
return {
|
||||
id: `run_event_${type}`,
|
||||
shareId: 'run_share_721',
|
||||
taskId: 'task_721',
|
||||
attemptId: 'attempt_721',
|
||||
type,
|
||||
actor: {
|
||||
id: 'editor-1',
|
||||
label: 'Pair Editor',
|
||||
workspaceId: 'local',
|
||||
},
|
||||
createdAt: '2026-06-18T10:00:00.000Z',
|
||||
message: type === 'message.sent' ? 'Continue the run' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
initBroadcast(null as unknown as WebSocketServer);
|
||||
|
|
@ -333,6 +351,58 @@ describe('BroadcastService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('broadcastRunSessionEvent()', () => {
|
||||
it('fans out shared run session updates to subscribed same-workspace readers', () => {
|
||||
const wss = createMockWss();
|
||||
const firstSubscriber = wss.addClient(1, undefined, {
|
||||
subscribedChannels: new Set(['run-sessions']),
|
||||
});
|
||||
const secondSubscriber = wss.addClient(1, undefined, {
|
||||
subscribedChannels: new Set(['run-sessions']),
|
||||
});
|
||||
const taskOnly = wss.addClient(1, undefined, {
|
||||
subscribedChannels: new Set(['tasks']),
|
||||
});
|
||||
const otherWorkspace = wss.addClient(
|
||||
1,
|
||||
{ role: 'read-only', isLocalhost: false, workspaceId: 'other' },
|
||||
{ subscribedChannels: new Set(['run-sessions']) }
|
||||
);
|
||||
initBroadcast(asWebSocketServer(wss));
|
||||
|
||||
broadcastRunSessionEvent(runSessionEvent(), { workspaceId: 'local' });
|
||||
|
||||
expect(firstSubscriber.sent).toHaveLength(1);
|
||||
expect(secondSubscriber.sent).toHaveLength(1);
|
||||
expect(taskOnly.sent).toHaveLength(0);
|
||||
expect(otherWorkspace.sent).toHaveLength(0);
|
||||
expect(wss.sentMessages).toHaveLength(2);
|
||||
expect(JSON.parse(firstSubscriber.sent[0])).toMatchObject({
|
||||
type: 'run-session:event',
|
||||
event: {
|
||||
type: 'message.sent',
|
||||
shareId: 'run_share_721',
|
||||
message: 'Continue the run',
|
||||
},
|
||||
workspaceId: 'local',
|
||||
sequence: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts revoke events so viewers can fail closed after access changes', () => {
|
||||
const wss = createMockWss();
|
||||
const subscriber = wss.addClient(1, undefined, {
|
||||
subscribedChannels: new Set(['run-sessions']),
|
||||
});
|
||||
initBroadcast(asWebSocketServer(wss));
|
||||
|
||||
broadcastRunSessionEvent(runSessionEvent('share.revoked'), { workspaceId: 'local' });
|
||||
|
||||
expect(subscriber.sent).toHaveLength(1);
|
||||
expect(JSON.parse(subscriber.sent[0]).event.type).toBe('share.revoked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastWorkflowStatus()', () => {
|
||||
it('should coalesce high-frequency status updates per workflow run', () => {
|
||||
vi.useFakeTimers();
|
||||
|
|
|
|||
156
server/src/__tests__/routes/run-sessions.test.ts
Normal file
156
server/src/__tests__/routes/run-sessions.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
|
||||
const mockRunSessionShareService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
listEvents: vi.fn(),
|
||||
update: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
respondToApproval: vi.fn(),
|
||||
fork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/run-session-share-service.js', () => ({
|
||||
getRunSessionShareService: () => mockRunSessionShareService,
|
||||
}));
|
||||
|
||||
import { runSessionRoutes } from '../../routes/run-sessions.js';
|
||||
|
||||
function createApp(): express.Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
userId: 'user-721',
|
||||
tokenName: 'Brad',
|
||||
role: 'admin',
|
||||
workspaceId: 'workspace-a',
|
||||
authMethod: 'api-token',
|
||||
clientMode: req.header('x-client-mode') || undefined,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use('/api/run-sessions', runSessionRoutes);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Run Session Routes', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
it('lists shares with task filters and request workspace actor context', async () => {
|
||||
mockRunSessionShareService.list.mockResolvedValue([
|
||||
{ id: 'run_share_1', taskId: 'task-721', permission: 'view', status: 'active' },
|
||||
]);
|
||||
|
||||
const response = await request(app).get('/api/run-sessions?taskId=task-721&status=active');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRunSessionShareService.list).toHaveBeenCalledWith(
|
||||
{ taskId: 'task-721', status: 'active' },
|
||||
expect.objectContaining({ id: 'user-721', workspaceId: 'workspace-a' })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates, updates, revokes, messages, approvals, and forks shares', async () => {
|
||||
mockRunSessionShareService.create.mockResolvedValue({ id: 'run_share_1' });
|
||||
mockRunSessionShareService.update.mockResolvedValue({ id: 'run_share_1', permission: 'edit' });
|
||||
mockRunSessionShareService.revoke.mockResolvedValue({ id: 'run_share_1', status: 'revoked' });
|
||||
mockRunSessionShareService.sendMessage.mockResolvedValue({ id: 'run_event_msg' });
|
||||
mockRunSessionShareService.respondToApproval.mockResolvedValue({ id: 'run_event_approval' });
|
||||
mockRunSessionShareService.fork.mockResolvedValue({
|
||||
fork: { id: 'run_fork_1' },
|
||||
task: { id: 'task-fork' },
|
||||
});
|
||||
|
||||
const create = await request(app)
|
||||
.post('/api/run-sessions')
|
||||
.send({
|
||||
taskId: 'task-721',
|
||||
permission: 'view',
|
||||
mobileSafeApprovalClasses: ['human-review'],
|
||||
});
|
||||
const update = await request(app).patch('/api/run-sessions/run_share_1').send({
|
||||
permission: 'edit',
|
||||
});
|
||||
const revoke = await request(app).post('/api/run-sessions/run_share_1/revoke').send({
|
||||
reason: 'Reviewer rotated out',
|
||||
});
|
||||
const message = await request(app).post('/api/run-sessions/run_share_1/messages').send({
|
||||
message: 'Please continue the focused run.',
|
||||
});
|
||||
const approval = await request(app)
|
||||
.post('/api/run-sessions/run_share_1/approvals')
|
||||
.set('x-client-mode', 'mobile-pwa')
|
||||
.send({
|
||||
actionClass: 'human-review',
|
||||
response: 'approved',
|
||||
});
|
||||
const fork = await request(app).post('/api/run-sessions/run_share_1/fork').send({
|
||||
title: 'Fork run session',
|
||||
priority: 'high',
|
||||
});
|
||||
|
||||
expect(create.status).toBe(201);
|
||||
expect(update.status).toBe(200);
|
||||
expect(revoke.status).toBe(200);
|
||||
expect(message.status).toBe(201);
|
||||
expect(approval.status).toBe(201);
|
||||
expect(fork.status).toBe(201);
|
||||
expect(mockRunSessionShareService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ taskId: 'task-721', permission: 'view' }),
|
||||
expect.objectContaining({ workspaceId: 'workspace-a' })
|
||||
);
|
||||
expect(mockRunSessionShareService.update).toHaveBeenCalledWith(
|
||||
'run_share_1',
|
||||
{ permission: 'edit' },
|
||||
expect.objectContaining({ workspaceId: 'workspace-a' })
|
||||
);
|
||||
expect(mockRunSessionShareService.revoke).toHaveBeenCalledWith(
|
||||
'run_share_1',
|
||||
expect.objectContaining({ workspaceId: 'workspace-a' }),
|
||||
'Reviewer rotated out'
|
||||
);
|
||||
expect(mockRunSessionShareService.sendMessage).toHaveBeenCalledWith(
|
||||
'run_share_1',
|
||||
{ message: 'Please continue the focused run.' },
|
||||
expect.objectContaining({ workspaceId: 'workspace-a' })
|
||||
);
|
||||
expect(mockRunSessionShareService.respondToApproval).toHaveBeenCalledWith(
|
||||
'run_share_1',
|
||||
{ actionClass: 'human-review', response: 'approved' },
|
||||
expect.objectContaining({ clientMode: 'mobile-pwa' })
|
||||
);
|
||||
expect(mockRunSessionShareService.fork).toHaveBeenCalledWith(
|
||||
'run_share_1',
|
||||
{ title: 'Fork run session', priority: 'high' },
|
||||
expect.objectContaining({ workspaceId: 'workspace-a' })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid permissions and blank co-drive messages before the service layer', async () => {
|
||||
const create = await request(app).post('/api/run-sessions').send({
|
||||
taskId: 'task-721',
|
||||
permission: 'admin',
|
||||
});
|
||||
const message = await request(app).post('/api/run-sessions/run_share_1/messages').send({
|
||||
message: '',
|
||||
});
|
||||
|
||||
expect(create.status).toBe(400);
|
||||
expect(message.status).toBe(400);
|
||||
expect(mockRunSessionShareService.create).not.toHaveBeenCalled();
|
||||
expect(mockRunSessionShareService.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
251
server/src/__tests__/run-session-share-service.test.ts
Normal file
251
server/src/__tests__/run-session-share-service.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { RunSessionShareService } from '../services/run-session-share-service.js';
|
||||
import type { TaskService } from '../services/task-service.js';
|
||||
import { ForbiddenError } from '../middleware/error-handler.js';
|
||||
|
||||
const mockTask: Task = {
|
||||
id: 'task-721',
|
||||
title: 'Ship shared live run sessions',
|
||||
description: 'Parent context in /Users/bradgroux/Projects/veritas-kanban with no secrets.',
|
||||
type: 'feature',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
project: 'veritas',
|
||||
sprint: '5.1',
|
||||
created: '2026-06-18T10:00:00.000Z',
|
||||
updated: '2026-06-18T10:00:00.000Z',
|
||||
agent: 'codex',
|
||||
git: {
|
||||
repo: 'BradGroux/veritas-kanban',
|
||||
branch: 'feat/shared-live-run-sessions-721',
|
||||
baseBranch: 'main',
|
||||
worktreePath: '/Users/bradgroux/Projects/veritas-kanban',
|
||||
},
|
||||
attempt: {
|
||||
id: 'attempt-721',
|
||||
agent: 'codex',
|
||||
status: 'running',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
started: '2026-06-18T10:01:00.000Z',
|
||||
threadId: 'thread-local-only',
|
||||
},
|
||||
deliverables: [
|
||||
{
|
||||
id: 'artifact-1',
|
||||
title: 'Preview artifact',
|
||||
type: 'artifact',
|
||||
status: 'attached',
|
||||
created: '2026-06-18T10:05:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('RunSessionShareService', () => {
|
||||
let tmpDir: string;
|
||||
let taskService: {
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
createTask: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let agentService: {
|
||||
getAgentStatus: ReturnType<typeof vi.fn>;
|
||||
getAttemptLog: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let service: RunSessionShareService;
|
||||
|
||||
const owner = {
|
||||
id: 'user-1',
|
||||
label: 'Brad',
|
||||
type: 'user' as const,
|
||||
workspaceId: 'local',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'run-session-shares-'));
|
||||
taskService = {
|
||||
getTask: vi.fn().mockResolvedValue(mockTask),
|
||||
createTask: vi.fn().mockResolvedValue({
|
||||
...mockTask,
|
||||
id: 'task-721-fork',
|
||||
title: 'Forked live session',
|
||||
description: 'Forked context',
|
||||
git: undefined,
|
||||
attempt: undefined,
|
||||
}),
|
||||
};
|
||||
agentService = {
|
||||
getAgentStatus: vi.fn().mockReturnValue({
|
||||
attemptId: 'attempt-721',
|
||||
status: 'running',
|
||||
agent: 'codex',
|
||||
startedAt: '2026-06-18T10:01:00.000Z',
|
||||
}),
|
||||
getAttemptLog: vi
|
||||
.fn()
|
||||
.mockResolvedValue('running in /Users/bradgroux/Projects/veritas-kanban\nready'),
|
||||
sendMessage: vi.fn().mockResolvedValue({ delivered: true }),
|
||||
};
|
||||
service = new RunSessionShareService({
|
||||
filePath: path.join(tmpDir, 'run-session-shares.json'),
|
||||
taskService: taskService as unknown as TaskService,
|
||||
agentService: agentService as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates view-only links and lists only shares in the actor workspace', async () => {
|
||||
const share = await service.create({ taskId: mockTask.id, permission: 'view' }, owner);
|
||||
await service.create(
|
||||
{ taskId: mockTask.id, permission: 'view' },
|
||||
{ ...owner, id: 'user-2', workspaceId: 'other' }
|
||||
);
|
||||
|
||||
expect(share.stablePath).toBe(`/runs/shared/${share.id}`);
|
||||
expect(share.snapshot).toMatchObject({
|
||||
running: true,
|
||||
attemptId: 'attempt-721',
|
||||
worktreePath: '[redacted-worktree]',
|
||||
});
|
||||
await expect(service.get(share.id, { actor: owner })).resolves.toMatchObject({
|
||||
id: share.id,
|
||||
permission: 'view',
|
||||
});
|
||||
await expect(
|
||||
service.sendMessage(share.id, { message: 'please continue' }, owner)
|
||||
).rejects.toBeInstanceOf(ForbiddenError);
|
||||
|
||||
expect(await service.list({ taskId: mockTask.id }, owner)).toHaveLength(1);
|
||||
expect(
|
||||
await service.list({ taskId: mockTask.id }, { ...owner, workspaceId: 'other' })
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
await service.list({ taskId: mockTask.id }, { ...owner, workspaceId: 'missing' })
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('upgrades edit access, attributes co-drive messages, and fails closed after revoke or expiry', async () => {
|
||||
const share = await service.create({ taskId: mockTask.id, permission: 'view' }, owner);
|
||||
const upgraded = await service.update(share.id, { permission: 'edit' }, owner);
|
||||
|
||||
expect(upgraded.permission).toBe('edit');
|
||||
const messageEvent = await service.sendMessage(
|
||||
share.id,
|
||||
{ message: 'Run the focused test gate' },
|
||||
{ ...owner, id: 'editor-1', label: 'Pair Editor' }
|
||||
);
|
||||
expect(messageEvent).toMatchObject({
|
||||
type: 'message.sent',
|
||||
message: 'Run the focused test gate',
|
||||
actor: expect.objectContaining({ id: 'editor-1', label: 'Pair Editor' }),
|
||||
});
|
||||
expect(agentService.sendMessage).toHaveBeenCalledWith(
|
||||
mockTask.id,
|
||||
'Run the focused test gate',
|
||||
expect.objectContaining({ actor: 'Pair Editor', source: `run-session:${share.id}` })
|
||||
);
|
||||
|
||||
await service.revoke(share.id, owner, 'Rotated reviewer access');
|
||||
await expect(service.get(share.id, { actor: owner })).rejects.toBeInstanceOf(ForbiddenError);
|
||||
await expect(
|
||||
service.sendMessage(share.id, { message: 'after revoke' }, owner)
|
||||
).rejects.toBeInstanceOf(ForbiddenError);
|
||||
|
||||
const expired = await service.create(
|
||||
{
|
||||
taskId: mockTask.id,
|
||||
permission: 'view',
|
||||
expiresAt: '2020-01-01T00:00:00.000Z',
|
||||
},
|
||||
owner
|
||||
);
|
||||
await expect(service.get(expired.id, { actor: owner })).rejects.toBeInstanceOf(ForbiddenError);
|
||||
expect(await service.list({ status: 'expired' }, owner)).toEqual([
|
||||
expect.objectContaining({ id: expired.id, status: 'expired' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows mobile-safe approvals and blocks unsafe approval classes from mobile clients', async () => {
|
||||
const share = await service.create(
|
||||
{
|
||||
taskId: mockTask.id,
|
||||
permission: 'edit',
|
||||
mobileSafeApprovalClasses: ['human-review'],
|
||||
},
|
||||
owner
|
||||
);
|
||||
const mobileActor = { ...owner, id: 'mobile-1', clientMode: 'mobile-pwa' };
|
||||
|
||||
await expect(
|
||||
service.respondToApproval(
|
||||
share.id,
|
||||
{ actionClass: 'human-review', response: 'approved' },
|
||||
mobileActor
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
type: 'approval.responded',
|
||||
actionClass: 'human-review',
|
||||
approvalResponse: 'approved',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.respondToApproval(
|
||||
share.id,
|
||||
{ actionClass: 'shell-command', response: 'approved' },
|
||||
mobileActor
|
||||
)
|
||||
).rejects.toBeInstanceOf(ForbiddenError);
|
||||
});
|
||||
|
||||
it('forks into a new task without inheriting local handles or mutating parent state', async () => {
|
||||
const share = await service.create({ taskId: mockTask.id, permission: 'fork' }, owner);
|
||||
|
||||
const result = await service.fork(
|
||||
share.id,
|
||||
{
|
||||
title: 'Investigate forked run',
|
||||
priority: 'critical',
|
||||
reason: 'Continue from /Users/bradgroux/private/repo on a separate track.',
|
||||
},
|
||||
owner
|
||||
);
|
||||
|
||||
expect(result.fork).toMatchObject({
|
||||
shareId: share.id,
|
||||
parentTaskId: mockTask.id,
|
||||
parentAttemptId: 'attempt-721',
|
||||
forkTaskId: 'task-721-fork',
|
||||
});
|
||||
expect(taskService.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Investigate forked run',
|
||||
priority: 'critical',
|
||||
type: mockTask.type,
|
||||
project: mockTask.project,
|
||||
sprint: mockTask.sprint,
|
||||
agent: mockTask.agent,
|
||||
})
|
||||
);
|
||||
const createdInput = taskService.createTask.mock.calls[0][0];
|
||||
expect(createdInput.git).toBeUndefined();
|
||||
expect(createdInput.attempt).toBeUndefined();
|
||||
expect(createdInput.description).toContain('[redacted-local-path]');
|
||||
expect(createdInput.description).not.toContain('/Users/bradgroux');
|
||||
|
||||
await expect(
|
||||
service.get(share.id, { actor: owner, includeInactive: true })
|
||||
).resolves.toMatchObject({
|
||||
forkedTaskIds: ['task-721-fork'],
|
||||
});
|
||||
expect(mockTask.git?.worktreePath).toBe('/Users/bradgroux/Projects/veritas-kanban');
|
||||
expect(mockTask.attempt?.threadId).toBe('thread-local-only');
|
||||
});
|
||||
});
|
||||
|
|
@ -819,6 +819,25 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (message.type === 'run-session:subscribe') {
|
||||
const workspaceId = getMessageWorkspaceId(message);
|
||||
const permissions: AuthPermission[] = ['task:read'];
|
||||
if (!canReceiveWebSocketEvent(ws, { workspaceId, permissions })) {
|
||||
sendWebSocketForbidden(ws, 'run-session:subscribe', permissions, workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeWebSocketChannel(ws, 'run-sessions');
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'run-session:subscribed',
|
||||
workspaceId,
|
||||
sequence: nextWebSocketEventSequence(),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Handle subscription to chat session
|
||||
if (message.type === 'chat:subscribe' && message.sessionId) {
|
||||
const workspaceId = getMessageWorkspaceId(message);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { asyncHandler } from '../middleware/async-handler.js';
|
|||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { requireLocalAgentCapability } from '../middleware/local-agent-capability.js';
|
||||
import { AgentBudgetPolicySchema } from '../schemas/agent-budget-schemas.js';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
|
|
@ -28,6 +29,11 @@ const completeAgentSchema = z.object({
|
|||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const sendAgentMessageSchema = z.object({
|
||||
message: z.string().trim().min(1).max(4000),
|
||||
actor: z.string().trim().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
const reportTokensSchema = z.object({
|
||||
attemptId: z.string().optional(),
|
||||
inputTokens: z.number({ message: 'inputTokens is required' }).int().nonnegative(),
|
||||
|
|
@ -119,6 +125,40 @@ router.post(
|
|||
})
|
||||
);
|
||||
|
||||
// POST /api/agents/:taskId/message - Send an attributed operator message to a running agent
|
||||
router.post(
|
||||
'/:taskId/message',
|
||||
asyncHandler(async (req, res) => {
|
||||
let message: string;
|
||||
let actorOverride: string | undefined;
|
||||
try {
|
||||
const parsed = sendAgentMessageSchema.parse(req.body);
|
||||
message = parsed.message;
|
||||
actorOverride = parsed.actor;
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', err.issues);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const auth = (req as AuthenticatedRequest).auth;
|
||||
const actor =
|
||||
actorOverride ||
|
||||
auth?.userId ||
|
||||
auth?.tokenName ||
|
||||
auth?.keyName ||
|
||||
auth?.clientId ||
|
||||
auth?.role ||
|
||||
'operator';
|
||||
const delivery = await clawdbotAgentService.sendMessage(req.params.taskId as string, message, {
|
||||
actor,
|
||||
source: 'agent-route',
|
||||
});
|
||||
res.json(delivery);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/agents/:taskId/status - Get agent status
|
||||
router.get(
|
||||
'/:taskId/status',
|
||||
|
|
|
|||
138
server/src/routes/run-sessions.ts
Normal file
138
server/src/routes/run-sessions.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import {
|
||||
createRunSessionShareSchema,
|
||||
forkRunSessionSchema,
|
||||
revokeRunSessionShareSchema,
|
||||
runSessionApprovalResponseSchema,
|
||||
runSessionShareListQuerySchema,
|
||||
runSessionShareParamsSchema,
|
||||
sendRunSessionMessageSchema,
|
||||
updateRunSessionShareSchema,
|
||||
} from '../schemas/run-session-schemas.js';
|
||||
import { getRunSessionShareService } from '../services/run-session-share-service.js';
|
||||
import type { RunSessionActor } from '@veritas-kanban/shared';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
try {
|
||||
return schema.parse(value);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function actorFromRequest(req: AuthenticatedRequest): RunSessionActor {
|
||||
const auth = req.auth;
|
||||
const id =
|
||||
auth?.userId ||
|
||||
auth?.tokenName ||
|
||||
auth?.keyName ||
|
||||
auth?.clientId ||
|
||||
auth?.deviceId ||
|
||||
auth?.role ||
|
||||
'operator';
|
||||
return {
|
||||
id,
|
||||
label: auth?.tokenName || auth?.keyName || auth?.clientId || auth?.userId || id,
|
||||
type: auth?.actorType,
|
||||
authMethod: auth?.authMethod,
|
||||
clientMode: auth?.clientMode,
|
||||
workspaceId: auth?.workspaceId || 'local',
|
||||
};
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = parseOrThrow(runSessionShareListQuerySchema, req.query);
|
||||
res.json(await getRunSessionShareService().list(query, actorFromRequest(req)));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseOrThrow(createRunSessionShareSchema, req.body);
|
||||
const share = await getRunSessionShareService().create(body, actorFromRequest(req));
|
||||
res.status(201).json(share);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:shareId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
res.json(await getRunSessionShareService().get(shareId, { actor: actorFromRequest(req) }));
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:shareId/events',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
res.json(await getRunSessionShareService().listEvents(shareId, actorFromRequest(req)));
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:shareId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
const body = parseOrThrow(updateRunSessionShareSchema, req.body);
|
||||
res.json(await getRunSessionShareService().update(shareId, body, actorFromRequest(req)));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:shareId/revoke',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
const body = parseOrThrow(revokeRunSessionShareSchema, req.body);
|
||||
res.json(await getRunSessionShareService().revoke(shareId, actorFromRequest(req), body.reason));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:shareId/messages',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
const body = parseOrThrow(sendRunSessionMessageSchema, req.body);
|
||||
res
|
||||
.status(201)
|
||||
.json(await getRunSessionShareService().sendMessage(shareId, body, actorFromRequest(req)));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:shareId/approvals',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
const body = parseOrThrow(runSessionApprovalResponseSchema, req.body);
|
||||
res
|
||||
.status(201)
|
||||
.json(
|
||||
await getRunSessionShareService().respondToApproval(shareId, body, actorFromRequest(req))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:shareId/fork',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { shareId } = parseOrThrow(runSessionShareParamsSchema, req.params);
|
||||
const body = parseOrThrow(forkRunSessionSchema, req.body);
|
||||
res
|
||||
.status(201)
|
||||
.json(await getRunSessionShareService().fork(shareId, body, actorFromRequest(req)));
|
||||
})
|
||||
);
|
||||
|
||||
export { router as runSessionRoutes };
|
||||
|
|
@ -131,6 +131,7 @@ import { skillCapabilityRoutes } from '../skill-capabilities.js';
|
|||
import { skillSecurityRoutes } from '../skill-security.js';
|
||||
import { watcherPolicyRoutes } from '../watcher-policies.js';
|
||||
import sandboxPolicyRoutes from '../sandbox-policies.js';
|
||||
import { runSessionRoutes } from '../run-sessions.js';
|
||||
|
||||
const v1Router: IRouter = Router();
|
||||
|
||||
|
|
@ -235,6 +236,7 @@ v1Router.use('/transcripts', transcriptAccess, transcriptRoutes);
|
|||
v1Router.use('/scoring', scoringAccess, scoringRoutes);
|
||||
v1Router.use('/system/health', workspaceAccess, systemHealthRouter);
|
||||
v1Router.use('/decisions', taskAccess, decisionRoutes);
|
||||
v1Router.use('/run-sessions', taskAccess, runSessionRoutes);
|
||||
v1Router.use('/governance/traces', policyAccess, governanceTraceRoutes);
|
||||
v1Router.use('/feedback', feedbackAccess, feedbackRoutes);
|
||||
v1Router.use('/prompt-registry', promptRegistryAccess, promptRegistryRoutes);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const agentRoutingAccess = routeAccess('agent:read', 'admin:manage', [
|
|||
{ methods: ['POST'], path: /^\/route\/?$/, permissions: 'agent:read' },
|
||||
{ methods: ['POST'], path: /^\/hosts\/preview\/?$/, permissions: 'agent:read' },
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/message\/?$/, permissions: 'task:write' },
|
||||
]);
|
||||
export const agentTaskAccess = routeAccess('agent:read', 'task:write', [
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
|
||||
|
|
|
|||
64
server/src/schemas/run-session-schemas.ts
Normal file
64
server/src/schemas/run-session-schemas.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const runSessionPermissionSchema = z.enum(['view', 'edit', 'fork']);
|
||||
export const runSessionStatusSchema = z.enum(['active', 'revoked', 'expired']);
|
||||
|
||||
const mobileSafeApprovalClassSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^[a-z0-9][a-z0-9._:-]*$/i, 'Invalid approval class');
|
||||
|
||||
export const createRunSessionShareSchema = z.object({
|
||||
taskId: z.string().min(1).max(120),
|
||||
permission: runSessionPermissionSchema.default('view'),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
actorLabel: z.string().trim().min(1).max(120).optional(),
|
||||
mobileSafeApprovalClasses: z.array(mobileSafeApprovalClassSchema).max(20).optional(),
|
||||
});
|
||||
|
||||
export const runSessionShareListQuerySchema = z.object({
|
||||
taskId: z.string().min(1).max(120).optional(),
|
||||
status: runSessionStatusSchema.optional(),
|
||||
});
|
||||
|
||||
export const runSessionShareParamsSchema = z.object({
|
||||
shareId: z.string().min(1).max(120),
|
||||
});
|
||||
|
||||
export const updateRunSessionShareSchema = z.object({
|
||||
permission: runSessionPermissionSchema.optional(),
|
||||
expiresAt: z.union([z.string().datetime(), z.null()]).optional(),
|
||||
actorLabel: z.string().trim().min(1).max(120).optional(),
|
||||
mobileSafeApprovalClasses: z.array(mobileSafeApprovalClassSchema).max(20).optional(),
|
||||
});
|
||||
|
||||
export const revokeRunSessionShareSchema = z.object({
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
export const sendRunSessionMessageSchema = z.object({
|
||||
message: z.string().trim().min(1).max(4000),
|
||||
});
|
||||
|
||||
export const runSessionApprovalResponseSchema = z.object({
|
||||
actionClass: mobileSafeApprovalClassSchema,
|
||||
response: z.enum(['approved', 'rejected']),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const forkRunSessionSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
reason: z.string().trim().max(1000).optional(),
|
||||
});
|
||||
|
||||
export type CreateRunSessionShareBody = z.infer<typeof createRunSessionShareSchema>;
|
||||
export type RunSessionShareListQuery = z.infer<typeof runSessionShareListQuerySchema>;
|
||||
export type RunSessionShareParams = z.infer<typeof runSessionShareParamsSchema>;
|
||||
export type UpdateRunSessionShareBody = z.infer<typeof updateRunSessionShareSchema>;
|
||||
export type RevokeRunSessionShareBody = z.infer<typeof revokeRunSessionShareSchema>;
|
||||
export type SendRunSessionMessageBody = z.infer<typeof sendRunSessionMessageSchema>;
|
||||
export type RunSessionApprovalResponseBody = z.infer<typeof runSessionApprovalResponseSchema>;
|
||||
export type ForkRunSessionBody = z.infer<typeof forkRunSessionSchema>;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { WebSocketServer } from 'ws';
|
||||
import type { AnyTelemetryEvent, SquadMessage } from '@veritas-kanban/shared';
|
||||
import type { AnyTelemetryEvent, RunSessionEvent, SquadMessage } from '@veritas-kanban/shared';
|
||||
import type { AuthenticatedWebSocket } from '../middleware/auth.js';
|
||||
import {
|
||||
notifyTaskChange,
|
||||
|
|
@ -283,6 +283,36 @@ export interface BroadcastMessageEvent {
|
|||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface RunSessionBroadcastEvent {
|
||||
type: 'run-session:event';
|
||||
event: RunSessionEvent;
|
||||
timestamp: string;
|
||||
sequence: number;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function broadcastRunSessionEvent(
|
||||
event: RunSessionEvent,
|
||||
options: { workspaceId?: string } = {}
|
||||
): void {
|
||||
if (!wssRef) return;
|
||||
const workspaceId = normalizeWorkspaceId(options.workspaceId);
|
||||
|
||||
const message: RunSessionBroadcastEvent = {
|
||||
type: 'run-session:event',
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
sequence: nextWebSocketEventSequence(),
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
broadcastToClients(JSON.stringify(message), {
|
||||
permissions: ['task:read'],
|
||||
workspaceId,
|
||||
channel: 'run-sessions',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a new broadcast message to all connected WebSocket clients.
|
||||
* Clients can listen for 'broadcast:new' messages to receive real-time notifications.
|
||||
|
|
|
|||
|
|
@ -127,6 +127,16 @@ export interface AgentStartOptions {
|
|||
budget?: AgentBudgetPolicy;
|
||||
}
|
||||
|
||||
export interface AgentMessageOptions {
|
||||
actor?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface AgentMessageDelivery {
|
||||
delivered: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export class AgentReadinessError extends Error {
|
||||
constructor(
|
||||
public readiness: TaskReadinessSummary,
|
||||
|
|
@ -644,6 +654,58 @@ export class ClawdbotAgentService {
|
|||
});
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
taskId: string,
|
||||
message: string,
|
||||
options: AgentMessageOptions = {}
|
||||
): Promise<AgentMessageDelivery> {
|
||||
const pending = pendingAgents.get(taskId);
|
||||
if (!pending) {
|
||||
throw new Error('No agent running for this task');
|
||||
}
|
||||
|
||||
const content = message.trim();
|
||||
if (!content) {
|
||||
throw new Error('Message cannot be empty');
|
||||
}
|
||||
|
||||
const actor = options.actor?.trim() || 'operator';
|
||||
const timestamp = new Date().toISOString();
|
||||
const redacted = this.redactTraceText(content);
|
||||
const logPath = path.join(this.logsDir, `${taskId}_${pending.attemptId}.md`);
|
||||
|
||||
await this.appendLog(
|
||||
logPath,
|
||||
`\n## Operator Message\n\n**Actor:** ${actor}\n**Source:** ${
|
||||
options.source || 'agent-panel'
|
||||
}\n\n${redacted}\n`
|
||||
);
|
||||
pending.emitter.emit('output', {
|
||||
type: 'stdin',
|
||||
content: `${actor}: ${redacted}`,
|
||||
timestamp,
|
||||
} satisfies AgentOutput);
|
||||
this.recordTraceStep(pending.attemptId, 'execute', {
|
||||
eventType: 'operator.message',
|
||||
actor,
|
||||
source: options.source,
|
||||
summary: redacted,
|
||||
agent: pending.agent,
|
||||
provider: pending.provider,
|
||||
model: pending.model,
|
||||
});
|
||||
|
||||
if (pending.process?.stdin?.writable) {
|
||||
pending.process.stdin.write(`${content}\n`);
|
||||
return { delivered: true, note: 'Message written to provider stdin.' };
|
||||
}
|
||||
|
||||
return {
|
||||
delivered: false,
|
||||
note: 'Provider does not expose interactive stdin; message was recorded and streamed.',
|
||||
};
|
||||
}
|
||||
|
||||
async recordBudgetUsage(taskId: string, delta: Partial<AgentBudgetUsage>): Promise<void> {
|
||||
await this.evaluatePendingBudget(taskId, delta, 'agent.usage', true);
|
||||
}
|
||||
|
|
|
|||
462
server/src/services/run-session-share-service.ts
Normal file
462
server/src/services/run-session-share-service.ts
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
CreateRunSessionShareInput,
|
||||
ForkRunSessionInput,
|
||||
RunSessionActor,
|
||||
RunSessionApprovalResponseInput,
|
||||
RunSessionEvent,
|
||||
RunSessionFork,
|
||||
RunSessionPermission,
|
||||
RunSessionShare,
|
||||
RunSessionShareListFilters,
|
||||
RunSessionShareStatus,
|
||||
RunSessionSnapshot,
|
||||
SendRunSessionMessageInput,
|
||||
Task,
|
||||
UpdateRunSessionShareInput,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { getDataDir } from '../utils/paths.js';
|
||||
import { validatePathSegment } from '../utils/sanitize.js';
|
||||
import { redactString } from '../lib/redact.js';
|
||||
import { ForbiddenError, NotFoundError } from '../middleware/error-handler.js';
|
||||
import { getTaskService, type TaskService } from './task-service.js';
|
||||
import { clawdbotAgentService } from './clawdbot-agent-service.js';
|
||||
import { broadcastRunSessionEvent } from './broadcast-service.js';
|
||||
|
||||
interface RunSessionShareState {
|
||||
shares: RunSessionShare[];
|
||||
events: RunSessionEvent[];
|
||||
forks: RunSessionFork[];
|
||||
}
|
||||
|
||||
interface RunSessionShareServiceOptions {
|
||||
filePath?: string;
|
||||
taskService?: TaskService;
|
||||
agentService?: typeof clawdbotAgentService;
|
||||
}
|
||||
|
||||
interface ShareAccessOptions {
|
||||
includeInactive?: boolean;
|
||||
permission?: RunSessionPermission;
|
||||
actor?: RunSessionActor;
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ID = 'local';
|
||||
const DEFAULT_MOBILE_SAFE_APPROVAL_CLASSES = ['human-review', 'task-comment', 'low-risk'];
|
||||
const MAX_EVENT_HISTORY = 5000;
|
||||
const LOG_CONTEXT_LIMIT = 4000;
|
||||
|
||||
export class RunSessionShareService {
|
||||
private readonly filePath: string;
|
||||
private readonly taskService: TaskService;
|
||||
private readonly agentService: typeof clawdbotAgentService;
|
||||
private state: RunSessionShareState | null = null;
|
||||
|
||||
constructor(options: RunSessionShareServiceOptions = {}) {
|
||||
this.filePath =
|
||||
options.filePath ?? path.join(getDataDir(), 'storage', 'run-session-shares.json');
|
||||
this.taskService = options.taskService ?? getTaskService();
|
||||
this.agentService = options.agentService ?? clawdbotAgentService;
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateRunSessionShareInput,
|
||||
actor: RunSessionActor
|
||||
): Promise<RunSessionShare> {
|
||||
const task = await this.requireTask(input.taskId);
|
||||
const now = new Date().toISOString();
|
||||
const status = this.statusFor(input.expiresAt, undefined);
|
||||
const id = `run_share_${nanoid(10)}`;
|
||||
const share: RunSessionShare = {
|
||||
id,
|
||||
workspaceId: actor.workspaceId || DEFAULT_WORKSPACE_ID,
|
||||
taskId: task.id,
|
||||
sourceType: 'task-agent',
|
||||
sourceId: task.attempt?.id ?? task.id,
|
||||
permission: input.permission,
|
||||
status,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actor,
|
||||
expiresAt: input.expiresAt,
|
||||
actorLabel: input.actorLabel,
|
||||
stablePath: `/runs/shared/${id}`,
|
||||
mobileSafeApprovalClasses: this.normalizeApprovalClasses(input.mobileSafeApprovalClasses),
|
||||
snapshot: this.snapshotTask(task),
|
||||
forkedTaskIds: [],
|
||||
};
|
||||
|
||||
const state = await this.loadState();
|
||||
state.shares.push(share);
|
||||
const event = this.createEvent(share, 'share.created', actor, {
|
||||
permission: share.permission,
|
||||
expiresAt: share.expiresAt,
|
||||
});
|
||||
state.events.push(event);
|
||||
await this.saveState(state);
|
||||
broadcastRunSessionEvent(event, { workspaceId: share.workspaceId });
|
||||
return share;
|
||||
}
|
||||
|
||||
async list(
|
||||
filters: RunSessionShareListFilters = {},
|
||||
actor?: RunSessionActor
|
||||
): Promise<RunSessionShare[]> {
|
||||
const state = await this.loadState();
|
||||
let changed = false;
|
||||
const workspaceId = actor?.workspaceId || DEFAULT_WORKSPACE_ID;
|
||||
const shares = state.shares.map((share) => {
|
||||
const status = this.statusFor(share.expiresAt, share.revokedAt);
|
||||
if (status !== share.status) {
|
||||
changed = true;
|
||||
return { ...share, status, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
return share;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
state.shares = shares;
|
||||
await this.saveState(state);
|
||||
}
|
||||
|
||||
return shares
|
||||
.filter((share) => share.workspaceId === workspaceId)
|
||||
.filter((share) => !filters.taskId || share.taskId === filters.taskId)
|
||||
.filter((share) => !filters.status || share.status === filters.status)
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||
}
|
||||
|
||||
async get(id: string, options: ShareAccessOptions = {}): Promise<RunSessionShare> {
|
||||
validatePathSegment(id);
|
||||
const state = await this.loadState();
|
||||
const index = state.shares.findIndex((share) => share.id === id);
|
||||
if (index < 0) throw new NotFoundError('Run session share not found');
|
||||
|
||||
let share = state.shares[index];
|
||||
const status = this.statusFor(share.expiresAt, share.revokedAt);
|
||||
if (status !== share.status) {
|
||||
share = { ...share, status, updatedAt: new Date().toISOString() };
|
||||
state.shares[index] = share;
|
||||
await this.saveState(state);
|
||||
}
|
||||
|
||||
this.assertWorkspace(share, options.actor);
|
||||
if (!options.includeInactive && share.status !== 'active') {
|
||||
throw new ForbiddenError('Run session share is not active', { status: share.status });
|
||||
}
|
||||
if (options.permission) this.assertSharePermission(share, options.permission);
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateRunSessionShareInput,
|
||||
actor: RunSessionActor
|
||||
): Promise<RunSessionShare> {
|
||||
const state = await this.loadState();
|
||||
const current = await this.get(id, { actor });
|
||||
const next: RunSessionShare = {
|
||||
...current,
|
||||
permission: input.permission ?? current.permission,
|
||||
expiresAt: input.expiresAt === null ? undefined : (input.expiresAt ?? current.expiresAt),
|
||||
actorLabel: input.actorLabel ?? current.actorLabel,
|
||||
mobileSafeApprovalClasses:
|
||||
input.mobileSafeApprovalClasses !== undefined
|
||||
? this.normalizeApprovalClasses(input.mobileSafeApprovalClasses)
|
||||
: current.mobileSafeApprovalClasses,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
next.status = this.statusFor(next.expiresAt, next.revokedAt);
|
||||
|
||||
state.shares = state.shares.map((share) => (share.id === id ? next : share));
|
||||
const event = this.createEvent(next, 'share.updated', actor, {
|
||||
permission: next.permission,
|
||||
expiresAt: next.expiresAt,
|
||||
});
|
||||
state.events.push(event);
|
||||
await this.saveState(state);
|
||||
broadcastRunSessionEvent(event, { workspaceId: next.workspaceId });
|
||||
return next;
|
||||
}
|
||||
|
||||
async revoke(id: string, actor: RunSessionActor, reason?: string): Promise<RunSessionShare> {
|
||||
const state = await this.loadState();
|
||||
const current = await this.get(id, { actor, includeInactive: true });
|
||||
const now = new Date().toISOString();
|
||||
const next: RunSessionShare = {
|
||||
...current,
|
||||
status: 'revoked',
|
||||
revokedAt: current.revokedAt ?? now,
|
||||
revokedBy: current.revokedBy ?? actor,
|
||||
revokedReason: reason,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
state.shares = state.shares.map((share) => (share.id === id ? next : share));
|
||||
const event = this.createEvent(next, 'share.revoked', actor, { reason });
|
||||
state.events.push(event);
|
||||
await this.saveState(state);
|
||||
broadcastRunSessionEvent(event, { workspaceId: next.workspaceId });
|
||||
return next;
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
id: string,
|
||||
input: SendRunSessionMessageInput,
|
||||
actor: RunSessionActor
|
||||
): Promise<RunSessionEvent> {
|
||||
const share = await this.get(id, { actor, permission: 'edit' });
|
||||
const delivery = await this.agentService.sendMessage(share.taskId, input.message, {
|
||||
actor: actor.label || actor.id,
|
||||
source: `run-session:${share.id}`,
|
||||
});
|
||||
const event = this.createEvent(share, 'message.sent', actor, {
|
||||
delivered: delivery.delivered,
|
||||
note: delivery.note,
|
||||
});
|
||||
event.message = input.message;
|
||||
|
||||
const state = await this.loadState();
|
||||
state.events.push(event);
|
||||
await this.saveState(state);
|
||||
broadcastRunSessionEvent(event, { workspaceId: share.workspaceId });
|
||||
return event;
|
||||
}
|
||||
|
||||
async respondToApproval(
|
||||
id: string,
|
||||
input: RunSessionApprovalResponseInput,
|
||||
actor: RunSessionActor
|
||||
): Promise<RunSessionEvent> {
|
||||
const share = await this.get(id, { actor, permission: 'edit' });
|
||||
const mobileClient = actor.clientMode === 'mobile-pwa';
|
||||
if (mobileClient && !share.mobileSafeApprovalClasses.includes(input.actionClass)) {
|
||||
throw new ForbiddenError('Approval class is not mobile-safe for this share', {
|
||||
actionClass: input.actionClass,
|
||||
mobileSafeApprovalClasses: share.mobileSafeApprovalClasses,
|
||||
});
|
||||
}
|
||||
|
||||
const event = this.createEvent(share, 'approval.responded', actor, {
|
||||
note: input.note,
|
||||
mobileClient,
|
||||
});
|
||||
event.actionClass = input.actionClass;
|
||||
event.approvalResponse = input.response;
|
||||
|
||||
const state = await this.loadState();
|
||||
state.events.push(event);
|
||||
await this.saveState(state);
|
||||
broadcastRunSessionEvent(event, { workspaceId: share.workspaceId });
|
||||
return event;
|
||||
}
|
||||
|
||||
async fork(
|
||||
id: string,
|
||||
input: ForkRunSessionInput,
|
||||
actor: RunSessionActor
|
||||
): Promise<{
|
||||
fork: RunSessionFork;
|
||||
task: Task;
|
||||
}> {
|
||||
const share = await this.get(id, { actor, permission: 'fork' });
|
||||
const parent = await this.requireTask(share.taskId);
|
||||
const description = await this.buildForkDescription(parent, share, input.reason);
|
||||
const task = await this.taskService.createTask({
|
||||
title: input.title?.trim() || `Fork: ${parent.title}`,
|
||||
description,
|
||||
type: parent.type,
|
||||
priority: input.priority ?? parent.priority,
|
||||
project: parent.project,
|
||||
sprint: parent.sprint,
|
||||
agent: parent.agent,
|
||||
createdBy: actor.label || actor.id,
|
||||
updatedBy: actor.label || actor.id,
|
||||
});
|
||||
|
||||
const fork: RunSessionFork = {
|
||||
id: `run_fork_${nanoid(10)}`,
|
||||
shareId: share.id,
|
||||
parentTaskId: parent.id,
|
||||
parentAttemptId: share.snapshot.attemptId,
|
||||
forkTaskId: task.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: actor,
|
||||
reason: input.reason,
|
||||
};
|
||||
|
||||
const state = await this.loadState();
|
||||
state.forks.push(fork);
|
||||
state.shares = state.shares.map((candidate) =>
|
||||
candidate.id === share.id
|
||||
? {
|
||||
...candidate,
|
||||
forkedTaskIds: [...new Set([...candidate.forkedTaskIds, task.id])],
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: candidate
|
||||
);
|
||||
const event = this.createEvent(
|
||||
{ ...share, forkedTaskIds: [...share.forkedTaskIds, task.id] },
|
||||
'fork.created',
|
||||
actor,
|
||||
{
|
||||
reason: input.reason,
|
||||
}
|
||||
);
|
||||
event.forkTaskId = task.id;
|
||||
state.events.push(event);
|
||||
await this.saveState(state);
|
||||
broadcastRunSessionEvent(event, { workspaceId: share.workspaceId });
|
||||
return { fork, task };
|
||||
}
|
||||
|
||||
async listEvents(shareId: string, actor?: RunSessionActor): Promise<RunSessionEvent[]> {
|
||||
const share = await this.get(shareId, { actor, includeInactive: true });
|
||||
const state = await this.loadState();
|
||||
return state.events.filter((event) => event.shareId === share.id);
|
||||
}
|
||||
|
||||
private async requireTask(taskId: string): Promise<Task> {
|
||||
const task = await this.taskService.getTask(taskId);
|
||||
if (!task) throw new NotFoundError('Task not found');
|
||||
return task;
|
||||
}
|
||||
|
||||
private snapshotTask(task: Task): RunSessionSnapshot {
|
||||
const status = this.agentService.getAgentStatus(task.id);
|
||||
return {
|
||||
running: Boolean(status),
|
||||
taskTitle: task.title,
|
||||
attemptId: status?.attemptId ?? task.attempt?.id,
|
||||
attemptStatus: status?.status ?? task.attempt?.status,
|
||||
agent: status?.agent ?? task.attempt?.agent,
|
||||
provider: task.attempt?.provider,
|
||||
model: task.attempt?.model,
|
||||
startedAt: status?.startedAt ?? task.attempt?.started,
|
||||
worktreePath: task.git?.worktreePath ? '[redacted-worktree]' : undefined,
|
||||
artifactCount: (task.deliverables?.length ?? 0) + (task.attachments?.length ?? 0),
|
||||
blocker: task.blockedReason?.note,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeApprovalClasses(classes?: string[]): string[] {
|
||||
return [...new Set([...(classes ?? DEFAULT_MOBILE_SAFE_APPROVAL_CLASSES)])].sort();
|
||||
}
|
||||
|
||||
private statusFor(expiresAt?: string, revokedAt?: string): RunSessionShareStatus {
|
||||
if (revokedAt) return 'revoked';
|
||||
if (expiresAt && Date.parse(expiresAt) <= Date.now()) return 'expired';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
private assertWorkspace(share: RunSessionShare, actor?: RunSessionActor): void {
|
||||
if (!actor) return;
|
||||
const actorWorkspace = actor.workspaceId || DEFAULT_WORKSPACE_ID;
|
||||
if (actorWorkspace !== share.workspaceId) {
|
||||
throw new ForbiddenError('Run session share belongs to a different workspace');
|
||||
}
|
||||
}
|
||||
|
||||
private assertSharePermission(share: RunSessionShare, requested: RunSessionPermission): void {
|
||||
if (requested === 'view') return;
|
||||
if (requested === 'edit' && share.permission === 'edit') return;
|
||||
if (requested === 'fork' && share.permission === 'fork') return;
|
||||
throw new ForbiddenError('Run session share does not grant the requested permission', {
|
||||
sharePermission: share.permission,
|
||||
requested,
|
||||
});
|
||||
}
|
||||
|
||||
private createEvent(
|
||||
share: RunSessionShare,
|
||||
type: RunSessionEvent['type'],
|
||||
actor: RunSessionActor,
|
||||
metadata?: Record<string, unknown>
|
||||
): RunSessionEvent {
|
||||
return {
|
||||
id: `run_event_${nanoid(10)}`,
|
||||
shareId: share.id,
|
||||
taskId: share.taskId,
|
||||
attemptId: share.snapshot.attemptId,
|
||||
type,
|
||||
actor,
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildForkDescription(
|
||||
parent: Task,
|
||||
share: RunSessionShare,
|
||||
reason?: string
|
||||
): Promise<string> {
|
||||
const lines = [
|
||||
`Forked from shared live run session \`${share.id}\`.`,
|
||||
'',
|
||||
`Parent task: \`${parent.id}\``,
|
||||
share.snapshot.attemptId ? `Parent attempt: \`${share.snapshot.attemptId}\`` : undefined,
|
||||
share.snapshot.agent ? `Agent: \`${share.snapshot.agent}\`` : undefined,
|
||||
share.snapshot.provider ? `Provider: \`${share.snapshot.provider}\`` : undefined,
|
||||
share.snapshot.model ? `Model: \`${share.snapshot.model}\`` : undefined,
|
||||
reason ? `Fork reason: ${this.redactForkText(reason)}` : undefined,
|
||||
'',
|
||||
'This fork intentionally does not inherit worktrees, thread IDs, credentials, or local-only handles.',
|
||||
'',
|
||||
'## Parent Context',
|
||||
'',
|
||||
this.redactForkText(parent.description || 'No parent task description.'),
|
||||
].filter((line): line is string => typeof line === 'string');
|
||||
|
||||
const attemptId = share.snapshot.attemptId;
|
||||
if (attemptId) {
|
||||
try {
|
||||
const log = await this.agentService.getAttemptLog(parent.id, attemptId);
|
||||
const excerpt = this.redactForkText(log.slice(-LOG_CONTEXT_LIMIT));
|
||||
lines.push('', '## Redacted Run Excerpt', '', '```text', excerpt, '```');
|
||||
} catch {
|
||||
lines.push('', '## Redacted Run Excerpt', '', 'No attempt log was available.');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private redactForkText(value: string): string {
|
||||
return redactString(value)
|
||||
.replace(/\/Users\/[^/\s]+\/[^\s)]+/g, '[redacted-local-path]')
|
||||
.replace(/[A-Z]:\\Users\\[^\\\s]+\\[^\s)]+/g, '[redacted-local-path]');
|
||||
}
|
||||
|
||||
private async loadState(): Promise<RunSessionShareState> {
|
||||
if (this.state) return this.state;
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8');
|
||||
this.state = JSON.parse(raw) as RunSessionShareState;
|
||||
} catch {
|
||||
this.state = { shares: [], events: [], forks: [] };
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private async saveState(state: RunSessionShareState): Promise<void> {
|
||||
state.events = state.events.slice(-MAX_EVENT_HISTORY);
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
let runSessionShareService: RunSessionShareService | null = null;
|
||||
|
||||
export function getRunSessionShareService(): RunSessionShareService {
|
||||
runSessionShareService ??= new RunSessionShareService();
|
||||
return runSessionShareService;
|
||||
}
|
||||
|
||||
export function resetRunSessionShareServiceForTests(): void {
|
||||
runSessionShareService = null;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export type WebSocketEventChannel =
|
|||
| 'squad'
|
||||
| 'tasks'
|
||||
| 'telemetry'
|
||||
| 'run-sessions'
|
||||
| 'workflows';
|
||||
|
||||
export interface WebSocketDeliveryOptions {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export * from './shared-resources.types.js';
|
|||
export * from './doc-freshness.types.js';
|
||||
export * from './drift.types.js';
|
||||
export * from './decision.types.js';
|
||||
export * from './run-session.types.js';
|
||||
export * from './evaluation.types.js';
|
||||
export * from './policy.types.js';
|
||||
export * from './prompt-registry.types.js';
|
||||
|
|
|
|||
120
shared/src/types/run-session.types.ts
Normal file
120
shared/src/types/run-session.types.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { AgentType, AttemptStatus, TaskPriority } from './task.types.js';
|
||||
|
||||
export type RunSessionPermission = 'view' | 'edit' | 'fork';
|
||||
export type RunSessionShareStatus = 'active' | 'revoked' | 'expired';
|
||||
export type RunSessionSourceType = 'task-agent' | 'workflow-run';
|
||||
export type RunSessionEventType =
|
||||
| 'share.created'
|
||||
| 'share.updated'
|
||||
| 'share.revoked'
|
||||
| 'message.sent'
|
||||
| 'approval.responded'
|
||||
| 'fork.created';
|
||||
|
||||
export interface RunSessionActor {
|
||||
id: string;
|
||||
label?: string;
|
||||
type?: 'user' | 'agent' | 'service' | 'device' | 'localhost-bypass';
|
||||
authMethod?: string;
|
||||
clientMode?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface RunSessionSnapshot {
|
||||
running: boolean;
|
||||
taskTitle?: string;
|
||||
attemptId?: string;
|
||||
attemptStatus?: AttemptStatus;
|
||||
agent?: AgentType;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
startedAt?: string;
|
||||
worktreePath?: string;
|
||||
changedFiles?: number;
|
||||
artifactCount?: number;
|
||||
blocker?: string;
|
||||
}
|
||||
|
||||
export interface RunSessionShare {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
taskId: string;
|
||||
sourceType: RunSessionSourceType;
|
||||
sourceId: string;
|
||||
permission: RunSessionPermission;
|
||||
status: RunSessionShareStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: RunSessionActor;
|
||||
expiresAt?: string;
|
||||
revokedAt?: string;
|
||||
revokedBy?: RunSessionActor;
|
||||
revokedReason?: string;
|
||||
actorLabel?: string;
|
||||
stablePath: string;
|
||||
mobileSafeApprovalClasses: string[];
|
||||
snapshot: RunSessionSnapshot;
|
||||
forkedTaskIds: string[];
|
||||
}
|
||||
|
||||
export interface CreateRunSessionShareInput {
|
||||
taskId: string;
|
||||
permission: RunSessionPermission;
|
||||
expiresAt?: string;
|
||||
actorLabel?: string;
|
||||
mobileSafeApprovalClasses?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateRunSessionShareInput {
|
||||
permission?: RunSessionPermission;
|
||||
expiresAt?: string | null;
|
||||
actorLabel?: string;
|
||||
mobileSafeApprovalClasses?: string[];
|
||||
}
|
||||
|
||||
export interface RunSessionShareListFilters {
|
||||
taskId?: string;
|
||||
status?: RunSessionShareStatus;
|
||||
}
|
||||
|
||||
export interface SendRunSessionMessageInput {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RunSessionApprovalResponseInput {
|
||||
actionClass: string;
|
||||
response: 'approved' | 'rejected';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ForkRunSessionInput {
|
||||
title?: string;
|
||||
priority?: TaskPriority;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RunSessionFork {
|
||||
id: string;
|
||||
shareId: string;
|
||||
parentTaskId: string;
|
||||
parentAttemptId?: string;
|
||||
forkTaskId: string;
|
||||
createdAt: string;
|
||||
createdBy: RunSessionActor;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RunSessionEvent {
|
||||
id: string;
|
||||
shareId: string;
|
||||
taskId: string;
|
||||
attemptId?: string;
|
||||
type: RunSessionEventType;
|
||||
actor: RunSessionActor;
|
||||
createdAt: string;
|
||||
message?: string;
|
||||
actionClass?: string;
|
||||
approvalResponse?: 'approved' | 'rejected';
|
||||
forkTaskId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -183,6 +183,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
|
|||
{ methods: ['POST'], path: /^\/route\/?$/, permissions: 'agent:read' },
|
||||
{ methods: ['POST'], path: /^\/hosts\/preview\/?$/, permissions: 'agent:read' },
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
|
||||
{ methods: ['POST'], path: /^\/[^/]+\/message\/?$/, permissions: 'task:write' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -314,6 +315,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
|
|||
},
|
||||
{ prefix: '/api/system/health', read: 'workspace:read', write: 'admin:manage' },
|
||||
{ prefix: '/api/decisions', read: 'task:read', write: 'task:write' },
|
||||
{ prefix: '/api/run-sessions', read: 'task:read', write: 'task:write' },
|
||||
{ prefix: '/api/governance/traces', read: 'policy:read' },
|
||||
{ prefix: '/api/feedback', read: 'report:read', write: 'comment:write' },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { usePendingProductMode } from './hooks/usePendingProductMode';
|
|||
import { DesktopShellProvider, useDesktopShell } from './components/layout/DesktopShellContext';
|
||||
import { DesktopLeftSidebar } from './components/layout/DesktopLeftSidebar';
|
||||
import { DesktopBottomPanel } from './components/layout/DesktopBottomPanel';
|
||||
import { RunSessionShareView } from './components/task/RunSessionSharesSection';
|
||||
|
||||
const LAZY_VIEW_COMPONENTS = Object.fromEntries(
|
||||
NAVIGATION_VIEWS.map((definition) => {
|
||||
|
|
@ -69,9 +70,19 @@ function ViewLoading({ view }: { view: AppView }) {
|
|||
);
|
||||
}
|
||||
|
||||
function runSessionShareIdFromLocation(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const path = window.location.pathname.replace(/\/+$/, '');
|
||||
const match = path.match(/\/runs\/shared\/([^/]+)$/);
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/** Renders the current view (board, activity feed, or backlog). */
|
||||
function MainContent() {
|
||||
const { view, setView, navigateToTask } = useView();
|
||||
const runSessionShareId = runSessionShareIdFromLocation();
|
||||
|
||||
if (runSessionShareId) return <RunSessionShareView shareId={runSessionShareId} />;
|
||||
|
||||
if (view === 'board') return <KanbanBoard />;
|
||||
|
||||
|
|
|
|||
244
web/src/__tests__/run-session-shares-mantine.test.tsx
Normal file
244
web/src/__tests__/run-session-shares-mantine.test.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { RunSessionEvent, RunSessionShare } from '@veritas-kanban/shared';
|
||||
|
||||
import {
|
||||
RunSessionShareView,
|
||||
RunSessionSharesSection,
|
||||
} from '@/components/task/RunSessionSharesSection';
|
||||
import { createMockTask, renderWithProviders } from './test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useRunSessions: vi.fn(),
|
||||
useRunSession: vi.fn(),
|
||||
useRunSessionEvents: vi.fn(),
|
||||
useRunSessionEventStream: vi.fn(),
|
||||
createShareMutateAsync: vi.fn(),
|
||||
updateShareMutate: vi.fn(),
|
||||
revokeShareMutate: vi.fn(),
|
||||
sendMessageMutateAsync: vi.fn(),
|
||||
approvalMutateAsync: vi.fn(),
|
||||
forkMutateAsync: vi.fn(),
|
||||
useAgentStream: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
identity: {
|
||||
authContext: { clientMode: 'desktop' },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useRunSessions', () => ({
|
||||
useRunSessions: mocks.useRunSessions,
|
||||
useRunSession: mocks.useRunSession,
|
||||
useRunSessionEvents: mocks.useRunSessionEvents,
|
||||
useRunSessionEventStream: mocks.useRunSessionEventStream,
|
||||
useCreateRunSessionShare: () => ({
|
||||
mutateAsync: mocks.createShareMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateRunSessionShare: () => ({
|
||||
mutate: mocks.updateShareMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useRevokeRunSessionShare: () => ({
|
||||
mutate: mocks.revokeShareMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useSendRunSessionMessage: () => ({
|
||||
mutateAsync: mocks.sendMessageMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useRunSessionApprovalResponse: () => ({
|
||||
mutateAsync: mocks.approvalMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useForkRunSession: () => ({
|
||||
mutateAsync: mocks.forkMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useAgent', () => ({
|
||||
useAgentStream: mocks.useAgentStream,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useIdentity', () => ({
|
||||
useIdentity: () => mocks.identity,
|
||||
}));
|
||||
|
||||
const baseShare: RunSessionShare = {
|
||||
id: 'run_share_721',
|
||||
workspaceId: 'local',
|
||||
taskId: 'task-721',
|
||||
sourceType: 'task-agent',
|
||||
sourceId: 'attempt-721',
|
||||
permission: 'view',
|
||||
status: 'active',
|
||||
createdAt: '2026-06-18T10:00:00.000Z',
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
createdBy: { id: 'user-1', label: 'Brad', workspaceId: 'local' },
|
||||
actorLabel: 'Reviewer',
|
||||
stablePath: '/runs/shared/run_share_721',
|
||||
mobileSafeApprovalClasses: ['human-review'],
|
||||
snapshot: {
|
||||
running: true,
|
||||
taskTitle: 'Shared live sessions',
|
||||
attemptId: 'attempt-721',
|
||||
attemptStatus: 'running',
|
||||
agent: 'codex',
|
||||
model: 'gpt-5',
|
||||
startedAt: '2026-06-18T10:00:00.000Z',
|
||||
},
|
||||
forkedTaskIds: [],
|
||||
};
|
||||
|
||||
const event: RunSessionEvent = {
|
||||
id: 'run_event_msg',
|
||||
shareId: baseShare.id,
|
||||
taskId: baseShare.taskId,
|
||||
attemptId: baseShare.snapshot.attemptId,
|
||||
type: 'message.sent',
|
||||
actor: { id: 'editor-1', label: 'Pair Editor', workspaceId: 'local' },
|
||||
createdAt: '2026-06-18T10:02:00.000Z',
|
||||
message: 'Continue the run',
|
||||
};
|
||||
|
||||
describe('run session share Mantine surfaces', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.useRunSessions.mockReturnValue({ data: [baseShare], isLoading: false });
|
||||
mocks.useRunSession.mockReturnValue({ data: baseShare, isLoading: false, error: null });
|
||||
mocks.useRunSessionEvents.mockReturnValue({ data: [event], isLoading: false });
|
||||
mocks.useAgentStream.mockReturnValue({
|
||||
outputs: [
|
||||
{
|
||||
type: 'stdout',
|
||||
content: 'agent streamed output',
|
||||
timestamp: '2026-06-18T10:01:00.000Z',
|
||||
},
|
||||
],
|
||||
isConnected: true,
|
||||
isRunning: true,
|
||||
clearOutputs: vi.fn(),
|
||||
});
|
||||
mocks.createShareMutateAsync.mockResolvedValue(baseShare);
|
||||
mocks.sendMessageMutateAsync.mockResolvedValue(event);
|
||||
mocks.approvalMutateAsync.mockResolvedValue({
|
||||
...event,
|
||||
type: 'approval.responded',
|
||||
actionClass: 'human-review',
|
||||
approvalResponse: 'approved',
|
||||
});
|
||||
mocks.forkMutateAsync.mockResolvedValue({
|
||||
fork: { id: 'run_fork_721' },
|
||||
task: { id: 'task-721-fork', title: 'Forked task' },
|
||||
});
|
||||
mocks.identity.authContext = { clientMode: 'desktop' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('creates links, upgrades view-only shares, and revokes active shares from task detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const task = createMockTask({ id: 'task-721', title: 'Shared live sessions' });
|
||||
|
||||
renderWithProviders(<RunSessionSharesSection task={task} isAgentRunning />);
|
||||
|
||||
expect(screen.getByText('Shared Live Sessions')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Create Link' }));
|
||||
expect(mocks.createShareMutateAsync).toHaveBeenCalledWith({
|
||||
taskId: 'task-721',
|
||||
permission: 'view',
|
||||
mobileSafeApprovalClasses: ['human-review', 'task-comment', 'low-risk'],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Co-drive' }));
|
||||
expect(mocks.updateShareMutate).toHaveBeenCalledWith({
|
||||
shareId: 'run_share_721',
|
||||
input: { permission: 'edit' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Revoke' }));
|
||||
expect(mocks.revokeShareMutate).toHaveBeenCalledWith({ shareId: 'run_share_721' });
|
||||
});
|
||||
|
||||
it('renders a view-only shared session without edit or fork controls', () => {
|
||||
renderWithProviders(<RunSessionShareView shareId="run_share_721" />);
|
||||
|
||||
expect(screen.getByText('Shared live sessions')).toBeTruthy();
|
||||
expect(screen.getByText('agent streamed output')).toBeTruthy();
|
||||
expect(screen.getByText('message.sent')).toBeTruthy();
|
||||
expect(screen.queryByText('Co-drive Message')).toBeNull();
|
||||
expect(screen.queryByText('Fork Session')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends attributed co-drive messages and mobile-safe approval responses', async () => {
|
||||
const user = userEvent.setup();
|
||||
mocks.identity.authContext = { clientMode: 'mobile-pwa' };
|
||||
mocks.useRunSession.mockReturnValue({
|
||||
data: { ...baseShare, permission: 'edit' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<RunSessionShareView shareId="run_share_721" />);
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Send an attributed message into the run...'),
|
||||
'Run the narrow verification gate'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Send Message' }));
|
||||
expect(mocks.sendMessageMutateAsync).toHaveBeenCalledWith({
|
||||
shareId: 'run_share_721',
|
||||
input: { message: 'Run the narrow verification gate' },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Mobile clients can respond only to classes marked mobile-safe for this share.'
|
||||
)
|
||||
).toBeTruthy();
|
||||
await user.type(screen.getByLabelText('Note'), 'Looks safe from mobile');
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.approvalMutateAsync).toHaveBeenCalledWith({
|
||||
shareId: 'run_share_721',
|
||||
input: {
|
||||
actionClass: 'human-review',
|
||||
response: 'approved',
|
||||
note: 'Looks safe from mobile',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('forks a fork-permission share without enabling co-drive controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
mocks.useRunSession.mockReturnValue({
|
||||
data: { ...baseShare, permission: 'fork' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<RunSessionShareView shareId="run_share_721" />);
|
||||
|
||||
expect(screen.queryByText('Co-drive Message')).toBeNull();
|
||||
await user.type(screen.getByLabelText('Fork task title'), 'Continue in a clean fork');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Fork' }));
|
||||
|
||||
expect(mocks.forkMutateAsync).toHaveBeenCalledWith({
|
||||
shareId: 'run_share_721',
|
||||
input: {
|
||||
title: 'Continue in a clean fork',
|
||||
reason: 'Forked from shared live run session.',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -225,7 +225,7 @@ describe('task detail agent, template, and metrics Mantine migration', () => {
|
|||
|
||||
const { baseElement, container } = renderWithProviders(<AgentPanel task={task} />);
|
||||
|
||||
expect(container.querySelectorAll('.mantine-Select-root')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('.mantine-Select-root')).toHaveLength(3);
|
||||
expect(container.querySelector('.mantine-Button-root')).toBeDefined();
|
||||
expect(container.querySelector('.mantine-Badge-root')).toBeDefined();
|
||||
expect(baseElement.querySelector('[data-slot="select-trigger"]')).toBeNull();
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { sanitizeText } from '@/lib/sanitize';
|
|||
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
|
||||
import { useIdentity } from '@/hooks/useIdentity';
|
||||
import { clientAllowsLocalAgentControls } from '@/lib/client-policy';
|
||||
import { RunSessionSharesSection } from './RunSessionSharesSection';
|
||||
|
||||
interface AgentPanelProps {
|
||||
task: Task;
|
||||
|
|
@ -524,6 +525,8 @@ export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) {
|
|||
</Paper>
|
||||
)}
|
||||
|
||||
<RunSessionSharesSection task={task} isAgentRunning={isAgentRunning} />
|
||||
|
||||
<Modal
|
||||
opened={stopDialogOpen}
|
||||
onClose={() => setStopDialogOpen(false)}
|
||||
|
|
|
|||
496
web/src/components/task/RunSessionSharesSection.tsx
Normal file
496
web/src/components/task/RunSessionSharesSection.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
CopyButton,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
GitFork,
|
||||
Link,
|
||||
MessageSquare,
|
||||
ShieldCheck,
|
||||
Share2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { RunSessionPermission, RunSessionShare, Task } from '@veritas-kanban/shared';
|
||||
import {
|
||||
useCreateRunSessionShare,
|
||||
useForkRunSession,
|
||||
useRevokeRunSessionShare,
|
||||
useRunSession,
|
||||
useRunSessionApprovalResponse,
|
||||
useRunSessionEvents,
|
||||
useRunSessionEventStream,
|
||||
useRunSessions,
|
||||
useSendRunSessionMessage,
|
||||
useUpdateRunSessionShare,
|
||||
} from '@/hooks/useRunSessions';
|
||||
import { useAgentStream } from '@/hooks/useAgent';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { sanitizeText } from '@/lib/sanitize';
|
||||
import { useIdentity } from '@/hooks/useIdentity';
|
||||
|
||||
interface RunSessionSharesSectionProps {
|
||||
task: Task;
|
||||
isAgentRunning?: boolean;
|
||||
}
|
||||
|
||||
const permissionOptions: Array<{ value: RunSessionPermission; label: string }> = [
|
||||
{ value: 'view', label: 'View' },
|
||||
{ value: 'edit', label: 'Co-drive' },
|
||||
{ value: 'fork', label: 'Fork' },
|
||||
];
|
||||
|
||||
function absoluteShareUrl(path: string): string {
|
||||
if (typeof window === 'undefined') return path;
|
||||
return new URL(path, window.location.origin).toString();
|
||||
}
|
||||
|
||||
function permissionLabel(permission: RunSessionPermission): string {
|
||||
if (permission === 'edit') return 'Co-drive';
|
||||
if (permission === 'fork') return 'Fork';
|
||||
return 'View';
|
||||
}
|
||||
|
||||
function canSendMessages(share?: RunSessionShare): boolean {
|
||||
return share?.status === 'active' && share.permission === 'edit';
|
||||
}
|
||||
|
||||
function canFork(share?: RunSessionShare): boolean {
|
||||
return share?.status === 'active' && share.permission === 'fork';
|
||||
}
|
||||
|
||||
export function RunSessionSharesSection({ task, isAgentRunning }: RunSessionSharesSectionProps) {
|
||||
const [permission, setPermission] = useState<RunSessionPermission>('view');
|
||||
const { data: shares = [] } = useRunSessions({ taskId: task.id });
|
||||
const createShare = useCreateRunSessionShare();
|
||||
const updateShare = useUpdateRunSessionShare();
|
||||
const revokeShare = useRevokeRunSessionShare();
|
||||
const { toast } = useToast();
|
||||
useRunSessionEventStream(task.id);
|
||||
|
||||
const activeShares = shares.filter((share) => share.status === 'active');
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const share = await createShare.mutateAsync({
|
||||
taskId: task.id,
|
||||
permission,
|
||||
mobileSafeApprovalClasses: ['human-review', 'task-comment', 'low-risk'],
|
||||
});
|
||||
toast({
|
||||
title: 'Shared run session created',
|
||||
description: `${permissionLabel(share.permission)} link ready.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create shared session',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Share2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Shared Live Sessions
|
||||
</Text>
|
||||
</Group>
|
||||
{isAgentRunning && (
|
||||
<Badge variant="light" color="green">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Paper className="p-3" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Select
|
||||
label="Permission"
|
||||
size="xs"
|
||||
value={permission}
|
||||
onChange={(value) => setPermission((value as RunSessionPermission) || 'view')}
|
||||
data={permissionOptions}
|
||||
className="w-[140px]"
|
||||
checkIconPosition="right"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<Link className="h-3 w-3" />}
|
||||
loading={createShare.isPending}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{activeShares.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No active shared run sessions.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{activeShares.map((share) => (
|
||||
<Group key={share.id} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Group gap="xs" className="min-w-0">
|
||||
<Users className="h-3 w-3 text-muted-foreground" />
|
||||
<Code className="truncate text-xs">{share.id}</Code>
|
||||
<Badge size="xs" variant="outline">
|
||||
{permissionLabel(share.permission)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{share.permission === 'view' && (
|
||||
<Tooltip label="Upgrade to co-drive">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
updateShare.mutate({
|
||||
shareId: share.id,
|
||||
input: { permission: 'edit' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Co-drive
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CopyButton value={absoluteShareUrl(share.stablePath)}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<Copy className="h-3 w-3" />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => revokeShare.mutate({ shareId: share.id })}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunSessionShareView({ shareId }: { shareId: string }) {
|
||||
const { authContext } = useIdentity();
|
||||
const { data: share, isLoading, error } = useRunSession(shareId);
|
||||
const { data: events = [] } = useRunSessionEvents(shareId);
|
||||
const { outputs, isConnected } = useAgentStream(share?.taskId);
|
||||
const sendMessage = useSendRunSessionMessage();
|
||||
const respondToApproval = useRunSessionApprovalResponse();
|
||||
const forkSession = useForkRunSession();
|
||||
const { toast } = useToast();
|
||||
const [message, setMessage] = useState('');
|
||||
const [approvalClass, setApprovalClass] = useState('human-review');
|
||||
const [approvalNote, setApprovalNote] = useState('');
|
||||
const [forkTitle, setForkTitle] = useState('');
|
||||
useRunSessionEventStream(share?.taskId);
|
||||
|
||||
const shareUrl = useMemo(() => (share ? absoluteShareUrl(share.stablePath) : ''), [share]);
|
||||
const mobileClient = authContext?.clientMode === 'mobile-pwa';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text c="dimmed">Loading shared run session...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!share || error) {
|
||||
return (
|
||||
<Alert color="red" icon={<AlertCircle className="h-4 w-4" />}>
|
||||
Shared run session is unavailable, revoked, expired, or outside your workspace.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
await sendMessage.mutateAsync({ shareId: share.id, input: { message: trimmed } });
|
||||
setMessage('');
|
||||
} catch (sendError) {
|
||||
toast({
|
||||
title: 'Message not sent',
|
||||
description: sendError instanceof Error ? sendError.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproval = async (response: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await respondToApproval.mutateAsync({
|
||||
shareId: share.id,
|
||||
input: {
|
||||
actionClass: approvalClass,
|
||||
response,
|
||||
note: approvalNote.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setApprovalNote('');
|
||||
} catch (approvalError) {
|
||||
toast({
|
||||
title: 'Approval response blocked',
|
||||
description: approvalError instanceof Error ? approvalError.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFork = async () => {
|
||||
try {
|
||||
const result = await forkSession.mutateAsync({
|
||||
shareId: share.id,
|
||||
input: {
|
||||
title: forkTitle.trim() || undefined,
|
||||
reason: 'Forked from shared live run session.',
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: 'Fork created',
|
||||
description: result.task.title,
|
||||
});
|
||||
setForkTitle('');
|
||||
} catch (forkError) {
|
||||
toast({
|
||||
title: 'Fork failed',
|
||||
description: forkError instanceof Error ? forkError.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Shared live run
|
||||
</Text>
|
||||
<Text fw={700} size="xl">
|
||||
{share.snapshot.taskTitle || share.taskId}
|
||||
</Text>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Badge variant="light">{permissionLabel(share.permission)}</Badge>
|
||||
<Badge variant="outline" color={share.status === 'active' ? 'green' : 'red'}>
|
||||
{share.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">{isConnected ? 'Connected' : 'Disconnected'}</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
<CopyButton value={shareUrl}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Copy className="h-3 w-3" />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy Link'}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<Paper className="p-3" radius="md" withBorder>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
Run Snapshot
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code>{share.snapshot.attemptId || 'no-attempt'}</Code>
|
||||
{share.snapshot.agent && <Badge variant="outline">{share.snapshot.agent}</Badge>}
|
||||
{share.snapshot.model && <Badge variant="outline">{share.snapshot.model}</Badge>}
|
||||
</Group>
|
||||
{share.snapshot.blocker && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Current blocker: {share.snapshot.blocker}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper className="overflow-hidden" radius="md" withBorder>
|
||||
<Group justify="space-between" className="border-b bg-card p-2">
|
||||
<Text size="sm" fw={600}>
|
||||
Live Output
|
||||
</Text>
|
||||
<Badge variant="dot" color={share.snapshot.running ? 'green' : 'gray'}>
|
||||
{share.snapshot.running ? 'running' : 'not running'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<div className="h-[320px] overflow-y-auto bg-zinc-950 p-3 font-mono text-xs text-zinc-200">
|
||||
{outputs.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No live output received in this viewer.
|
||||
</Text>
|
||||
) : (
|
||||
outputs.map((output, index) => (
|
||||
<div key={`${output.timestamp}-${index}`} className="whitespace-pre-wrap break-all">
|
||||
{sanitizeText(output.content)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
{canSendMessages(share) && (
|
||||
<Paper className="p-3" radius="md" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<Text size="sm" fw={600}>
|
||||
Co-drive Message
|
||||
</Text>
|
||||
</Group>
|
||||
<Textarea
|
||||
minRows={2}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.currentTarget.value)}
|
||||
placeholder="Send an attributed message into the run..."
|
||||
/>
|
||||
<Button size="xs" loading={sendMessage.isPending} onClick={handleSendMessage}>
|
||||
Send Message
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{canSendMessages(share) && (
|
||||
<Paper className="p-3" radius="md" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<Text size="sm" fw={600}>
|
||||
Approval Response
|
||||
</Text>
|
||||
</Group>
|
||||
<Select
|
||||
label="Action class"
|
||||
size="xs"
|
||||
value={approvalClass}
|
||||
onChange={(value) => setApprovalClass(value || 'human-review')}
|
||||
data={share.mobileSafeApprovalClasses.map((item) => ({ value: item, label: item }))}
|
||||
checkIconPosition="right"
|
||||
/>
|
||||
{mobileClient && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Mobile clients can respond only to classes marked mobile-safe for this share.
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
label="Note"
|
||||
size="xs"
|
||||
value={approvalNote}
|
||||
onChange={(event) => setApprovalNote(event.currentTarget.value)}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" onClick={() => handleApproval('approved')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => handleApproval('rejected')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{canFork(share) && (
|
||||
<Paper className="p-3" radius="md" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<GitFork className="h-4 w-4 text-muted-foreground" />
|
||||
<Text size="sm" fw={600}>
|
||||
Fork Session
|
||||
</Text>
|
||||
</Group>
|
||||
<TextInput
|
||||
size="xs"
|
||||
label="Fork task title"
|
||||
placeholder={`Fork: ${share.snapshot.taskTitle || share.taskId}`}
|
||||
value={forkTitle}
|
||||
onChange={(event) => setForkTitle(event.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={forkSession.isPending}
|
||||
onClick={handleFork}
|
||||
leftSection={<GitFork className="h-3 w-3" />}
|
||||
>
|
||||
Create Fork
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper className="p-3" radius="md" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Session Events
|
||||
</Text>
|
||||
{events.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No share events recorded yet.
|
||||
</Text>
|
||||
) : (
|
||||
events
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event) => (
|
||||
<Group key={event.id} gap="xs" align="flex-start" wrap="nowrap">
|
||||
<Badge size="xs" variant="outline">
|
||||
{event.type}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{event.actor.label || event.actor.id} at{' '}
|
||||
{new Date(event.createdAt).toLocaleString()}
|
||||
{event.forkTaskId ? ` forked ${event.forkTaskId}` : ''}
|
||||
{event.message ? `: ${event.message}` : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
134
web/src/hooks/useRunSessions.ts
Normal file
134
web/src/hooks/useRunSessions.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
CreateRunSessionShareInput,
|
||||
ForkRunSessionInput,
|
||||
RunSessionApprovalResponseInput,
|
||||
RunSessionEvent,
|
||||
RunSessionShareListFilters,
|
||||
SendRunSessionMessageInput,
|
||||
UpdateRunSessionShareInput,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { useWebSocket, type WebSocketMessage } from './useWebSocket';
|
||||
|
||||
function invalidateRunSessionQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
taskId?: string,
|
||||
shareId?: string
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: ['run-sessions'] });
|
||||
if (taskId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['run-sessions', { taskId }] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', taskId] });
|
||||
}
|
||||
if (shareId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['run-sessions', shareId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['run-sessions', shareId, 'events'] });
|
||||
}
|
||||
}
|
||||
|
||||
export function useRunSessions(filters: RunSessionShareListFilters = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['run-sessions', filters],
|
||||
queryFn: () => api.runSessions.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunSession(shareId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['run-sessions', shareId],
|
||||
queryFn: () => api.runSessions.get(shareId || ''),
|
||||
enabled: Boolean(shareId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunSessionEvents(shareId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['run-sessions', shareId, 'events'],
|
||||
queryFn: () => api.runSessions.events(shareId || ''),
|
||||
enabled: Boolean(shareId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRunSessionShare() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateRunSessionShareInput) => api.runSessions.create(input),
|
||||
onSuccess: (share) => invalidateRunSessionQueries(queryClient, share.taskId, share.id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRunSessionShare() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ shareId, input }: { shareId: string; input: UpdateRunSessionShareInput }) =>
|
||||
api.runSessions.update(shareId, input),
|
||||
onSuccess: (share) => invalidateRunSessionQueries(queryClient, share.taskId, share.id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeRunSessionShare() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ shareId, reason }: { shareId: string; reason?: string }) =>
|
||||
api.runSessions.revoke(shareId, reason),
|
||||
onSuccess: (share) => invalidateRunSessionQueries(queryClient, share.taskId, share.id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendRunSessionMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ shareId, input }: { shareId: string; input: SendRunSessionMessageInput }) =>
|
||||
api.runSessions.sendMessage(shareId, input),
|
||||
onSuccess: (event) => invalidateRunSessionQueries(queryClient, event.taskId, event.shareId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunSessionApprovalResponse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ shareId, input }: { shareId: string; input: RunSessionApprovalResponseInput }) =>
|
||||
api.runSessions.respondToApproval(shareId, input),
|
||||
onSuccess: (event) => invalidateRunSessionQueries(queryClient, event.taskId, event.shareId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useForkRunSession() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ shareId, input }: { shareId: string; input: ForkRunSessionInput }) =>
|
||||
api.runSessions.fork(shareId, input),
|
||||
onSuccess: ({ fork }) => {
|
||||
invalidateRunSessionQueries(queryClient, fork.parentTaskId, fork.shareId);
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isRunSessionEvent(message: WebSocketMessage): message is WebSocketMessage & {
|
||||
type: 'run-session:event';
|
||||
event: RunSessionEvent;
|
||||
} {
|
||||
return message.type === 'run-session:event' && typeof message.event === 'object';
|
||||
}
|
||||
|
||||
export function useRunSessionEventStream(taskId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const onMessage = useCallback(
|
||||
(message: WebSocketMessage) => {
|
||||
if (!isRunSessionEvent(message)) return;
|
||||
const event = message.event;
|
||||
if (taskId && event.taskId !== taskId) return;
|
||||
invalidateRunSessionQueries(queryClient, event.taskId, event.shareId);
|
||||
},
|
||||
[queryClient, taskId]
|
||||
);
|
||||
|
||||
useWebSocket({
|
||||
autoConnect: true,
|
||||
onOpen: { type: 'run-session:subscribe' },
|
||||
onMessage,
|
||||
});
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import { scheduledDeliverablesApi } from './deliverables';
|
|||
import { evidenceApi } from './evidence';
|
||||
import { timeBreakdownsApi } from './time-breakdowns';
|
||||
import { sandboxPoliciesApi } from './sandbox-policies';
|
||||
import { runSessionsApi } from './run-sessions';
|
||||
|
||||
// Assemble the full API object (matches original structure exactly)
|
||||
export const api = {
|
||||
|
|
@ -67,6 +68,7 @@ export const api = {
|
|||
evidence: evidenceApi,
|
||||
timeBreakdowns: timeBreakdownsApi,
|
||||
sandboxPolicies: sandboxPoliciesApi,
|
||||
runSessions: runSessionsApi,
|
||||
};
|
||||
|
||||
export type {
|
||||
|
|
@ -197,6 +199,9 @@ export type {
|
|||
DecisionReviewSession,
|
||||
DecisionWithChain,
|
||||
DecisionListFilters,
|
||||
RunSessionEvent,
|
||||
RunSessionFork,
|
||||
RunSessionShare,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export type {
|
||||
|
|
|
|||
119
web/src/lib/api/run-sessions.ts
Normal file
119
web/src/lib/api/run-sessions.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type {
|
||||
CreateRunSessionShareInput,
|
||||
ForkRunSessionInput,
|
||||
RunSessionApprovalResponseInput,
|
||||
RunSessionEvent,
|
||||
RunSessionFork,
|
||||
RunSessionShare,
|
||||
RunSessionShareListFilters,
|
||||
SendRunSessionMessageInput,
|
||||
Task,
|
||||
UpdateRunSessionShareInput,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { API_BASE, handleResponse } from './helpers';
|
||||
|
||||
function queryFromFilters(filters: RunSessionShareListFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.taskId) params.set('taskId', filters.taskId);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export const runSessionsApi = {
|
||||
list: async (filters: RunSessionShareListFilters = {}): Promise<RunSessionShare[]> => {
|
||||
const query = queryFromFilters(filters);
|
||||
const response = await fetch(`${API_BASE}/run-sessions${query ? `?${query}` : ''}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<RunSessionShare[]>(response);
|
||||
},
|
||||
|
||||
create: async (input: CreateRunSessionShareInput): Promise<RunSessionShare> => {
|
||||
const response = await fetch(`${API_BASE}/run-sessions`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<RunSessionShare>(response);
|
||||
},
|
||||
|
||||
get: async (shareId: string): Promise<RunSessionShare> => {
|
||||
const response = await fetch(`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<RunSessionShare>(response);
|
||||
},
|
||||
|
||||
events: async (shareId: string): Promise<RunSessionEvent[]> => {
|
||||
const response = await fetch(`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}/events`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<RunSessionEvent[]>(response);
|
||||
},
|
||||
|
||||
update: async (shareId: string, input: UpdateRunSessionShareInput): Promise<RunSessionShare> => {
|
||||
const response = await fetch(`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}`, {
|
||||
credentials: 'include',
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<RunSessionShare>(response);
|
||||
},
|
||||
|
||||
revoke: async (shareId: string, reason?: string): Promise<RunSessionShare> => {
|
||||
const response = await fetch(`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}/revoke`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
return handleResponse<RunSessionShare>(response);
|
||||
},
|
||||
|
||||
sendMessage: async (
|
||||
shareId: string,
|
||||
input: SendRunSessionMessageInput
|
||||
): Promise<RunSessionEvent> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}/messages`,
|
||||
{
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
return handleResponse<RunSessionEvent>(response);
|
||||
},
|
||||
|
||||
respondToApproval: async (
|
||||
shareId: string,
|
||||
input: RunSessionApprovalResponseInput
|
||||
): Promise<RunSessionEvent> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}/approvals`,
|
||||
{
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
return handleResponse<RunSessionEvent>(response);
|
||||
},
|
||||
|
||||
fork: async (
|
||||
shareId: string,
|
||||
input: ForkRunSessionInput
|
||||
): Promise<{ fork: RunSessionFork; task: Task }> => {
|
||||
const response = await fetch(`${API_BASE}/run-sessions/${encodeURIComponent(shareId)}/fork`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<{ fork: RunSessionFork; task: Task }>(response);
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue