P2 BLOCKING exit green: approval interception chain end-to-end
- tc-tools: Effect declarations -> §15 GatedCategory mapping, deny-by-default external reach, taint invariant property-tested (tainted external effects are NEVER auto-allowed) - tc-safety: pending approvals with exact payload+preview, CAS decide with audit + single-use grant in one tx, checkpoint suspend/load, exclusive resume claim, expiry sweep, decided-unresumed work queue (migration 0004 adds the outbox the gated email.send tool writes) - tc-runtime: resumable LoopState checkpointed to agent_runs; gated tool -> approval row -> approval_required/run_suspended events -> suspend; resume consumes the grant BEFORE executing (spent grant = no execution), rejection feeds a structured refusal in-band; durable resume sweeper; continuous journal seq across suspension (tested). ContentPart::Text became a struct variant — internally-tagged newtype primitives don't serialize - tc-api: GET/decide approvals endpoints (409 double-decide, tenant isolation), decision triggers in-process resume; full chain proven over HTTP incl. gateway resumeFrom continuation - frontend: approval_required/run_suspended events, suspended reply state, inline ApprovalCard (§10: summary, category, exact payload preview, approve/reject -> decide + stream re-attach), /approvals queue page, nav - E2E (14 journeys, workers:1 to serialize the shared backend): gated email blocks with disabled composer -> approve -> continuation + ✓ step + reload replay; reject -> ✗ step, nothing executed; queue page decides pending 106 Rust + 61 frontend tests + 14 Playwright journeys green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9f9f507c15
commit
de38449b41
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO execution_grants (id, approval_id, nonce)\n VALUES ($1, $2, $3)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0da90e00e009575f8570de01d2ee0e7cbd6cea8605b331b44a96049e361e378c"
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT a.run_id, a.id AS approval_id, a.status\n FROM approvals a\n JOIN agent_runs r ON r.id = a.run_id\n WHERE a.status IN ('approved', 'rejected')\n AND r.state = 'awaiting_approval'\n ORDER BY a.decided_at",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "run_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "approval_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2390b39f2359c75dc13c5749adbb912534d29ed74a920e3940338cb63369693f"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE execution_grants\n SET consumed = true, consumed_at = now()\n WHERE approval_id = $1 AND consumed = false\n RETURNING id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2dcf8d290a8219810eb3340de4a5c7d3e92f8acde30195333731a1924a117968"
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at\n FROM approvals\n WHERE workspace_id = $1 AND status = 'pending'\n ORDER BY created_at",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "run_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "session_key",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "action_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "payload",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "preview",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "requested_by_agent",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "taint_sources",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2e38e993b07b0e7f9f61c23dea9209faafbc8fbe06b15012dd14b0b51d71b62b"
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO approvals\n (id, workspace_id, run_id, session_key, action_type, category,\n payload, preview, requested_by_agent, taint_sources, status,\n expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11)\n RETURNING id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "run_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "session_key",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "action_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "payload",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "preview",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "requested_by_agent",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "taint_sources",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Jsonb",
|
||||||
|
"Jsonb",
|
||||||
|
"Uuid",
|
||||||
|
"TextArray",
|
||||||
|
"Timestamptz"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "40540e457a09c543487f49cf782fdf84bd5fcce6f35601332288c1901fd8faee"
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE approvals\n SET status = $2, decided_by = $3, decided_at = now()\n WHERE id = $1 AND status = 'pending'\n RETURNING id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "run_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "session_key",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "action_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "payload",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "preview",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "requested_by_agent",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "taint_sources",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "482852be248fb6961f54dcbbb45ad0c2532966e1d9f467ce40d32b99ebfc3a1f"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT 1 AS x FROM approvals WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "x",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4ef893a7e6e42739910e1fa7ec169d96d69910abf5f903d3690dfe2cae47c217"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE agent_runs SET state = 'running', updated_at = now()\n WHERE id = $1 AND state = 'awaiting_approval'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7817ece28b619bb84e5c4dfac7b766be79b10e43b8aea1d704983d8703af9586"
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO outbox (id, workspace_id, agent_id, recipient, subject, body)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "96fbd944f8e9363b7be01affe85a1b6bd54183e635088bc6dcf80934e5412011"
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO audit_log\n (workspace_id, actor_kind, actor_id, event_type, subject_type,\n subject_id, detail)\n VALUES ($1, 'user', $2, $3, 'approval', $4, $5)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Jsonb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "9bfcbca59dff3744ac0a1cdbdb1ccd27a45a0e299d3a59423259d2df648e45b7"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE agent_runs\n SET checkpoint = $2, state = 'awaiting_approval', updated_at = now()\n WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Jsonb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a1e909c4be1add6f6e43947ef95aad247f2748cb6f89cadf4c6433abdb81d8f3"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT checkpoint FROM agent_runs WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "checkpoint",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b6a4e6ec07d810102690f94149f8610f379cb1e43fd054f5d045ac6f4f853412"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE approvals SET status = 'expired'\n WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at < now()",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f7bef1eca457a8f986131756a7f0b1c99ff398824aafa70aa8c222079b940232"
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at\n FROM approvals WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "run_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "session_key",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "action_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "payload",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "preview",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "requested_by_agent",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "taint_sources",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "decided_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fe53e0a71d3cd00649fa1d36c388a4c9ce076c30cb1b5e125c3a64539172c08a"
|
||||||
|
}
|
||||||
Generated
+29
@@ -2776,6 +2776,7 @@ dependencies = [
|
|||||||
"tc-domain",
|
"tc-domain",
|
||||||
"tc-llm",
|
"tc-llm",
|
||||||
"tc-runtime",
|
"tc-runtime",
|
||||||
|
"tc-safety",
|
||||||
"tc-testkit",
|
"tc-testkit",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
@@ -2864,6 +2865,24 @@ dependencies = [
|
|||||||
"tc-db",
|
"tc-db",
|
||||||
"tc-domain",
|
"tc-domain",
|
||||||
"tc-llm",
|
"tc-llm",
|
||||||
|
"tc-safety",
|
||||||
|
"tc-testkit",
|
||||||
|
"tc-tools",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tc-safety"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tc-db",
|
||||||
|
"tc-domain",
|
||||||
"tc-testkit",
|
"tc-testkit",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
@@ -2882,6 +2901,16 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tc-tools"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"proptest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tc-domain",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "teamclaw-server"
|
name = "teamclaw-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ members = [
|
|||||||
"crates/tc-db",
|
"crates/tc-db",
|
||||||
"crates/tc-llm",
|
"crates/tc-llm",
|
||||||
"crates/tc-runtime",
|
"crates/tc-runtime",
|
||||||
|
"crates/tc-tools",
|
||||||
|
"crates/tc-safety",
|
||||||
"crates/tc-testkit",
|
"crates/tc-testkit",
|
||||||
"crates/tc-auth",
|
"crates/tc-auth",
|
||||||
"crates/tc-api",
|
"crates/tc-api",
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ async fn run() -> Result<(), String> {
|
|||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Durable §15 path: expires overdue approvals and resumes decided runs
|
||||||
|
// even if the deciding request's process died mid-flight.
|
||||||
|
runtime.spawn_resume_sweeper(std::time::Duration::from_secs(2));
|
||||||
|
|
||||||
let app = tc_api::router(tc_api::AppState::new(pool, runtime));
|
let app = tc_api::router(tc_api::AppState::new(pool, runtime));
|
||||||
let listener = tokio::net::TcpListener::bind(config.listen_addr)
|
let listener = tokio::net::TcpListener::bind(config.listen_addr)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ tc-auth = { path = "../tc-auth" }
|
|||||||
tc-db = { path = "../tc-db" }
|
tc-db = { path = "../tc-db" }
|
||||||
tc-domain = { path = "../tc-domain" }
|
tc-domain = { path = "../tc-domain" }
|
||||||
tc-runtime = { path = "../tc-runtime" }
|
tc-runtime = { path = "../tc-runtime" }
|
||||||
|
tc-safety = { path = "../tc-safety" }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub enum ApiError {
|
|||||||
Forbidden,
|
Forbidden,
|
||||||
#[error("not found")]
|
#[error("not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
|
#[error("conflict")]
|
||||||
|
Conflict,
|
||||||
#[error("internal error")]
|
#[error("internal error")]
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ impl IntoResponse for ApiError {
|
|||||||
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
|
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
ApiError::Forbidden => StatusCode::FORBIDDEN,
|
ApiError::Forbidden => StatusCode::FORBIDDEN,
|
||||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
ApiError::Conflict => StatusCode::CONFLICT,
|
||||||
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
(status, Json(json!({ "error": self.to_string() }))).into_response()
|
(status, Json(json!({ "error": self.to_string() }))).into_response()
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/sessions", post(routes::sessions::create))
|
.route("/api/sessions", post(routes::sessions::create))
|
||||||
.route("/api/sessions/history", get(routes::sessions::history))
|
.route("/api/sessions/history", get(routes::sessions::history))
|
||||||
.route("/api/gateway", post(routes::gateway::gateway))
|
.route("/api/gateway", post(routes::gateway::gateway))
|
||||||
|
.route("/api/approvals", get(routes::approvals::list))
|
||||||
|
.route("/api/approvals/{id}", get(routes::approvals::get))
|
||||||
|
.route(
|
||||||
|
"/api/approvals/{id}/approve",
|
||||||
|
post(routes::approvals::approve),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/approvals/{id}/reject",
|
||||||
|
post(routes::approvals::reject),
|
||||||
|
)
|
||||||
.route("/api/team/claws", get(routes::team::claws))
|
.route("/api/team/claws", get(routes::team::claws))
|
||||||
.route("/api/team/members", get(routes::team::members))
|
.route("/api/team/members", get(routes::team::members))
|
||||||
.route("/api/team/credits", get(routes::team::credits))
|
.route("/api/team/credits", get(routes::team::credits))
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tc_safety::{approvals, Approval, Decision, ResumeReady, SafetyError};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{ApiError, AppState, Authed};
|
||||||
|
|
||||||
|
impl From<SafetyError> for ApiError {
|
||||||
|
fn from(err: SafetyError) -> Self {
|
||||||
|
match err {
|
||||||
|
SafetyError::NotFound => ApiError::NotFound,
|
||||||
|
SafetyError::AlreadyDecided => ApiError::Conflict,
|
||||||
|
_ => ApiError::Internal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads an approval, hiding other workspaces' approvals entirely.
|
||||||
|
async fn workspace_approval(
|
||||||
|
state: &AppState,
|
||||||
|
user: &tc_auth::AuthedUser,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Approval, ApiError> {
|
||||||
|
let approval = approvals::get(&state.pool, id).await?;
|
||||||
|
if approval.workspace_id != user.workspace_id {
|
||||||
|
return Err(ApiError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(approval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/approvals — the pending review queue (§10), oldest first.
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<Vec<Approval>>, ApiError> {
|
||||||
|
let pending = approvals::list_pending(&state.pool, user.workspace_id).await?;
|
||||||
|
Ok(Json(pending))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/approvals/{id}
|
||||||
|
pub async fn get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Approval>, ApiError> {
|
||||||
|
Ok(Json(workspace_approval(&state, &user, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decide(
|
||||||
|
state: AppState,
|
||||||
|
user: tc_auth::AuthedUser,
|
||||||
|
id: Uuid,
|
||||||
|
decision: Decision,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
workspace_approval(&state, &user, id).await?;
|
||||||
|
let approval = approvals::decide(&state.pool, id, user.user_id, decision).await?;
|
||||||
|
|
||||||
|
// Kick the resume immediately; the sweeper remains the durable
|
||||||
|
// fallback if this process dies first.
|
||||||
|
let runtime = state.runtime.clone();
|
||||||
|
let ready = ResumeReady {
|
||||||
|
run_id: approval.run_id,
|
||||||
|
approval_id: approval.id,
|
||||||
|
approved: decision == Decision::Approve,
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = runtime.resume_run(ready).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
json!({ "id": approval.id, "status": approval.status }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/approvals/{id}/approve — executes the gated action (§15:
|
||||||
|
/// approve-only execution, idempotent, audited).
|
||||||
|
pub async fn approve(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
decide(state, user, id, Decision::Approve).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/approvals/{id}/reject — the agent learns the refusal in-band.
|
||||||
|
pub async fn reject(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
decide(state, user, id, Decision::Reject).await
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod approvals;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod claws;
|
pub mod claws;
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! The §15 chain over HTTP: gateway suspends, the approvals API decides,
|
||||||
|
//! and the gateway resume streams the continuation.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use eventsource_stream::Eventsource;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tc_api::AppState;
|
||||||
|
use tc_auth::AuthService;
|
||||||
|
use tc_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||||
|
use tc_llm::ScriptedProvider;
|
||||||
|
use tc_runtime::{Runtime, RuntimeConfig};
|
||||||
|
|
||||||
|
const SCENARIOS: &str = r#"
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:gated-email]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "I'll send that email." },
|
||||||
|
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Q2", body = "Revenue is up." } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = " The email step is finished." },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
struct TestServer {
|
||||||
|
base: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(pool: sqlx::PgPool) -> TestServer {
|
||||||
|
let runtime = Runtime::new(
|
||||||
|
pool.clone(),
|
||||||
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
||||||
|
RuntimeConfig {
|
||||||
|
model: "scripted".into(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let app = tc_api::router(AppState::new(pool, runtime));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
TestServer {
|
||||||
|
base: format!("http://{addr}"),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_and_login(pool: &sqlx::PgPool, server: &TestServer) -> (String, String) {
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(pool, &owner).await.unwrap();
|
||||||
|
AuthService::new(pool.clone())
|
||||||
|
.set_password(owner.id, "pw")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let token = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/login", server.base))
|
||||||
|
.json(&json!({"email": owner.email, "password": "pw"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap()["token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
let claw: Value = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/claws", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(token, claw["id"].as_str().unwrap().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_sse(response: reqwest::Response) -> Vec<(String, String, Value)> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut stream = response.bytes_stream().eventsource();
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
|
let event = event.unwrap();
|
||||||
|
let data: Value = serde_json::from_str(&event.data).unwrap();
|
||||||
|
let name = event.event.clone();
|
||||||
|
let done = matches!(name.as_str(), "run_completed" | "error" | "run_suspended");
|
||||||
|
events.push((event.id, name, data));
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the gated run and returns (session_key, approval_id, last_seq).
|
||||||
|
async fn suspend_gated_run(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
claw_id: &str,
|
||||||
|
) -> (String, String, i64) {
|
||||||
|
let session: Value = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/sessions", server.base))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&json!({"clawId": claw_id, "title": "Email"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let session_key = session["sessionKey"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let events = collect_sse(
|
||||||
|
server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/gateway?clawId={claw_id}", server.base))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&json!({
|
||||||
|
"sessionKey": session_key,
|
||||||
|
"message": "send it [[scenario:gated-email]]"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (last_id, name, data) = events.last().unwrap();
|
||||||
|
assert_eq!(name, "run_suspended");
|
||||||
|
let approval_id = data["approval_id"].as_str().unwrap().to_owned();
|
||||||
|
(session_key, approval_id, last_id.parse().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_chain_over_http_executes_only_after_approval() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let server = serve(pool.clone()).await;
|
||||||
|
let (token, claw_id) = seed_and_login(&pool, &server).await;
|
||||||
|
let (session_key, approval_id, last_seq) = suspend_gated_run(&server, &token, &claw_id).await;
|
||||||
|
|
||||||
|
// Queued: the approvals API lists it with the exact preview.
|
||||||
|
let queue: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/approvals", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pending = queue.as_array().unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0]["id"], approval_id);
|
||||||
|
assert_eq!(
|
||||||
|
pending[0]["preview"]["summary"],
|
||||||
|
"Send email to [email protected]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked while pending.
|
||||||
|
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outbox, 0);
|
||||||
|
|
||||||
|
// Approve over HTTP.
|
||||||
|
let approve = server
|
||||||
|
.client
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/approvals/{approval_id}/approve",
|
||||||
|
server.base
|
||||||
|
))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(approve.status(), 200);
|
||||||
|
|
||||||
|
// Double-decide is a conflict.
|
||||||
|
let again = server
|
||||||
|
.client
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/approvals/{approval_id}/approve",
|
||||||
|
server.base
|
||||||
|
))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(again.status(), 409);
|
||||||
|
|
||||||
|
// Re-attach the gateway from where we left off: the continuation
|
||||||
|
// streams the approved execution through to completion.
|
||||||
|
let continuation = collect_sse(
|
||||||
|
server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/gateway?clawId={claw_id}", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&json!({"sessionKey": session_key, "resumeFrom": last_seq}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let kinds: Vec<&str> = continuation.iter().map(|(_, n, _)| n.as_str()).collect();
|
||||||
|
assert!(kinds.contains(&"step_finished"));
|
||||||
|
assert_eq!(*kinds.last().unwrap(), "run_completed");
|
||||||
|
|
||||||
|
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outbox, 1);
|
||||||
|
|
||||||
|
// The queue is clear again.
|
||||||
|
let queue: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/approvals", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(queue.as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_over_http_executes_nothing() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let server = serve(pool.clone()).await;
|
||||||
|
let (token, claw_id) = seed_and_login(&pool, &server).await;
|
||||||
|
let (session_key, approval_id, last_seq) = suspend_gated_run(&server, &token, &claw_id).await;
|
||||||
|
|
||||||
|
let reject = server
|
||||||
|
.client
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/approvals/{approval_id}/reject",
|
||||||
|
server.base
|
||||||
|
))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reject.status(), 200);
|
||||||
|
|
||||||
|
let continuation = collect_sse(
|
||||||
|
server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/gateway?clawId={claw_id}", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&json!({"sessionKey": session_key, "resumeFrom": last_seq}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(continuation.last().unwrap().1, "run_completed");
|
||||||
|
|
||||||
|
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outbox, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approvals_are_tenant_isolated() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let server = serve(pool.clone()).await;
|
||||||
|
let (token, claw_id) = seed_and_login(&pool, &server).await;
|
||||||
|
let (other_token, _) = seed_and_login(&pool, &server).await;
|
||||||
|
let (_, approval_id, _) = suspend_gated_run(&server, &token, &claw_id).await;
|
||||||
|
|
||||||
|
// Another workspace sees an empty queue and cannot decide.
|
||||||
|
let queue: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/approvals", server.base))
|
||||||
|
.bearer_auth(&other_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(queue.as_array().unwrap().is_empty());
|
||||||
|
|
||||||
|
let foreign = server
|
||||||
|
.client
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/approvals/{approval_id}/approve",
|
||||||
|
server.base
|
||||||
|
))
|
||||||
|
.bearer_auth(&other_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(foreign.status(), 404);
|
||||||
|
}
|
||||||
@@ -30,4 +30,32 @@ impl GatedCategory {
|
|||||||
GatedCategory::FileDeletion,
|
GatedCategory::FileDeletion,
|
||||||
GatedCategory::InfraAccessGrant,
|
GatedCategory::InfraAccessGrant,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Storage form matching the `approvals.category` CHECK constraint.
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GatedCategory::OutboundMessage => "outbound_message",
|
||||||
|
GatedCategory::SecretSharing => "secret_sharing",
|
||||||
|
GatedCategory::AccessChange => "access_change",
|
||||||
|
GatedCategory::FinancialTransaction => "financial_transaction",
|
||||||
|
GatedCategory::FileDeletion => "file_deletion",
|
||||||
|
GatedCategory::InfraAccessGrant => "infra_access_grant",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for GatedCategory {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"outbound_message" => Ok(GatedCategory::OutboundMessage),
|
||||||
|
"secret_sharing" => Ok(GatedCategory::SecretSharing),
|
||||||
|
"access_change" => Ok(GatedCategory::AccessChange),
|
||||||
|
"financial_transaction" => Ok(GatedCategory::FinancialTransaction),
|
||||||
|
"file_deletion" => Ok(GatedCategory::FileDeletion),
|
||||||
|
"infra_access_grant" => Ok(GatedCategory::InfraAccessGrant),
|
||||||
|
other => Err(format!("unknown gated category: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ impl AnthropicProvider {
|
|||||||
.parts
|
.parts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|part| match part {
|
.map(|part| match part {
|
||||||
ContentPart::Text(text) => json!({"type": "text", "text": text}),
|
ContentPart::Text { text } => json!({"type": "text", "text": text}),
|
||||||
ContentPart::ToolUse { id, name, input } => {
|
ContentPart::ToolUse { id, name, input } => {
|
||||||
json!({"type": "tool_use", "id": id, "name": name, "input": input})
|
json!({"type": "tool_use", "id": id, "name": name, "input": input})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl OpenAiCompatProvider {
|
|||||||
let mut tool_calls: Vec<Value> = Vec::new();
|
let mut tool_calls: Vec<Value> = Vec::new();
|
||||||
for part in &message.parts {
|
for part in &message.parts {
|
||||||
match part {
|
match part {
|
||||||
ContentPart::Text(t) => text.push_str(t),
|
ContentPart::Text { text: t } => text.push_str(t),
|
||||||
ContentPart::ToolUse { id, name, input } => tool_calls.push(json!({
|
ContentPart::ToolUse { id, name, input } => tool_calls.push(json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ pub enum ChatRole {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ContentPart {
|
pub enum ContentPart {
|
||||||
Text(String),
|
// Struct variant (not newtype): internally-tagged enums cannot
|
||||||
|
// serialize newtype primitives, and this type round-trips through
|
||||||
|
// `agent_runs.checkpoint`.
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -27,6 +32,13 @@ pub enum ContentPart {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ContentPart {
|
||||||
|
/// Convenience constructor for plain text parts.
|
||||||
|
pub fn text(value: impl Into<String>) -> ContentPart {
|
||||||
|
ContentPart::Text { text: value.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
pub role: ChatRole,
|
pub role: ChatRole,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ impl LlmProvider for ScriptedProvider {
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|m| m.parts.iter())
|
.flat_map(|m| m.parts.iter())
|
||||||
.filter_map(|p| match p {
|
.filter_map(|p| match p {
|
||||||
ContentPart::Text(t) => Some(t.as_str()),
|
ContentPart::Text { text } => Some(text.as_str()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -149,7 +149,7 @@ impl LlmProvider for ScriptedProvider {
|
|||||||
.rev()
|
.rev()
|
||||||
.flat_map(|m| m.parts.iter())
|
.flat_map(|m| m.parts.iter())
|
||||||
.find_map(|p| match p {
|
.find_map(|p| match p {
|
||||||
ContentPart::Text(t) => Some(t.clone()),
|
ContentPart::Text { text } => Some(text.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fn simple_request(model: &str) -> ChatRequest {
|
|||||||
system: "Answer in exactly one short sentence.".into(),
|
system: "Answer in exactly one short sentence.".into(),
|
||||||
messages: vec![ChatMessage {
|
messages: vec![ChatMessage {
|
||||||
role: ChatRole::User,
|
role: ChatRole::User,
|
||||||
parts: vec![ContentPart::Text("Say the word 'pong'.".into())],
|
parts: vec![ContentPart::text("Say the word 'pong'.")],
|
||||||
}],
|
}],
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
model: model.into(),
|
model: model.into(),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ fn request_with_user_text(text: &str) -> ChatRequest {
|
|||||||
system: "You are Scout.".into(),
|
system: "You are Scout.".into(),
|
||||||
messages: vec![ChatMessage {
|
messages: vec![ChatMessage {
|
||||||
role: ChatRole::User,
|
role: ChatRole::User,
|
||||||
parts: vec![ContentPart::Text(text.into())],
|
parts: vec![ContentPart::text(text)],
|
||||||
}],
|
}],
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
model: "scripted".into(),
|
model: "scripted".into(),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ sqlx = { workspace = true }
|
|||||||
tc-db = { path = "../tc-db" }
|
tc-db = { path = "../tc-db" }
|
||||||
tc-domain = { path = "../tc-domain" }
|
tc-domain = { path = "../tc-domain" }
|
||||||
tc-llm = { path = "../tc-llm" }
|
tc-llm = { path = "../tc-llm" }
|
||||||
|
tc-safety = { path = "../tc-safety" }
|
||||||
|
tc-tools = { path = "../tc-tools" }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ pub enum RunEventBody {
|
|||||||
status: String,
|
status: String,
|
||||||
output: Value,
|
output: Value,
|
||||||
},
|
},
|
||||||
|
/// A gated action awaits human review (§15): carries the exact preview
|
||||||
|
/// of what will execute, surfaced as the approval card (§10).
|
||||||
|
ApprovalRequired {
|
||||||
|
approval_id: Uuid,
|
||||||
|
category: String,
|
||||||
|
action_type: String,
|
||||||
|
preview: Value,
|
||||||
|
},
|
||||||
|
/// The run is blocked until the pending approval is decided.
|
||||||
|
RunSuspended {
|
||||||
|
approval_id: Uuid,
|
||||||
|
},
|
||||||
RunCompleted {
|
RunCompleted {
|
||||||
message_id: String,
|
message_id: String,
|
||||||
},
|
},
|
||||||
@@ -46,6 +58,8 @@ impl RunEventBody {
|
|||||||
RunEventBody::TextDelta { .. } => "text_delta",
|
RunEventBody::TextDelta { .. } => "text_delta",
|
||||||
RunEventBody::StepStarted { .. } => "step_started",
|
RunEventBody::StepStarted { .. } => "step_started",
|
||||||
RunEventBody::StepFinished { .. } => "step_finished",
|
RunEventBody::StepFinished { .. } => "step_finished",
|
||||||
|
RunEventBody::ApprovalRequired { .. } => "approval_required",
|
||||||
|
RunEventBody::RunSuspended { .. } => "run_suspended",
|
||||||
RunEventBody::RunCompleted { .. } => "run_completed",
|
RunEventBody::RunCompleted { .. } => "run_completed",
|
||||||
RunEventBody::Error { .. } => "error",
|
RunEventBody::Error { .. } => "error",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ mod tools;
|
|||||||
|
|
||||||
pub use events::{RunEventBody, RunEventEnvelope};
|
pub use events::{RunEventBody, RunEventEnvelope};
|
||||||
pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun};
|
pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun};
|
||||||
pub use tools::{ClockNow, Tool, ToolRegistry};
|
pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
|
||||||
|
|||||||
+437
-160
@@ -1,18 +1,28 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tc_db::repo::{messages, run_events, runs, sessions, steps};
|
use tc_db::repo::{messages, run_events, runs, sessions, steps};
|
||||||
use tc_db::DbError;
|
use tc_db::DbError;
|
||||||
use tc_domain::{MessageRole, MessageWithSteps, SessionId, Step, StepStatus};
|
use tc_domain::{
|
||||||
|
shard_of, AgentId, MessageId, MessageRole, MessageWithSteps, SessionId, SessionKey, Step,
|
||||||
|
StepStatus, WorkspaceId,
|
||||||
|
};
|
||||||
use tc_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider, StopReason};
|
use tc_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider, StopReason};
|
||||||
|
use tc_safety::{approvals, checkpoint, grants, NewApproval, ResumeReady, SafetyError};
|
||||||
|
use tc_tools::{GateDecision, GatePolicy, TaintSet};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::events::{RunEventBody, RunEventEnvelope};
|
use crate::events::{RunEventBody, RunEventEnvelope};
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::{ToolContext, ToolRegistry};
|
||||||
|
|
||||||
|
/// How long a pending approval stays decidable before it expires.
|
||||||
|
const APPROVAL_TTL: time::Duration = time::Duration::hours(24);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RuntimeConfig {
|
pub struct RuntimeConfig {
|
||||||
@@ -24,8 +34,12 @@ pub struct RuntimeConfig {
|
|||||||
pub enum RuntimeError {
|
pub enum RuntimeError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Db(#[from] DbError),
|
Db(#[from] DbError),
|
||||||
|
#[error("safety error: {0}")]
|
||||||
|
Safety(#[from] SafetyError),
|
||||||
#[error("llm error: {0}")]
|
#[error("llm error: {0}")]
|
||||||
Llm(String),
|
Llm(String),
|
||||||
|
#[error("checkpoint corrupt: {0}")]
|
||||||
|
Checkpoint(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle returned to the caller: the run id plus a live event receiver
|
/// Handle returned to the caller: the run id plus a live event receiver
|
||||||
@@ -35,6 +49,37 @@ pub struct StartedRun {
|
|||||||
pub events: broadcast::Receiver<RunEventEnvelope>,
|
pub events: broadcast::Receiver<RunEventEnvelope>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tool call the model requested that has not executed yet.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct PendingTool {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complete, serializable state of an in-flight run. This is what
|
||||||
|
/// `agent_runs.checkpoint` stores while a run awaits approval; resume
|
||||||
|
/// deserializes it and continues exactly where the loop stopped.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct LoopState {
|
||||||
|
session_id: SessionId,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
agent_id: AgentId,
|
||||||
|
reply_message_id: MessageId,
|
||||||
|
request: ChatRequest,
|
||||||
|
full_text: String,
|
||||||
|
step_seq: i32,
|
||||||
|
event_seq: i64,
|
||||||
|
assistant_parts: Vec<ContentPart>,
|
||||||
|
result_parts: Vec<ContentPart>,
|
||||||
|
pending_tools: Vec<PendingTool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Outcome {
|
||||||
|
Completed,
|
||||||
|
Suspended,
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns active run channels; one instance lives in the server state.
|
/// Owns active run channels; one instance lives in the server state.
|
||||||
/// Cheap to clone (shared inner state).
|
/// Cheap to clone (shared inner state).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -63,6 +108,10 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn pool(&self) -> &PgPool {
|
||||||
|
&self.inner.pool
|
||||||
|
}
|
||||||
|
|
||||||
/// Live-attach to a run that is still streaming.
|
/// Live-attach to a run that is still streaming.
|
||||||
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
|
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
|
||||||
self.inner
|
self.inner
|
||||||
@@ -85,57 +134,160 @@ impl Runtime {
|
|||||||
let history = messages::history(&inner.pool, session_id).await?;
|
let history = messages::history(&inner.pool, session_id).await?;
|
||||||
|
|
||||||
let run_id = runs::create(&inner.pool, session_id).await?;
|
let run_id = runs::create(&inner.pool, session_id).await?;
|
||||||
let (sender, receiver) = broadcast::channel(1024);
|
let receiver = self.open_channel(run_id).await;
|
||||||
inner.channels.lock().await.insert(run_id, sender.clone());
|
|
||||||
|
|
||||||
let runtime = self.clone();
|
messages::append(
|
||||||
let user_text = user_text.to_owned();
|
&inner.pool,
|
||||||
tokio::spawn(async move {
|
|
||||||
let result = runtime
|
|
||||||
.run_loop(
|
|
||||||
run_id,
|
|
||||||
session_id,
|
session_id,
|
||||||
&agent.system_prompt,
|
MessageRole::User,
|
||||||
history,
|
json!({"text": user_text}),
|
||||||
&user_text,
|
|
||||||
&sender,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
if let Err(error) = result {
|
sessions::touch(&inner.pool, session_id).await?;
|
||||||
let _ = runs::set_state(
|
// The reply row exists up front so steps can attach while streaming.
|
||||||
&runtime.inner.pool,
|
let reply = messages::append(
|
||||||
run_id,
|
&inner.pool,
|
||||||
tc_domain::RunState::Failed,
|
session_id,
|
||||||
Some(&error.to_string()),
|
MessageRole::Agent,
|
||||||
|
json!({"text": ""}),
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
let mut seq = last_journaled_seq(&runtime.inner.pool, run_id).await;
|
|
||||||
let _ = runtime
|
|
||||||
.emit(
|
|
||||||
run_id,
|
|
||||||
&sender,
|
|
||||||
&mut seq,
|
|
||||||
RunEventBody::Error {
|
|
||||||
message: error.to_string(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
runtime.inner.channels.lock().await.remove(&run_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let state = LoopState {
|
||||||
|
session_id,
|
||||||
|
workspace_id: agent.workspace_id,
|
||||||
|
agent_id: agent.id,
|
||||||
|
reply_message_id: reply.id,
|
||||||
|
request: ChatRequest {
|
||||||
|
system: agent.system_prompt.clone(),
|
||||||
|
messages: chat_messages(&history, user_text),
|
||||||
|
tools: inner.tools.descriptors(),
|
||||||
|
model: inner.config.model.clone(),
|
||||||
|
max_tokens: inner.config.max_tokens,
|
||||||
|
},
|
||||||
|
full_text: String::new(),
|
||||||
|
step_seq: 0,
|
||||||
|
event_seq: 0,
|
||||||
|
assistant_parts: Vec::new(),
|
||||||
|
result_parts: Vec::new(),
|
||||||
|
pending_tools: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.spawn_drive(run_id, state, true);
|
||||||
Ok(StartedRun {
|
Ok(StartedRun {
|
||||||
run_id,
|
run_id,
|
||||||
events: receiver,
|
events: receiver,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resumes a suspended run after its approval was decided. Exactly one
|
||||||
|
/// caller wins the claim; everyone else returns quietly.
|
||||||
|
pub async fn resume_run(&self, ready: ResumeReady) -> Result<(), RuntimeError> {
|
||||||
|
if !checkpoint::claim_resume(&self.inner.pool, ready.run_id).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let raw = checkpoint::load(&self.inner.pool, ready.run_id).await?;
|
||||||
|
let mut state: LoopState =
|
||||||
|
serde_json::from_value(raw).map_err(|e| RuntimeError::Checkpoint(e.to_string()))?;
|
||||||
|
self.open_channel(ready.run_id).await;
|
||||||
|
|
||||||
|
let runtime = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = runtime.resolve_gated_tool(ready, &mut state).await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => runtime.spawn_drive_inline(ready.run_id, state).await,
|
||||||
|
Err(error) => runtime.fail_run(ready.run_id, &error.to_string()).await,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The durable resume path: periodically expires overdue approvals and
|
||||||
|
/// resumes runs whose approvals were decided (survives crashes between
|
||||||
|
/// decision and resumption).
|
||||||
|
pub fn spawn_resume_sweeper(&self, interval: Duration) {
|
||||||
|
let runtime = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(interval);
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let _ = approvals::sweep_expired(&runtime.inner.pool).await;
|
||||||
|
if let Ok(ready) = approvals::decided_unresumed(&runtime.inner.pool).await {
|
||||||
|
for item in ready {
|
||||||
|
let _ = runtime.resume_run(item).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_channel(&self, run_id: Uuid) -> broadcast::Receiver<RunEventEnvelope> {
|
||||||
|
let (sender, receiver) = broadcast::channel(1024);
|
||||||
|
self.inner.channels.lock().await.insert(run_id, sender);
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_drive(&self, run_id: Uuid, state: LoopState, emit_started: bool) {
|
||||||
|
let runtime = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if emit_started {
|
||||||
|
let mut seq = state.event_seq;
|
||||||
|
if runtime
|
||||||
|
.emit(run_id, &mut seq, RunEventBody::RunStarted { run_id })
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
runtime
|
||||||
|
.fail_run(run_id, "could not journal run start")
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut state = state;
|
||||||
|
state.event_seq = seq;
|
||||||
|
runtime.spawn_drive_inline(run_id, state).await;
|
||||||
|
} else {
|
||||||
|
runtime.spawn_drive_inline(run_id, state).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_drive_inline(&self, run_id: Uuid, state: LoopState) {
|
||||||
|
match self.drive(run_id, state).await {
|
||||||
|
Ok(Outcome::Completed) | Ok(Outcome::Suspended) => {}
|
||||||
|
Err(error) => self.fail_run(run_id, &error.to_string()).await,
|
||||||
|
}
|
||||||
|
self.inner.channels.lock().await.remove(&run_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail_run(&self, run_id: Uuid, message: &str) {
|
||||||
|
let _ = runs::set_state(
|
||||||
|
&self.inner.pool,
|
||||||
|
run_id,
|
||||||
|
tc_domain::RunState::Failed,
|
||||||
|
Some(message),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut seq = runs::get(&self.inner.pool, run_id)
|
||||||
|
.await
|
||||||
|
.map(|r| r.last_event_id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = self
|
||||||
|
.emit(
|
||||||
|
run_id,
|
||||||
|
&mut seq,
|
||||||
|
RunEventBody::Error {
|
||||||
|
message: message.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.inner.channels.lock().await.remove(&run_id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Journals an event, then broadcasts it. Persist-before-emit is the
|
/// Journals an event, then broadcasts it. Persist-before-emit is the
|
||||||
/// invariant that makes replay equal to what live observers saw.
|
/// invariant that makes replay equal to what live observers saw.
|
||||||
async fn emit(
|
async fn emit(
|
||||||
&self,
|
&self,
|
||||||
run_id: Uuid,
|
run_id: Uuid,
|
||||||
sender: &broadcast::Sender<RunEventEnvelope>,
|
|
||||||
seq: &mut i64,
|
seq: &mut i64,
|
||||||
event: RunEventBody,
|
event: RunEventBody,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
@@ -143,153 +295,219 @@ impl Runtime {
|
|||||||
let payload = serde_json::to_value(&event).expect("event serializes");
|
let payload = serde_json::to_value(&event).expect("event serializes");
|
||||||
run_events::append(&self.inner.pool, run_id, *seq, event.type_name(), payload).await?;
|
run_events::append(&self.inner.pool, run_id, *seq, event.type_name(), payload).await?;
|
||||||
runs::set_last_event(&self.inner.pool, run_id, *seq).await?;
|
runs::set_last_event(&self.inner.pool, run_id, *seq).await?;
|
||||||
|
if let Some(sender) = self.inner.channels.lock().await.get(&run_id) {
|
||||||
let _ = sender.send(RunEventEnvelope { seq: *seq, event });
|
let _ = sender.send(RunEventEnvelope { seq: *seq, event });
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_loop(
|
/// Applies the human decision to the gated tool at the head of the
|
||||||
|
/// pending queue: approval consumes the single-use grant and executes;
|
||||||
|
/// rejection feeds a structured refusal back to the model.
|
||||||
|
async fn resolve_gated_tool(
|
||||||
&self,
|
&self,
|
||||||
run_id: Uuid,
|
ready: ResumeReady,
|
||||||
session_id: SessionId,
|
state: &mut LoopState,
|
||||||
system_prompt: &str,
|
|
||||||
history: Vec<MessageWithSteps>,
|
|
||||||
user_text: &str,
|
|
||||||
sender: &broadcast::Sender<RunEventEnvelope>,
|
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
let mut seq: i64 = 0;
|
let Some(tool) = state.pending_tools.first().cloned() else {
|
||||||
self.emit(
|
return Err(RuntimeError::Checkpoint(
|
||||||
run_id,
|
"resumed run has no pending tool".into(),
|
||||||
sender,
|
));
|
||||||
&mut seq,
|
};
|
||||||
RunEventBody::RunStarted { run_id },
|
state.pending_tools.remove(0);
|
||||||
)
|
let ctx = ToolContext {
|
||||||
.await?;
|
pool: self.inner.pool.clone(),
|
||||||
|
workspace_id: state.workspace_id,
|
||||||
|
agent_id: state.agent_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.step_seq += 1;
|
||||||
|
self.emit_step_started(ready.run_id, state, &tool).await?;
|
||||||
|
|
||||||
messages::append(
|
let (status, output) = if !ready.approved {
|
||||||
&self.inner.pool,
|
(
|
||||||
session_id,
|
StepStatus::Error,
|
||||||
MessageRole::User,
|
json!({"rejected": "The human reviewer rejected this action."}),
|
||||||
json!({"text": user_text}),
|
|
||||||
)
|
)
|
||||||
.await?;
|
} else {
|
||||||
sessions::touch(&self.inner.pool, session_id).await?;
|
match grants::consume(&self.inner.pool, ready.approval_id).await {
|
||||||
|
// The grant is consumed BEFORE the action runs: even a
|
||||||
|
// racing resumer cannot execute a gated call twice.
|
||||||
|
Ok(()) => match self
|
||||||
|
.inner
|
||||||
|
.tools
|
||||||
|
.execute(&ctx, &tool.name, tool.input.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => (StepStatus::Ok, value),
|
||||||
|
Err(message) => (StepStatus::Error, json!({"error": message})),
|
||||||
|
},
|
||||||
|
Err(_) => (
|
||||||
|
StepStatus::Error,
|
||||||
|
json!({"error": "execution grant unavailable (already used or never issued)"}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// The reply row exists up front so steps can attach to it while the
|
self.record_step(state, &tool, status, &output).await?;
|
||||||
// run streams; its text is finalized at the end.
|
self.emit_step_finished(ready.run_id, state, status, &output)
|
||||||
let reply = messages::append(
|
|
||||||
&self.inner.pool,
|
|
||||||
session_id,
|
|
||||||
MessageRole::Agent,
|
|
||||||
json!({"text": ""}),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
state.assistant_parts.push(ContentPart::ToolUse {
|
||||||
|
id: tool.id.clone(),
|
||||||
|
name: tool.name,
|
||||||
|
input: tool.input,
|
||||||
|
});
|
||||||
|
state.result_parts.push(ContentPart::ToolResult {
|
||||||
|
tool_use_id: tool.id,
|
||||||
|
content: output,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
let mut request = ChatRequest {
|
/// The run loop. Processes pending tool calls (suspending at the first
|
||||||
system: system_prompt.to_owned(),
|
/// gated one), then streams the provider; repeats until the model ends
|
||||||
messages: chat_messages(&history, user_text),
|
/// its turn.
|
||||||
tools: self.inner.tools.descriptors(),
|
async fn drive(&self, run_id: Uuid, mut state: LoopState) -> Result<Outcome, RuntimeError> {
|
||||||
model: self.inner.config.model.clone(),
|
let policy = GatePolicy;
|
||||||
max_tokens: self.inner.config.max_tokens,
|
let ctx = ToolContext {
|
||||||
|
pool: self.inner.pool.clone(),
|
||||||
|
workspace_id: state.workspace_id,
|
||||||
|
agent_id: state.agent_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut full_text = String::new();
|
|
||||||
let mut step_seq: i32 = 0;
|
|
||||||
loop {
|
loop {
|
||||||
|
while let Some(tool) = state.pending_tools.first().cloned() {
|
||||||
|
let effects = self.inner.tools.effects_of(&tool.name);
|
||||||
|
// Taint plumbing arrives with untrusted sources (P3);
|
||||||
|
// inputs today originate from workspace humans only.
|
||||||
|
let taint = TaintSet::clean();
|
||||||
|
if let GateDecision::RequireApproval(category) = policy.classify(effects, &taint) {
|
||||||
|
let session = sessions::get(&self.inner.pool, state.session_id).await?;
|
||||||
|
let session_key = SessionKey {
|
||||||
|
agent_id: state.agent_id,
|
||||||
|
shard: shard_of(state.agent_id),
|
||||||
|
session_id: state.session_id,
|
||||||
|
message_id: state.reply_message_id,
|
||||||
|
};
|
||||||
|
let approval = approvals::create(
|
||||||
|
&self.inner.pool,
|
||||||
|
NewApproval {
|
||||||
|
workspace_id: session.workspace_id,
|
||||||
|
run_id,
|
||||||
|
session_key: session_key.to_string(),
|
||||||
|
action_type: tool.name.clone(),
|
||||||
|
category,
|
||||||
|
payload: tool.input.clone(),
|
||||||
|
preview: self.inner.tools.preview_of(&tool.name, &tool.input),
|
||||||
|
requested_by_agent: state.agent_id,
|
||||||
|
taint_sources: taint.as_strings(),
|
||||||
|
expires_at: Some(time::OffsetDateTime::now_utc() + APPROVAL_TTL),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut seq = state.event_seq;
|
||||||
|
self.emit(
|
||||||
|
run_id,
|
||||||
|
&mut seq,
|
||||||
|
RunEventBody::ApprovalRequired {
|
||||||
|
approval_id: approval.id,
|
||||||
|
category: category.as_str().to_owned(),
|
||||||
|
action_type: approval.action_type.clone(),
|
||||||
|
preview: approval.preview.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.emit(
|
||||||
|
run_id,
|
||||||
|
&mut seq,
|
||||||
|
RunEventBody::RunSuspended {
|
||||||
|
approval_id: approval.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state.event_seq = seq;
|
||||||
|
// Checkpoint AFTER journaling so resume continues from
|
||||||
|
// the right sequence number.
|
||||||
|
let snapshot = serde_json::to_value(&state)
|
||||||
|
.map_err(|e| RuntimeError::Checkpoint(e.to_string()))?;
|
||||||
|
checkpoint::suspend(&self.inner.pool, run_id, &snapshot).await?;
|
||||||
|
return Ok(Outcome::Suspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungated: execute now.
|
||||||
|
state.pending_tools.remove(0);
|
||||||
|
state.step_seq += 1;
|
||||||
|
self.emit_step_started(run_id, &mut state, &tool).await?;
|
||||||
|
let (status, output) = match self
|
||||||
|
.inner
|
||||||
|
.tools
|
||||||
|
.execute(&ctx, &tool.name, tool.input.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => (StepStatus::Ok, value),
|
||||||
|
Err(message) => (StepStatus::Error, json!({"error": message})),
|
||||||
|
};
|
||||||
|
self.record_step(&state, &tool, status, &output).await?;
|
||||||
|
self.emit_step_finished(run_id, &mut state, status, &output)
|
||||||
|
.await?;
|
||||||
|
state.assistant_parts.push(ContentPart::ToolUse {
|
||||||
|
id: tool.id.clone(),
|
||||||
|
name: tool.name.clone(),
|
||||||
|
input: tool.input.clone(),
|
||||||
|
});
|
||||||
|
state.result_parts.push(ContentPart::ToolResult {
|
||||||
|
tool_use_id: tool.id.clone(),
|
||||||
|
content: output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed any finished tool batch back to the model.
|
||||||
|
if !state.assistant_parts.is_empty() {
|
||||||
|
state.request.messages.push(ChatMessage {
|
||||||
|
role: ChatRole::Assistant,
|
||||||
|
parts: std::mem::take(&mut state.assistant_parts),
|
||||||
|
});
|
||||||
|
state.request.messages.push(ChatMessage {
|
||||||
|
role: ChatRole::User,
|
||||||
|
parts: std::mem::take(&mut state.result_parts),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut stream = self
|
let mut stream = self
|
||||||
.inner
|
.inner
|
||||||
.provider
|
.provider
|
||||||
.stream(request.clone())
|
.stream(state.request.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RuntimeError::Llm(e.to_string()))?;
|
.map_err(|e| RuntimeError::Llm(e.to_string()))?;
|
||||||
let mut tool_uses: Vec<(String, String, Value)> = Vec::new();
|
|
||||||
let mut stop = StopReason::EndTurn;
|
let mut stop = StopReason::EndTurn;
|
||||||
while let Some(event) = stream.next().await {
|
while let Some(event) = stream.next().await {
|
||||||
match event.map_err(|e| RuntimeError::Llm(e.to_string()))? {
|
match event.map_err(|e| RuntimeError::Llm(e.to_string()))? {
|
||||||
LlmEvent::TextDelta(delta) => {
|
LlmEvent::TextDelta(delta) => {
|
||||||
full_text.push_str(&delta);
|
state.full_text.push_str(&delta);
|
||||||
self.emit(run_id, sender, &mut seq, RunEventBody::TextDelta { delta })
|
let mut seq = state.event_seq;
|
||||||
|
self.emit(run_id, &mut seq, RunEventBody::TextDelta { delta })
|
||||||
.await?;
|
.await?;
|
||||||
|
state.event_seq = seq;
|
||||||
}
|
}
|
||||||
LlmEvent::ToolUse { id, name, input } => {
|
LlmEvent::ToolUse { id, name, input } => {
|
||||||
tool_uses.push((id, name, input));
|
state.pending_tools.push(PendingTool { id, name, input });
|
||||||
}
|
}
|
||||||
LlmEvent::Stop(reason) => stop = reason,
|
LlmEvent::Stop(reason) => stop = reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tool_uses.is_empty() || stop != StopReason::ToolUse {
|
if state.pending_tools.is_empty() || stop != StopReason::ToolUse {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut assistant_parts = Vec::new();
|
messages::set_content(
|
||||||
let mut result_parts = Vec::new();
|
|
||||||
for (tool_use_id, name, input) in tool_uses {
|
|
||||||
step_seq += 1;
|
|
||||||
self.emit(
|
|
||||||
run_id,
|
|
||||||
sender,
|
|
||||||
&mut seq,
|
|
||||||
RunEventBody::StepStarted {
|
|
||||||
step_seq,
|
|
||||||
tool: name.clone(),
|
|
||||||
input: input.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let outcome = self.inner.tools.execute(&name, input.clone()).await;
|
|
||||||
let (status, output) = match outcome {
|
|
||||||
Ok(value) => (StepStatus::Ok, value),
|
|
||||||
Err(message) => (StepStatus::Error, json!({"error": message})),
|
|
||||||
};
|
|
||||||
steps::append(
|
|
||||||
&self.inner.pool,
|
&self.inner.pool,
|
||||||
&Step {
|
state.reply_message_id,
|
||||||
id: Uuid::now_v7(),
|
json!({"text": state.full_text}),
|
||||||
message_id: reply.id,
|
|
||||||
seq: step_seq,
|
|
||||||
kind: "tool_call".into(),
|
|
||||||
tool_name: Some(name.clone()),
|
|
||||||
input: Some(input.clone()),
|
|
||||||
output: Some(output.clone()),
|
|
||||||
taint: vec![],
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
self.emit(
|
|
||||||
run_id,
|
|
||||||
sender,
|
|
||||||
&mut seq,
|
|
||||||
RunEventBody::StepFinished {
|
|
||||||
step_seq,
|
|
||||||
status: status.as_str().to_owned(),
|
|
||||||
output: output.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
assistant_parts.push(ContentPart::ToolUse {
|
|
||||||
id: tool_use_id.clone(),
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
result_parts.push(ContentPart::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
request.messages.push(ChatMessage {
|
|
||||||
role: ChatRole::Assistant,
|
|
||||||
parts: assistant_parts,
|
|
||||||
});
|
|
||||||
request.messages.push(ChatMessage {
|
|
||||||
role: ChatRole::User,
|
|
||||||
parts: result_parts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
messages::set_content(&self.inner.pool, reply.id, json!({"text": full_text})).await?;
|
|
||||||
runs::set_state(
|
runs::set_state(
|
||||||
&self.inner.pool,
|
&self.inner.pool,
|
||||||
run_id,
|
run_id,
|
||||||
@@ -297,12 +515,80 @@ impl Runtime {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let mut seq = state.event_seq;
|
||||||
self.emit(
|
self.emit(
|
||||||
run_id,
|
run_id,
|
||||||
sender,
|
|
||||||
&mut seq,
|
&mut seq,
|
||||||
RunEventBody::RunCompleted {
|
RunEventBody::RunCompleted {
|
||||||
message_id: reply.id.to_string(),
|
message_id: state.reply_message_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Outcome::Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_step_started(
|
||||||
|
&self,
|
||||||
|
run_id: Uuid,
|
||||||
|
state: &mut LoopState,
|
||||||
|
tool: &PendingTool,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let mut seq = state.event_seq;
|
||||||
|
self.emit(
|
||||||
|
run_id,
|
||||||
|
&mut seq,
|
||||||
|
RunEventBody::StepStarted {
|
||||||
|
step_seq: state.step_seq,
|
||||||
|
tool: tool.name.clone(),
|
||||||
|
input: tool.input.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state.event_seq = seq;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_step_finished(
|
||||||
|
&self,
|
||||||
|
run_id: Uuid,
|
||||||
|
state: &mut LoopState,
|
||||||
|
status: StepStatus,
|
||||||
|
output: &Value,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let mut seq = state.event_seq;
|
||||||
|
self.emit(
|
||||||
|
run_id,
|
||||||
|
&mut seq,
|
||||||
|
RunEventBody::StepFinished {
|
||||||
|
step_seq: state.step_seq,
|
||||||
|
status: status.as_str().to_owned(),
|
||||||
|
output: output.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state.event_seq = seq;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_step(
|
||||||
|
&self,
|
||||||
|
state: &LoopState,
|
||||||
|
tool: &PendingTool,
|
||||||
|
status: StepStatus,
|
||||||
|
output: &Value,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
steps::append(
|
||||||
|
&self.inner.pool,
|
||||||
|
&Step {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
message_id: state.reply_message_id,
|
||||||
|
seq: state.step_seq,
|
||||||
|
kind: "tool_call".into(),
|
||||||
|
tool_name: Some(tool.name.clone()),
|
||||||
|
input: Some(tool.input.clone()),
|
||||||
|
output: Some(output.clone()),
|
||||||
|
taint: vec![],
|
||||||
|
status,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -326,22 +612,13 @@ fn chat_messages(history: &[MessageWithSteps], user_text: &str) -> Vec<ChatMessa
|
|||||||
};
|
};
|
||||||
Some(ChatMessage {
|
Some(ChatMessage {
|
||||||
role,
|
role,
|
||||||
parts: vec![ContentPart::Text(text.to_owned())],
|
parts: vec![ContentPart::text(text)],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.push(ChatMessage {
|
out.push(ChatMessage {
|
||||||
role: ChatRole::User,
|
role: ChatRole::User,
|
||||||
parts: vec![ContentPart::Text(user_text.to_owned())],
|
parts: vec![ContentPart::text(user_text)],
|
||||||
});
|
});
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sequence continuation for the failure path: errors are journaled after
|
|
||||||
/// whatever the loop already wrote.
|
|
||||||
async fn last_journaled_seq(pool: &PgPool, run_id: Uuid) -> i64 {
|
|
||||||
runs::get(pool, run_id)
|
|
||||||
.await
|
|
||||||
.map(|r| r.last_event_id)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
//! Built-in ungated tools. P1 ships server-side tools with no external
|
//! Built-in tools. Each tool declares its effects (spec §15); the gate
|
||||||
//! effects; sandboxed environment tools and the gate policy arrive in P2.
|
//! policy decides from those declarations whether human approval is
|
||||||
|
//! required before execution.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{AgentId, WorkspaceId};
|
||||||
use tc_llm::ToolDescriptor;
|
use tc_llm::ToolDescriptor;
|
||||||
|
use tc_tools::Effect;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Execution context handed to tools: who is acting, for which tenant.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ToolContext {
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub workspace_id: WorkspaceId,
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait Tool: Send + Sync {
|
pub trait Tool: Send + Sync {
|
||||||
fn descriptor(&self) -> ToolDescriptor;
|
fn descriptor(&self) -> ToolDescriptor;
|
||||||
async fn execute(&self, input: Value) -> Result<Value, String>;
|
/// Declared effects; the gate policy classifies from these.
|
||||||
|
fn effects(&self) -> &'static [Effect];
|
||||||
|
/// The exact human-facing preview for approval cards (§10).
|
||||||
|
fn preview(&self, input: &Value) -> Value {
|
||||||
|
input.clone()
|
||||||
|
}
|
||||||
|
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current UTC time — the simplest real tool; lets scenarios and the UI
|
/// Current UTC time — effect-free, never gated.
|
||||||
/// exercise full step traces without a sandbox.
|
|
||||||
pub struct ClockNow;
|
pub struct ClockNow;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -27,7 +45,11 @@ impl Tool for ClockNow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _input: Value) -> Result<Value, String> {
|
fn effects(&self) -> &'static [Effect] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, _ctx: &ToolContext, _input: Value) -> Result<Value, String> {
|
||||||
let now = time::OffsetDateTime::now_utc()
|
let now = time::OffsetDateTime::now_utc()
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -35,6 +57,69 @@ impl Tool for ClockNow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queues an outbound email — a §15 gated category (outbound message).
|
||||||
|
/// The real effect is an `outbox` row; the delivery transport drains the
|
||||||
|
/// queue in P4. This row must only ever exist after explicit approval.
|
||||||
|
pub struct EmailSend;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for EmailSend {
|
||||||
|
fn descriptor(&self) -> ToolDescriptor {
|
||||||
|
ToolDescriptor {
|
||||||
|
name: "email.send".into(),
|
||||||
|
description: "Sends an email outside the workspace. Requires \
|
||||||
|
human approval before it executes."
|
||||||
|
.into(),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"to": {"type": "string"},
|
||||||
|
"subject": {"type": "string"},
|
||||||
|
"body": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["to", "subject", "body"],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> &'static [Effect] {
|
||||||
|
&[Effect::SendsExternally]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview(&self, input: &Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"summary": format!(
|
||||||
|
"Send email to {}",
|
||||||
|
input["to"].as_str().unwrap_or("(missing recipient)")
|
||||||
|
),
|
||||||
|
"to": input["to"],
|
||||||
|
"subject": input["subject"],
|
||||||
|
"body": input["body"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String> {
|
||||||
|
let to = input["to"].as_str().ok_or("missing 'to'")?;
|
||||||
|
let subject = input["subject"].as_str().ok_or("missing 'subject'")?;
|
||||||
|
let body = input["body"].as_str().ok_or("missing 'body'")?;
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO outbox (id, workspace_id, agent_id, recipient, subject, body)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
id,
|
||||||
|
ctx.workspace_id.as_uuid(),
|
||||||
|
ctx.agent_id.as_uuid(),
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.execute(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(json!({ "queued": true, "outbox_id": id.to_string() }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ToolRegistry {
|
pub struct ToolRegistry {
|
||||||
tools: HashMap<String, Arc<dyn Tool>>,
|
tools: HashMap<String, Arc<dyn Tool>>,
|
||||||
}
|
}
|
||||||
@@ -45,6 +130,7 @@ impl Default for ToolRegistry {
|
|||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
};
|
};
|
||||||
registry.register(Arc::new(ClockNow));
|
registry.register(Arc::new(ClockNow));
|
||||||
|
registry.register(Arc::new(EmailSend));
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,9 +146,28 @@ impl ToolRegistry {
|
|||||||
all
|
all
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(&self, name: &str, input: Value) -> Result<Value, String> {
|
/// Declared effects of a tool; unknown tools have none (they cannot
|
||||||
|
/// execute anything — `execute` fails for them).
|
||||||
|
pub fn effects_of(&self, name: &str) -> &'static [Effect] {
|
||||||
|
self.tools.get(name).map(|t| t.effects()).unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The approval-card preview for a tool input.
|
||||||
|
pub fn preview_of(&self, name: &str, input: &Value) -> Value {
|
||||||
|
self.tools
|
||||||
|
.get(name)
|
||||||
|
.map(|t| t.preview(input))
|
||||||
|
.unwrap_or_else(|| input.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
ctx: &ToolContext,
|
||||||
|
name: &str,
|
||||||
|
input: Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
match self.tools.get(name) {
|
match self.tools.get(name) {
|
||||||
Some(tool) => tool.execute(input).await,
|
Some(tool) => tool.execute(ctx, input).await,
|
||||||
None => Err(format!("unknown tool: {name}")),
|
None => Err(format!("unknown tool: {name}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
//! The acceptance-blocking §15 chain at the runtime level: a gated tool
|
||||||
|
//! call is intercepted, previewed, queued, blocks the run, executes only
|
||||||
|
//! on approval (single-use grant), and is fully audited.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
|
||||||
|
};
|
||||||
|
use tc_llm::ScriptedProvider;
|
||||||
|
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig};
|
||||||
|
use tc_safety::{approvals, ApprovalStatus, Decision};
|
||||||
|
|
||||||
|
const SCENARIOS: &str = r#"
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:gated-email]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "I'll send that email." },
|
||||||
|
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Q2 numbers", body = "Revenue is up 14%." } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = " The email step is finished." },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
struct Seed {
|
||||||
|
workspace: Workspace,
|
||||||
|
owner: User,
|
||||||
|
agent: Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seeded(pool: &sqlx::PgPool) -> Seed {
|
||||||
|
let workspace = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(pool, &workspace)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(pool, &owner).await.unwrap();
|
||||||
|
let agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Analyst".into(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
avatar: String::new(),
|
||||||
|
accent: String::new(),
|
||||||
|
wallpaper: String::new(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Online,
|
||||||
|
};
|
||||||
|
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
Seed {
|
||||||
|
workspace,
|
||||||
|
owner,
|
||||||
|
agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime(pool: sqlx::PgPool) -> Runtime {
|
||||||
|
Runtime::new(
|
||||||
|
pool,
|
||||||
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
||||||
|
RuntimeConfig {
|
||||||
|
model: "scripted".into(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drain_until_suspended(
|
||||||
|
mut rx: tokio::sync::broadcast::Receiver<tc_runtime::RunEventEnvelope>,
|
||||||
|
) -> Vec<tc_runtime::RunEventEnvelope> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
while let Ok(envelope) = rx.recv().await {
|
||||||
|
let done = matches!(
|
||||||
|
envelope.event,
|
||||||
|
RunEventBody::RunSuspended { .. }
|
||||||
|
| RunEventBody::RunCompleted { .. }
|
||||||
|
| RunEventBody::Error { .. }
|
||||||
|
);
|
||||||
|
events.push(envelope);
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn outbox_count(pool: &sqlx::PgPool) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the run reaches a terminal state, reading the journal (the
|
||||||
|
/// resumed run streams on a new channel; the journal is the durable feed).
|
||||||
|
async fn wait_terminal(pool: &sqlx::PgPool, run_id: uuid::Uuid) -> RunState {
|
||||||
|
for _ in 0..100 {
|
||||||
|
let run = tc_db::repo::runs::get(pool, run_id).await.unwrap();
|
||||||
|
if run.state != RunState::Running && run.state != RunState::AwaitingApproval {
|
||||||
|
return run.state;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
panic!("run never reached a terminal state");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_gated_run(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
seed: &Seed,
|
||||||
|
rt: &Runtime,
|
||||||
|
) -> (uuid::Uuid, tc_safety::Approval) {
|
||||||
|
let session = tc_db::repo::sessions::create(pool, seed.agent.id, seed.workspace.id, "Chat")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let started = rt
|
||||||
|
.send_message(session.id, "send it [[scenario:gated-email]]")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let events = drain_until_suspended(started.events).await;
|
||||||
|
|
||||||
|
// Intercepted and previewed: the approval_required event carries the
|
||||||
|
// exact human-facing preview.
|
||||||
|
let preview = events
|
||||||
|
.iter()
|
||||||
|
.find_map(|e| match &e.event {
|
||||||
|
RunEventBody::ApprovalRequired { preview, .. } => Some(preview.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("approval_required event");
|
||||||
|
assert_eq!(preview["summary"], "Send email to [email protected]");
|
||||||
|
assert_eq!(preview["body"], "Revenue is up 14%.");
|
||||||
|
assert!(matches!(
|
||||||
|
events.last().unwrap().event,
|
||||||
|
RunEventBody::RunSuspended { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
// Blocked: nothing executed, run suspended, approval queued.
|
||||||
|
assert_eq!(outbox_count(pool).await, 0);
|
||||||
|
let run = tc_db::repo::runs::get(pool, started.run_id).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
run.state,
|
||||||
|
RunState::AwaitingApproval,
|
||||||
|
"run error: {:?}",
|
||||||
|
run.error
|
||||||
|
);
|
||||||
|
let pending = approvals::list_pending(pool, seed.workspace.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
(started.run_id, pending.into_iter().next().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gated_call_blocks_then_executes_only_after_approval() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let rt = runtime(pool.clone());
|
||||||
|
let (run_id, approval) = start_gated_run(&pool, &seed, &rt).await;
|
||||||
|
|
||||||
|
// Approve through the real decision path; the sweeper resumes the run.
|
||||||
|
rt.spawn_resume_sweeper(Duration::from_millis(50));
|
||||||
|
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wait_terminal(&pool, run_id).await, RunState::Completed);
|
||||||
|
|
||||||
|
// Approve-only execution: exactly one outbox row, with the approved payload.
|
||||||
|
assert_eq!(outbox_count(&pool).await, 1);
|
||||||
|
let recipient = sqlx::query_scalar::<_, String>("SELECT recipient FROM outbox LIMIT 1")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(recipient, "[email protected]");
|
||||||
|
|
||||||
|
// Audited: the decision is in the audit log.
|
||||||
|
let audited = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT count(*) FROM audit_log WHERE event_type = 'approval.approved'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(audited, 1);
|
||||||
|
|
||||||
|
// The journal contains the full chain for replay.
|
||||||
|
let journal = tc_db::repo::run_events::list_after(&pool, run_id, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let kinds: Vec<&str> = journal.iter().map(|e| e.event_type.as_str()).collect();
|
||||||
|
assert!(kinds.contains(&"approval_required"));
|
||||||
|
assert!(kinds.contains(&"run_suspended"));
|
||||||
|
assert!(kinds.contains(&"step_finished"));
|
||||||
|
assert_eq!(*kinds.last().unwrap(), "run_completed");
|
||||||
|
// Sequence numbers are continuous across the suspension.
|
||||||
|
let seqs: Vec<i64> = journal.iter().map(|e| e.seq).collect();
|
||||||
|
assert_eq!(seqs, (1..=seqs.len() as i64).collect::<Vec<_>>());
|
||||||
|
|
||||||
|
// The step trace on the reply records the approved execution.
|
||||||
|
let session_id = tc_db::repo::runs::get(&pool, run_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.session_id;
|
||||||
|
let history = tc_db::repo::messages::history(&pool, session_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reply = history.last().unwrap();
|
||||||
|
assert_eq!(reply.steps.len(), 1);
|
||||||
|
assert_eq!(reply.steps[0].status, tc_domain::StepStatus::Ok);
|
||||||
|
let text = reply.message.content["text"].as_str().unwrap();
|
||||||
|
assert!(text.contains("The email step is finished."), "got: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejection_executes_nothing_and_informs_the_model() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let rt = runtime(pool.clone());
|
||||||
|
let (run_id, approval) = start_gated_run(&pool, &seed, &rt).await;
|
||||||
|
|
||||||
|
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Reject)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
rt.resume_run(tc_safety::ResumeReady {
|
||||||
|
run_id,
|
||||||
|
approval_id: approval.id,
|
||||||
|
approved: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wait_terminal(&pool, run_id).await, RunState::Completed);
|
||||||
|
// Nothing executed, ever.
|
||||||
|
assert_eq!(outbox_count(&pool).await, 0);
|
||||||
|
|
||||||
|
// The model saw the structured rejection and continued in-band.
|
||||||
|
let session_id = tc_db::repo::runs::get(&pool, run_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.session_id;
|
||||||
|
let history = tc_db::repo::messages::history(&pool, session_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reply = history.last().unwrap();
|
||||||
|
assert_eq!(reply.steps.len(), 1);
|
||||||
|
assert_eq!(reply.steps[0].status, tc_domain::StepStatus::Error);
|
||||||
|
assert!(reply.steps[0].output.as_ref().unwrap()["rejected"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("rejected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_spent_grant_cannot_execute_again() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let rt = runtime(pool.clone());
|
||||||
|
let (run_id, approval) = start_gated_run(&pool, &seed, &rt).await;
|
||||||
|
|
||||||
|
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// An attacker (or crashed half-resume) already consumed the grant.
|
||||||
|
tc_safety::grants::consume(&pool, approval.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
rt.resume_run(tc_safety::ResumeReady {
|
||||||
|
run_id,
|
||||||
|
approval_id: approval.id,
|
||||||
|
approved: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wait_terminal(&pool, run_id).await, RunState::Completed);
|
||||||
|
// The gated action did NOT run a second time — no outbox row at all,
|
||||||
|
// because the only consumption happened outside the executor.
|
||||||
|
assert_eq!(outbox_count(&pool).await, 0);
|
||||||
|
let approval_after = approvals::get(&pool, approval.id).await.unwrap();
|
||||||
|
assert_eq!(approval_after.status, ApprovalStatus::Approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ungated_tools_run_without_any_approval_rows() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let scenarios = r#"
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:clock]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [ { type = "tool_use", name = "clock.now", input = {} } ]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [ { type = "text", text = "Done." } ]
|
||||||
|
"#;
|
||||||
|
let rt = Runtime::new(
|
||||||
|
pool.clone(),
|
||||||
|
Arc::new(ScriptedProvider::from_toml(scenarios).unwrap()),
|
||||||
|
RuntimeConfig {
|
||||||
|
model: "scripted".into(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let session = tc_db::repo::sessions::create(&pool, seed.agent.id, seed.workspace.id, "Chat")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let started = rt
|
||||||
|
.send_message(session.id, "time [[scenario:clock]]")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drain_until_suspended(started.events).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
wait_terminal(&pool, started.run_id).await,
|
||||||
|
RunState::Completed
|
||||||
|
);
|
||||||
|
let pending = approvals::list_pending(&pool, seed.workspace.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(pending.is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-safety"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
tc-db = { path = "../tc-db" }
|
||||||
|
tc-domain = { path = "../tc-domain" }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
time = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tc-testkit = { path = "../tc-testkit" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{AgentId, UserId, WorkspaceId};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Approval, ApprovalStatus, Decision, NewApproval, ResumeReady, SafetyError};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn map_row(
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
run_id: Uuid,
|
||||||
|
session_key: String,
|
||||||
|
action_type: String,
|
||||||
|
category: String,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
preview: serde_json::Value,
|
||||||
|
requested_by_agent: Uuid,
|
||||||
|
taint_sources: Vec<String>,
|
||||||
|
status: String,
|
||||||
|
decided_by: Option<Uuid>,
|
||||||
|
created_at: time::OffsetDateTime,
|
||||||
|
) -> Approval {
|
||||||
|
Approval {
|
||||||
|
id,
|
||||||
|
workspace_id: WorkspaceId::from(workspace_id),
|
||||||
|
run_id,
|
||||||
|
session_key,
|
||||||
|
action_type,
|
||||||
|
category: category.parse().expect("category CHECK constraint"),
|
||||||
|
payload,
|
||||||
|
preview,
|
||||||
|
requested_by_agent: AgentId::from(requested_by_agent),
|
||||||
|
taint_sources,
|
||||||
|
status: status.parse().expect("status CHECK constraint"),
|
||||||
|
decided_by: decided_by.map(UserId::from),
|
||||||
|
created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! approval_from {
|
||||||
|
($row:expr) => {
|
||||||
|
map_row(
|
||||||
|
$row.id,
|
||||||
|
$row.workspace_id,
|
||||||
|
$row.run_id,
|
||||||
|
$row.session_key,
|
||||||
|
$row.action_type,
|
||||||
|
$row.category,
|
||||||
|
$row.payload,
|
||||||
|
$row.preview,
|
||||||
|
$row.requested_by_agent,
|
||||||
|
$row.taint_sources,
|
||||||
|
$row.status,
|
||||||
|
$row.decided_by,
|
||||||
|
$row.created_at,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(pool: &PgPool, new: NewApproval) -> Result<Approval, SafetyError> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"INSERT INTO approvals
|
||||||
|
(id, workspace_id, run_id, session_key, action_type, category,
|
||||||
|
payload, preview, requested_by_agent, taint_sources, status,
|
||||||
|
expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11)
|
||||||
|
RETURNING id, workspace_id, run_id, session_key, action_type,
|
||||||
|
category, payload, preview, requested_by_agent,
|
||||||
|
taint_sources, status, decided_by, created_at"#,
|
||||||
|
id,
|
||||||
|
new.workspace_id.as_uuid(),
|
||||||
|
new.run_id,
|
||||||
|
new.session_key,
|
||||||
|
new.action_type,
|
||||||
|
new.category.as_str(),
|
||||||
|
new.payload,
|
||||||
|
new.preview,
|
||||||
|
new.requested_by_agent.as_uuid(),
|
||||||
|
&new.taint_sources,
|
||||||
|
new.expires_at,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(approval_from!(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Approval, SafetyError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"SELECT id, workspace_id, run_id, session_key, action_type,
|
||||||
|
category, payload, preview, requested_by_agent,
|
||||||
|
taint_sources, status, decided_by, created_at
|
||||||
|
FROM approvals WHERE id = $1"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(SafetyError::NotFound)?;
|
||||||
|
Ok(approval_from!(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The approval queue (§10), oldest first so reviews happen in order.
|
||||||
|
pub async fn list_pending(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<Vec<Approval>, SafetyError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT id, workspace_id, run_id, session_key, action_type,
|
||||||
|
category, payload, preview, requested_by_agent,
|
||||||
|
taint_sources, status, decided_by, created_at
|
||||||
|
FROM approvals
|
||||||
|
WHERE workspace_id = $1 AND status = 'pending'
|
||||||
|
ORDER BY created_at"#,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|row| approval_from!(row)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotent decision: a compare-and-swap from `pending`, the audit row,
|
||||||
|
/// and (on approval) the single-use grant — one transaction. Losing the
|
||||||
|
/// race surfaces as `AlreadyDecided`, never a double execution.
|
||||||
|
pub async fn decide(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
decided_by: UserId,
|
||||||
|
decision: Decision,
|
||||||
|
) -> Result<Approval, SafetyError> {
|
||||||
|
let status = match decision {
|
||||||
|
Decision::Approve => ApprovalStatus::Approved,
|
||||||
|
Decision::Reject => ApprovalStatus::Rejected,
|
||||||
|
};
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"UPDATE approvals
|
||||||
|
SET status = $2, decided_by = $3, decided_at = now()
|
||||||
|
WHERE id = $1 AND status = 'pending'
|
||||||
|
RETURNING id, workspace_id, run_id, session_key, action_type,
|
||||||
|
category, payload, preview, requested_by_agent,
|
||||||
|
taint_sources, status, decided_by, created_at"#,
|
||||||
|
id,
|
||||||
|
status.as_str(),
|
||||||
|
decided_by.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
// Distinguish missing from already-decided for honest API errors.
|
||||||
|
let exists = sqlx::query_scalar!("SELECT 1 AS x FROM approvals WHERE id = $1", id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
return Err(if exists {
|
||||||
|
SafetyError::AlreadyDecided
|
||||||
|
} else {
|
||||||
|
SafetyError::NotFound
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let approval = approval_from!(row);
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO audit_log
|
||||||
|
(workspace_id, actor_kind, actor_id, event_type, subject_type,
|
||||||
|
subject_id, detail)
|
||||||
|
VALUES ($1, 'user', $2, $3, 'approval', $4, $5)",
|
||||||
|
approval.workspace_id.as_uuid(),
|
||||||
|
decided_by.as_uuid(),
|
||||||
|
match decision {
|
||||||
|
Decision::Approve => "approval.approved",
|
||||||
|
Decision::Reject => "approval.rejected",
|
||||||
|
},
|
||||||
|
approval.id.to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"action_type": approval.action_type,
|
||||||
|
"category": approval.category,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if decision == Decision::Approve {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO execution_grants (id, approval_id, nonce)
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
Uuid::now_v7(),
|
||||||
|
approval.id,
|
||||||
|
Uuid::now_v7().to_string(),
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(approval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expires overdue pending approvals; their runs fail on resume sweep.
|
||||||
|
pub async fn sweep_expired(pool: &PgPool) -> Result<u64, SafetyError> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE approvals SET status = 'expired'
|
||||||
|
WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at < now()",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decided approvals whose suspended runs still await resumption — the
|
||||||
|
/// durable work queue for the resume sweeper.
|
||||||
|
pub async fn decided_unresumed(pool: &PgPool) -> Result<Vec<ResumeReady>, SafetyError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT a.run_id, a.id AS approval_id, a.status
|
||||||
|
FROM approvals a
|
||||||
|
JOIN agent_runs r ON r.id = a.run_id
|
||||||
|
WHERE a.status IN ('approved', 'rejected')
|
||||||
|
AND r.state = 'awaiting_approval'
|
||||||
|
ORDER BY a.decided_at"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| ResumeReady {
|
||||||
|
run_id: row.run_id,
|
||||||
|
approval_id: row.approval_id,
|
||||||
|
approved: row.status == "approved",
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::SafetyError;
|
||||||
|
|
||||||
|
/// Suspends a run: persists the full serialized loop state and flips the
|
||||||
|
/// run to `awaiting_approval` atomically.
|
||||||
|
pub async fn suspend(
|
||||||
|
pool: &PgPool,
|
||||||
|
run_id: Uuid,
|
||||||
|
state: &serde_json::Value,
|
||||||
|
) -> Result<(), SafetyError> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE agent_runs
|
||||||
|
SET checkpoint = $2, state = 'awaiting_approval', updated_at = now()
|
||||||
|
WHERE id = $1",
|
||||||
|
run_id,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(SafetyError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(pool: &PgPool, run_id: Uuid) -> Result<serde_json::Value, SafetyError> {
|
||||||
|
let row = sqlx::query!("SELECT checkpoint FROM agent_runs WHERE id = $1", run_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(SafetyError::NotFound)?;
|
||||||
|
row.checkpoint.ok_or(SafetyError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claims a suspended run for resumption. The CAS from `awaiting_approval`
|
||||||
|
/// to `running` ensures exactly one resumer proceeds.
|
||||||
|
pub async fn claim_resume(pool: &PgPool, run_id: Uuid) -> Result<bool, SafetyError> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE agent_runs SET state = 'running', updated_at = now()
|
||||||
|
WHERE id = $1 AND state = 'awaiting_approval'",
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() == 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::SafetyError;
|
||||||
|
|
||||||
|
/// Consumes the single-use execution grant for an approval. The
|
||||||
|
/// compare-and-swap guarantees a gated action can execute at most once,
|
||||||
|
/// even against a compromised or racing caller (§15 defense in depth).
|
||||||
|
pub async fn consume(pool: &PgPool, approval_id: Uuid) -> Result<(), SafetyError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"UPDATE execution_grants
|
||||||
|
SET consumed = true, consumed_at = now()
|
||||||
|
WHERE approval_id = $1 AND consumed = false
|
||||||
|
RETURNING id",
|
||||||
|
approval_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
if row.is_none() {
|
||||||
|
return Err(SafetyError::GrantUnavailable);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//! The approval state machine (spec §15, acceptance-blocking).
|
||||||
|
//!
|
||||||
|
//! A gated tool call becomes a pending approval with the exact payload and
|
||||||
|
//! rendered preview. Decisions are idempotent compare-and-swaps audited in
|
||||||
|
//! the same transaction; approval mints a single-use execution grant the
|
||||||
|
//! executor must consume before the action runs. Suspended runs checkpoint
|
||||||
|
//! their full state and are claimed for resume exactly once.
|
||||||
|
|
||||||
|
pub mod approvals;
|
||||||
|
pub mod checkpoint;
|
||||||
|
pub mod grants;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tc_domain::{AgentId, GatedCategory, UserId, WorkspaceId};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SafetyError {
|
||||||
|
#[error("approval not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("approval already decided")]
|
||||||
|
AlreadyDecided,
|
||||||
|
#[error("no consumable grant for this approval")]
|
||||||
|
GrantUnavailable,
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApprovalStatus {
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Rejected,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ApprovalStatus::Pending => "pending",
|
||||||
|
ApprovalStatus::Approved => "approved",
|
||||||
|
ApprovalStatus::Rejected => "rejected",
|
||||||
|
ApprovalStatus::Expired => "expired",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ApprovalStatus {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"pending" => Ok(ApprovalStatus::Pending),
|
||||||
|
"approved" => Ok(ApprovalStatus::Approved),
|
||||||
|
"rejected" => Ok(ApprovalStatus::Rejected),
|
||||||
|
"expired" => Ok(ApprovalStatus::Expired),
|
||||||
|
other => Err(format!("unknown approval status: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Approve,
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for a new pending approval.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewApproval {
|
||||||
|
pub workspace_id: WorkspaceId,
|
||||||
|
pub run_id: Uuid,
|
||||||
|
pub session_key: String,
|
||||||
|
pub action_type: String,
|
||||||
|
pub category: GatedCategory,
|
||||||
|
/// The exact tool input that will execute on approval.
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
/// The exact rendering shown to the human (§10 approval card).
|
||||||
|
pub preview: serde_json::Value,
|
||||||
|
pub requested_by_agent: AgentId,
|
||||||
|
pub taint_sources: Vec<String>,
|
||||||
|
pub expires_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Approval {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub workspace_id: WorkspaceId,
|
||||||
|
pub run_id: Uuid,
|
||||||
|
pub session_key: String,
|
||||||
|
pub action_type: String,
|
||||||
|
pub category: GatedCategory,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub preview: serde_json::Value,
|
||||||
|
pub requested_by_agent: AgentId,
|
||||||
|
pub taint_sources: Vec<String>,
|
||||||
|
pub status: ApprovalStatus,
|
||||||
|
pub decided_by: Option<UserId>,
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decided approval whose suspended run has not been resumed yet.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ResumeReady {
|
||||||
|
pub run_id: Uuid,
|
||||||
|
pub approval_id: Uuid,
|
||||||
|
pub approved: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentStatus, GatedCategory, Role, User, UserId, Workspace,
|
||||||
|
WorkspaceId,
|
||||||
|
};
|
||||||
|
use tc_safety::{
|
||||||
|
approvals, checkpoint, grants, ApprovalStatus, Decision, NewApproval, SafetyError,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Seed {
|
||||||
|
workspace: Workspace,
|
||||||
|
owner: User,
|
||||||
|
agent: Agent,
|
||||||
|
run_id: uuid::Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seeded(pool: &sqlx::PgPool) -> Seed {
|
||||||
|
let workspace = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(pool, &workspace)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(pool, &owner).await.unwrap();
|
||||||
|
let agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Analyst".into(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
avatar: String::new(),
|
||||||
|
accent: String::new(),
|
||||||
|
wallpaper: String::new(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Online,
|
||||||
|
};
|
||||||
|
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let session = tc_db::repo::sessions::create(pool, agent.id, workspace.id, "Chat")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let run_id = tc_db::repo::runs::create(pool, session.id).await.unwrap();
|
||||||
|
Seed {
|
||||||
|
workspace,
|
||||||
|
owner,
|
||||||
|
agent,
|
||||||
|
run_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_approval(seed: &Seed) -> NewApproval {
|
||||||
|
NewApproval {
|
||||||
|
workspace_id: seed.workspace.id,
|
||||||
|
run_id: seed.run_id,
|
||||||
|
session_key: "agent:x-claw-0:session:y:z".into(),
|
||||||
|
action_type: "email.send".into(),
|
||||||
|
category: GatedCategory::OutboundMessage,
|
||||||
|
payload: json!({"to": "[email protected]", "subject": "Q2", "body": "Numbers"}),
|
||||||
|
preview: json!({"summary": "Send email to [email protected]", "body": "Numbers"}),
|
||||||
|
requested_by_agent: seed.agent.id,
|
||||||
|
taint_sources: vec![],
|
||||||
|
expires_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn created_approvals_are_listed_pending_with_exact_preview() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||||
|
assert_eq!(approval.status, ApprovalStatus::Pending);
|
||||||
|
|
||||||
|
let pending = approvals::list_pending(&pool, seed.workspace.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].id, approval.id);
|
||||||
|
assert_eq!(
|
||||||
|
pending[0].preview["summary"],
|
||||||
|
"Send email to [email protected]"
|
||||||
|
);
|
||||||
|
assert_eq!(pending[0].category, GatedCategory::OutboundMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approve_is_a_cas_that_mints_one_grant_and_audits() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||||
|
|
||||||
|
let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decided.status, ApprovalStatus::Approved);
|
||||||
|
assert_eq!(decided.decided_by, Some(seed.owner.id));
|
||||||
|
|
||||||
|
// Double-decide is rejected, not silently absorbed.
|
||||||
|
let again = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await;
|
||||||
|
assert!(matches!(again, Err(SafetyError::AlreadyDecided)));
|
||||||
|
|
||||||
|
// Decision is audit-logged.
|
||||||
|
let audited = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT count(*) FROM audit_log WHERE event_type = 'approval.approved'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(audited, 1);
|
||||||
|
|
||||||
|
// The grant is single-use: first consumption succeeds, second refuses.
|
||||||
|
grants::consume(&pool, approval.id).await.unwrap();
|
||||||
|
let reused = grants::consume(&pool, approval.id).await;
|
||||||
|
assert!(matches!(reused, Err(SafetyError::GrantUnavailable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_mints_no_grant() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||||
|
|
||||||
|
let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Reject)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decided.status, ApprovalStatus::Rejected);
|
||||||
|
|
||||||
|
let no_grant = grants::consume(&pool, approval.id).await;
|
||||||
|
assert!(matches!(no_grant, Err(SafetyError::GrantUnavailable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_approvals_cannot_be_consumed() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||||
|
let blocked = grants::consume(&pool, approval.id).await;
|
||||||
|
assert!(matches!(blocked, Err(SafetyError::GrantUnavailable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkpoint_round_trips_and_resume_claim_is_exclusive() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
|
||||||
|
let state = json!({"request": {"model": "scripted"}, "step_seq": 3});
|
||||||
|
checkpoint::suspend(&pool, seed.run_id, &state)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let run = tc_db::repo::runs::get(&pool, seed.run_id).await.unwrap();
|
||||||
|
assert_eq!(run.state, tc_domain::RunState::AwaitingApproval);
|
||||||
|
|
||||||
|
let loaded = checkpoint::load(&pool, seed.run_id).await.unwrap();
|
||||||
|
assert_eq!(loaded, state);
|
||||||
|
|
||||||
|
// Exactly one resumer can claim the run.
|
||||||
|
assert!(checkpoint::claim_resume(&pool, seed.run_id).await.unwrap());
|
||||||
|
assert!(!checkpoint::claim_resume(&pool, seed.run_id).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expiry_sweep_expires_overdue_pending_approvals() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let mut overdue = new_approval(&seed);
|
||||||
|
overdue.expires_at = Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(5));
|
||||||
|
let approval = approvals::create(&pool, overdue).await.unwrap();
|
||||||
|
|
||||||
|
let expired = approvals::sweep_expired(&pool).await.unwrap();
|
||||||
|
assert_eq!(expired, 1);
|
||||||
|
let after = approvals::get(&pool, approval.id).await.unwrap();
|
||||||
|
assert_eq!(after.status, ApprovalStatus::Expired);
|
||||||
|
|
||||||
|
// Expired approvals cannot be decided.
|
||||||
|
let late = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await;
|
||||||
|
assert!(matches!(late, Err(SafetyError::AlreadyDecided)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn decided_unresumed_finds_runs_awaiting_resume() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let seed = seeded(&pool).await;
|
||||||
|
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||||
|
checkpoint::suspend(&pool, seed.run_id, &json!({"s": 1}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Nothing decided yet.
|
||||||
|
assert!(approvals::decided_unresumed(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
|
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ready = approvals::decided_unresumed(&pool).await.unwrap();
|
||||||
|
assert_eq!(ready.len(), 1);
|
||||||
|
assert_eq!(ready[0].run_id, seed.run_id);
|
||||||
|
assert_eq!(ready[0].approval_id, approval.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-tools"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tc-domain = { path = "../tc-domain" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! The gate policy (spec §15, acceptance-blocking).
|
||||||
|
//!
|
||||||
|
//! Tools declare *effects*; the policy maps effects to the six gated
|
||||||
|
//! categories. Classification is deny-by-default: any externally-visible
|
||||||
|
//! effect without a more specific category gates as an outbound message,
|
||||||
|
//! and input tainted by untrusted sources gates every external effect.
|
||||||
|
//! This module is pure — no I/O — so its invariants are property-tested.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tc_domain::GatedCategory;
|
||||||
|
|
||||||
|
/// What a tool can do, declared statically by the tool itself.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Effect {
|
||||||
|
/// Reads data inside the workspace boundary.
|
||||||
|
ReadsWorkspaceData,
|
||||||
|
/// Writes data inside the workspace boundary.
|
||||||
|
WritesWorkspaceData,
|
||||||
|
/// Contacts systems outside the workspace without sending user content
|
||||||
|
/// (e.g. fetching a public page).
|
||||||
|
ReachesExternally,
|
||||||
|
/// Sends messages/emails/posts outside the workspace.
|
||||||
|
SendsExternally,
|
||||||
|
/// Exposes keys, secrets, or credentials.
|
||||||
|
SharesSecrets,
|
||||||
|
/// Changes access, permissions, or sharing.
|
||||||
|
ChangesAccess,
|
||||||
|
/// Financial transactions and credit purchases.
|
||||||
|
MovesMoney,
|
||||||
|
/// Deletes files or records.
|
||||||
|
DeletesData,
|
||||||
|
/// Grants or extends external-infrastructure access.
|
||||||
|
GrantsInfraAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect {
|
||||||
|
/// Effects visible outside the workspace boundary.
|
||||||
|
pub fn is_external(&self) -> bool {
|
||||||
|
!matches!(
|
||||||
|
self,
|
||||||
|
Effect::ReadsWorkspaceData | Effect::WritesWorkspaceData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The §15 category this effect always gates as, if any.
|
||||||
|
fn gated_category(&self) -> Option<GatedCategory> {
|
||||||
|
match self {
|
||||||
|
Effect::SendsExternally => Some(GatedCategory::OutboundMessage),
|
||||||
|
Effect::SharesSecrets => Some(GatedCategory::SecretSharing),
|
||||||
|
Effect::ChangesAccess => Some(GatedCategory::AccessChange),
|
||||||
|
Effect::MovesMoney => Some(GatedCategory::FinancialTransaction),
|
||||||
|
Effect::DeletesData => Some(GatedCategory::FileDeletion),
|
||||||
|
Effect::GrantsInfraAccess => Some(GatedCategory::InfraAccessGrant),
|
||||||
|
Effect::ReadsWorkspaceData
|
||||||
|
| Effect::WritesWorkspaceData
|
||||||
|
| Effect::ReachesExternally => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Severity order for picking the headline category of a multi-effect
|
||||||
|
/// tool (most consequential wins).
|
||||||
|
fn severity(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Effect::ReadsWorkspaceData => 0,
|
||||||
|
Effect::WritesWorkspaceData => 1,
|
||||||
|
Effect::ReachesExternally => 2,
|
||||||
|
Effect::SendsExternally => 3,
|
||||||
|
Effect::DeletesData => 4,
|
||||||
|
Effect::ChangesAccess => 5,
|
||||||
|
Effect::SharesSecrets => 6,
|
||||||
|
Effect::GrantsInfraAccess => 7,
|
||||||
|
Effect::MovesMoney => 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a piece of content came from. Anything here marks the content as
|
||||||
|
/// untrusted-by-default (§15): data, never instructions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TaintSource {
|
||||||
|
Web,
|
||||||
|
Email,
|
||||||
|
InterAgent,
|
||||||
|
ToolResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaintSource {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TaintSource::Web => "web",
|
||||||
|
TaintSource::Email => "email",
|
||||||
|
TaintSource::InterAgent => "inter_agent",
|
||||||
|
TaintSource::ToolResult => "tool_result",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of untrusted sources that influenced a tool input.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TaintSet {
|
||||||
|
sources: Vec<TaintSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaintSet {
|
||||||
|
pub fn clean() -> TaintSet {
|
||||||
|
TaintSet::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_sources(sources: &[TaintSource]) -> TaintSet {
|
||||||
|
let mut unique: Vec<TaintSource> = Vec::new();
|
||||||
|
for source in sources {
|
||||||
|
if !unique.contains(source) {
|
||||||
|
unique.push(*source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TaintSet { sources: unique }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_clean(&self) -> bool {
|
||||||
|
self.sources.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sources(&self) -> &[TaintSource] {
|
||||||
|
&self.sources
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage form for `steps.taint` / `approvals.taint_sources`.
|
||||||
|
pub fn as_strings(&self) -> Vec<String> {
|
||||||
|
self.sources.iter().map(|s| s.as_str().to_owned()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GateDecision {
|
||||||
|
Allow,
|
||||||
|
RequireApproval(GatedCategory),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure classifier from declared effects + input taint to a decision.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct GatePolicy;
|
||||||
|
|
||||||
|
impl GatePolicy {
|
||||||
|
pub fn classify(&self, effects: &[Effect], taint: &TaintSet) -> GateDecision {
|
||||||
|
let headline = effects.iter().max_by_key(|e| e.severity()).copied();
|
||||||
|
|
||||||
|
let Some(headline) = headline else {
|
||||||
|
return GateDecision::Allow;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(category) = effects
|
||||||
|
.iter()
|
||||||
|
.filter_map(Effect::gated_category)
|
||||||
|
.max_by_key(|c| {
|
||||||
|
effects
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.gated_category() == Some(*c))
|
||||||
|
.map(|e| e.severity())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return GateDecision::RequireApproval(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No always-gated effect. External reach gates by default — and
|
||||||
|
// unconditionally when the input carries untrusted taint (§15).
|
||||||
|
if headline.is_external() {
|
||||||
|
return GateDecision::RequireApproval(GatedCategory::OutboundMessage);
|
||||||
|
}
|
||||||
|
if !taint.is_clean() && effects.iter().any(Effect::is_external) {
|
||||||
|
return GateDecision::RequireApproval(GatedCategory::OutboundMessage);
|
||||||
|
}
|
||||||
|
GateDecision::Allow
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use tc_domain::GatedCategory;
|
||||||
|
use tc_tools::{Effect, GateDecision, GatePolicy, TaintSet, TaintSource};
|
||||||
|
|
||||||
|
fn policy() -> GatePolicy {
|
||||||
|
GatePolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effect_free_tools_are_allowed() {
|
||||||
|
let decision = policy().classify(&[], &TaintSet::clean());
|
||||||
|
assert_eq!(decision, GateDecision::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_only_effects_are_allowed() {
|
||||||
|
let decision = policy().classify(&[Effect::ReadsWorkspaceData], &TaintSet::clean());
|
||||||
|
assert_eq!(decision, GateDecision::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_spec_15_category_has_a_triggering_effect() {
|
||||||
|
// The six gated categories (§15) and the effect that triggers each.
|
||||||
|
let cases = [
|
||||||
|
(Effect::SendsExternally, GatedCategory::OutboundMessage),
|
||||||
|
(Effect::SharesSecrets, GatedCategory::SecretSharing),
|
||||||
|
(Effect::ChangesAccess, GatedCategory::AccessChange),
|
||||||
|
(Effect::MovesMoney, GatedCategory::FinancialTransaction),
|
||||||
|
(Effect::DeletesData, GatedCategory::FileDeletion),
|
||||||
|
(Effect::GrantsInfraAccess, GatedCategory::InfraAccessGrant),
|
||||||
|
];
|
||||||
|
for (effect, category) in cases {
|
||||||
|
let decision = policy().classify(&[effect], &TaintSet::clean());
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
GateDecision::RequireApproval(category),
|
||||||
|
"{effect:?} must gate as {category:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_most_severe_effect_wins_for_multi_effect_tools() {
|
||||||
|
let decision = policy().classify(
|
||||||
|
&[Effect::ReadsWorkspaceData, Effect::MovesMoney],
|
||||||
|
&TaintSet::clean(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
GateDecision::RequireApproval(GatedCategory::FinancialTransaction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undeclared_external_reach_is_gated_by_default() {
|
||||||
|
// A tool that reaches outside the workspace but declares nothing
|
||||||
|
// specific is still gated (§15 untrusted-by-default).
|
||||||
|
let decision = policy().classify(&[Effect::ReachesExternally], &TaintSet::clean());
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
GateDecision::RequireApproval(GatedCategory::OutboundMessage)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tainted_input_gates_even_benign_external_tools() {
|
||||||
|
let taint = TaintSet::from_sources(&[TaintSource::Web]);
|
||||||
|
let decision = policy().classify(&[Effect::ReachesExternally], &taint);
|
||||||
|
assert!(matches!(decision, GateDecision::RequireApproval(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tainted_input_does_not_gate_purely_internal_reads() {
|
||||||
|
// Untrusted content is data, not instructions; reading workspace data
|
||||||
|
// with tainted input has no external effect to protect.
|
||||||
|
let taint = TaintSet::from_sources(&[TaintSource::InterAgent]);
|
||||||
|
let decision = policy().classify(&[Effect::ReadsWorkspaceData], &taint);
|
||||||
|
assert_eq!(decision, GateDecision::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
mod properties {
|
||||||
|
use super::*;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
fn arb_effect() -> impl Strategy<Value = Effect> {
|
||||||
|
prop_oneof![
|
||||||
|
Just(Effect::ReadsWorkspaceData),
|
||||||
|
Just(Effect::WritesWorkspaceData),
|
||||||
|
Just(Effect::ReachesExternally),
|
||||||
|
Just(Effect::SendsExternally),
|
||||||
|
Just(Effect::SharesSecrets),
|
||||||
|
Just(Effect::ChangesAccess),
|
||||||
|
Just(Effect::MovesMoney),
|
||||||
|
Just(Effect::DeletesData),
|
||||||
|
Just(Effect::GrantsInfraAccess),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arb_taint() -> impl Strategy<Value = TaintSet> {
|
||||||
|
proptest::collection::vec(
|
||||||
|
prop_oneof![
|
||||||
|
Just(TaintSource::Web),
|
||||||
|
Just(TaintSource::Email),
|
||||||
|
Just(TaintSource::InterAgent),
|
||||||
|
Just(TaintSource::ToolResult),
|
||||||
|
],
|
||||||
|
0..4,
|
||||||
|
)
|
||||||
|
.prop_map(|sources| TaintSet::from_sources(&sources))
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
/// §15 invariant: input carrying untrusted taint combined with ANY
|
||||||
|
/// externally-visible effect is never auto-allowed.
|
||||||
|
#[test]
|
||||||
|
fn tainted_external_is_never_allowed(
|
||||||
|
effects in proptest::collection::vec(arb_effect(), 1..4),
|
||||||
|
taint in arb_taint(),
|
||||||
|
) {
|
||||||
|
let has_external = effects.iter().any(|e| e.is_external());
|
||||||
|
let decision = GatePolicy.classify(&effects, &taint);
|
||||||
|
if !taint.is_clean() && has_external {
|
||||||
|
prop_assert!(
|
||||||
|
matches!(decision, GateDecision::RequireApproval(_)),
|
||||||
|
"tainted external effects must require approval, got {decision:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gated effects require approval regardless of taint.
|
||||||
|
#[test]
|
||||||
|
fn gated_effects_always_require_approval(
|
||||||
|
taint in arb_taint(),
|
||||||
|
) {
|
||||||
|
for effect in [
|
||||||
|
Effect::SendsExternally,
|
||||||
|
Effect::SharesSecrets,
|
||||||
|
Effect::ChangesAccess,
|
||||||
|
Effect::MovesMoney,
|
||||||
|
Effect::DeletesData,
|
||||||
|
Effect::GrantsInfraAccess,
|
||||||
|
] {
|
||||||
|
let decision = GatePolicy.classify(&[effect], &taint);
|
||||||
|
prop_assert!(matches!(decision, GateDecision::RequireApproval(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,17 @@ events = [
|
|||||||
events = [
|
events = [
|
||||||
{ type = "text", text = "Done — I checked the current time for you." },
|
{ type = "text", text = "Done — I checked the current time for you." },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:gated-email]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "I'll send that email once you approve it." },
|
||||||
|
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Q2 numbers", body = "Revenue is up 14% quarter over quarter." } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = " The email step is finished." },
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export default defineConfig({
|
|||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
|
// One worker: journeys share one backend + database; parallel spec files
|
||||||
|
// would interleave sessions and approval queues across tests.
|
||||||
|
workers: 1,
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApprovalQueue } from "@/components/safety/ApprovalQueue";
|
||||||
|
import { fetchPendingApprovals } from "@/lib/api/approvals";
|
||||||
|
|
||||||
|
// The approvals queue (§10): every gated action waiting on a human.
|
||||||
|
export default async function ApprovalsPage() {
|
||||||
|
const approvals = await fetchPendingApprovals();
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-8 py-12">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Approvals</h1>
|
||||||
|
<p className="pt-1 text-sm text-muted-foreground">
|
||||||
|
{approvals.length === 0
|
||||||
|
? "All clear — no actions awaiting review."
|
||||||
|
: `${approvals.length} ${
|
||||||
|
approvals.length === 1 ? "action awaits" : "actions await"
|
||||||
|
} your review. Nothing runs until you decide.`}
|
||||||
|
</p>
|
||||||
|
<ApprovalQueue approvals={approvals} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export function ChatWorkspace({
|
|||||||
sessions,
|
sessions,
|
||||||
}: ChatWorkspaceProps) {
|
}: ChatWorkspaceProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { state, send } = useChat(agent.id, sessionKey, initialMessages);
|
const { state, send, decide } = useChat(agent.id, sessionKey, initialMessages);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [{ sessions: showSessions }] = useQueryStates(panelParsers, {
|
const [{ sessions: showSessions }] = useQueryStates(panelParsers, {
|
||||||
shallow: true,
|
shallow: true,
|
||||||
@@ -74,7 +74,7 @@ export function ChatWorkspace({
|
|||||||
{state.messages.length === 0 ? (
|
{state.messages.length === 0 ? (
|
||||||
<WelcomeState agent={agent} onPick={setDraft} />
|
<WelcomeState agent={agent} onPick={setDraft} />
|
||||||
) : (
|
) : (
|
||||||
<MessageList messages={state.messages} agent={agent} />
|
<MessageList messages={state.messages} agent={agent} onDecide={decide} />
|
||||||
)}
|
)}
|
||||||
<Composer
|
<Composer
|
||||||
agentName={agent.name}
|
agentName={agent.name}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { useEffect, useRef } from "react";
|
|||||||
import type { UiMessage } from "@/lib/gateway/transcript";
|
import type { UiMessage } from "@/lib/gateway/transcript";
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { ApprovalCard } from "@/components/safety/ApprovalCard";
|
||||||
import { StepTrace } from "./steps/StepTrace";
|
import { StepTrace } from "./steps/StepTrace";
|
||||||
|
|
||||||
|
type DecideFn = (approvalId: string, decision: "approve" | "reject") => void;
|
||||||
|
|
||||||
function UserBubble({ message }: { message: UiMessage }) {
|
function UserBubble({ message }: { message: UiMessage }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -21,7 +24,15 @@ function UserBubble({ message }: { message: UiMessage }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent }) {
|
function AgentMessage({
|
||||||
|
message,
|
||||||
|
agent,
|
||||||
|
onDecide,
|
||||||
|
}: {
|
||||||
|
message: UiMessage;
|
||||||
|
agent: Agent;
|
||||||
|
onDecide: DecideFn;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Avatar name={agent.name} accent={agent.accent} size="sm" />
|
<Avatar name={agent.name} accent={agent.accent} size="sm" />
|
||||||
@@ -40,6 +51,13 @@ function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent })
|
|||||||
Something went wrong with this reply.
|
Something went wrong with this reply.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{message.status === "suspended" && message.pendingApproval && (
|
||||||
|
<ApprovalCard
|
||||||
|
approval={message.pendingApproval}
|
||||||
|
agentName={agent.name}
|
||||||
|
onDecide={onDecide}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StepTrace steps={message.steps} />
|
<StepTrace steps={message.steps} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,9 +68,11 @@ function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent })
|
|||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
agent,
|
agent,
|
||||||
|
onDecide,
|
||||||
}: {
|
}: {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
|
onDecide: DecideFn;
|
||||||
}) {
|
}) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const tailText = messages.at(-1)?.text;
|
const tailText = messages.at(-1)?.text;
|
||||||
@@ -70,7 +90,12 @@ export function MessageList({
|
|||||||
message.role === "user" ? (
|
message.role === "user" ? (
|
||||||
<UserBubble key={message.id} message={message} />
|
<UserBubble key={message.id} message={message} />
|
||||||
) : (
|
) : (
|
||||||
<AgentMessage key={message.id} message={message} agent={agent} />
|
<AgentMessage
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
agent={agent}
|
||||||
|
onDecide={onDecide}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ApprovalCard } from "./ApprovalCard";
|
||||||
|
|
||||||
|
const approval = {
|
||||||
|
id: "ap-1",
|
||||||
|
category: "outbound_message",
|
||||||
|
actionType: "email.send",
|
||||||
|
preview: {
|
||||||
|
summary: "Send email to [email protected]",
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Q2 numbers",
|
||||||
|
body: "Revenue is up 14%.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ApprovalCard", () => {
|
||||||
|
it("shows the summary, category, and the exact payload preview", () => {
|
||||||
|
render(
|
||||||
|
<ApprovalCard approval={approval} agentName="Scout" onDecide={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: "Review and approve" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The headline names the action; the preview repeats the exact payload.
|
||||||
|
expect(screen.getByText(/wants to:/)).toHaveTextContent(
|
||||||
|
"Send email to [email protected]",
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Outbound message/)).toBeInTheDocument();
|
||||||
|
// The preview block carries the exact payload that will execute.
|
||||||
|
expect(screen.getByText(/Revenue is up 14%\./)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("approve and reject report the decision and disable the buttons", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDecide = vi.fn();
|
||||||
|
render(
|
||||||
|
<ApprovalCard approval={approval} agentName="Scout" onDecide={onDecide} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Approve" }));
|
||||||
|
expect(onDecide).toHaveBeenCalledWith("ap-1", "approve");
|
||||||
|
expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reject reports reject", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDecide = vi.fn();
|
||||||
|
render(
|
||||||
|
<ApprovalCard approval={approval} agentName="Scout" onDecide={onDecide} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Reject" }));
|
||||||
|
expect(onDecide).toHaveBeenCalledWith("ap-1", "reject");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the action type when the preview has no summary", () => {
|
||||||
|
render(
|
||||||
|
<ApprovalCard
|
||||||
|
approval={{ ...approval, preview: { raw: true } }}
|
||||||
|
agentName="Scout"
|
||||||
|
onDecide={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/wants to:/)).toHaveTextContent("email.send");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { UiPendingApproval } from "@/lib/gateway/transcript";
|
||||||
|
|
||||||
|
interface ApprovalCardProps {
|
||||||
|
approval: UiPendingApproval;
|
||||||
|
onDecide: (approvalId: string, decision: "approve" | "reject") => void;
|
||||||
|
/** Agent name for the "this claw wants to" line. */
|
||||||
|
agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
outbound_message: "Outbound message",
|
||||||
|
secret_sharing: "Secret sharing",
|
||||||
|
access_change: "Access change",
|
||||||
|
financial_transaction: "Financial transaction",
|
||||||
|
file_deletion: "File deletion",
|
||||||
|
infra_access_grant: "Infrastructure access",
|
||||||
|
};
|
||||||
|
|
||||||
|
function previewField(preview: unknown, key: string): string | null {
|
||||||
|
if (typeof preview !== "object" || preview === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = (preview as Record<string, unknown>)[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The §10 approval card: what the claw wants to do, the EXACT payload
|
||||||
|
* that will execute, and the explicit human decision. */
|
||||||
|
export function ApprovalCard({ approval, onDecide, agentName }: ApprovalCardProps) {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const summary =
|
||||||
|
previewField(approval.preview, "summary") ?? approval.actionType;
|
||||||
|
|
||||||
|
function decide(decision: "approve" | "reject") {
|
||||||
|
setPending(true);
|
||||||
|
onDecide(approval.id, decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Review and approve"
|
||||||
|
className="mt-2 max-w-md rounded-(--radius) border border-accent/40 bg-surface-warm p-3 shadow-(--shadow-card) motion-safe:animate-[scale-in_var(--duration-fast)_var(--ease-app)]"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-accent">
|
||||||
|
Review & approve
|
||||||
|
</p>
|
||||||
|
<p className="pt-1 text-sm">
|
||||||
|
{agentName} wants to: <span className="font-medium">{summary}</span>
|
||||||
|
</p>
|
||||||
|
<p className="pt-0.5 text-xxs text-muted-foreground">
|
||||||
|
{CATEGORY_LABELS[approval.category] ?? approval.category} ·{" "}
|
||||||
|
{approval.actionType}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 rounded-(--radius) border border-border bg-background p-2">
|
||||||
|
<p className="text-xxs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Preview
|
||||||
|
</p>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap pt-1 font-mono text-xs text-foreground">
|
||||||
|
{JSON.stringify(approval.preview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => decide("reject")}
|
||||||
|
className="rounded-(--radius-button) border border-border px-4 py-1.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => decide("approve")}
|
||||||
|
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { Approval } from "@/lib/api/approvals";
|
||||||
|
import { ApprovalCard } from "./ApprovalCard";
|
||||||
|
|
||||||
|
/** The standalone review queue (§10). Decisions here resume the suspended
|
||||||
|
* runs server-side; open the session to watch the continuation. */
|
||||||
|
export function ApprovalQueue({ approvals }: { approvals: Approval[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function decide(approvalId: string, decision: "approve" | "reject") {
|
||||||
|
await fetch(`/api/approvals/${approvalId}/${decision}`, { method: "POST" });
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvals.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="pt-8 text-sm text-muted-foreground">
|
||||||
|
Nothing waiting for review.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-4 pt-6">
|
||||||
|
{approvals.map((approval) => (
|
||||||
|
<li key={approval.id}>
|
||||||
|
<ApprovalCard
|
||||||
|
approval={{
|
||||||
|
id: approval.id,
|
||||||
|
category: approval.category,
|
||||||
|
actionType: approval.action_type,
|
||||||
|
preview: approval.preview,
|
||||||
|
}}
|
||||||
|
agentName="A claw"
|
||||||
|
onDecide={decide}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { GlobalNavItem } from "./GlobalNavItem";
|
|||||||
/** Global nav entries that exist in the current phase. Skills and Apps join
|
/** Global nav entries that exist in the current phase. Skills and Apps join
|
||||||
* the rail when their pages land (spec §17 P1/P5). */
|
* the rail when their pages land (spec §17 P1/P5). */
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/approvals", label: "Approvals" },
|
||||||
{ href: "/team", label: "Team" },
|
{ href: "/team", label: "Team" },
|
||||||
{ href: "/credits", label: "Credits" },
|
{ href: "/credits", label: "Credits" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { apiFetch } from "./http";
|
||||||
|
|
||||||
|
export const ApprovalSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspace_id: z.string().uuid(),
|
||||||
|
run_id: z.string().uuid(),
|
||||||
|
session_key: z.string(),
|
||||||
|
action_type: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
payload: z.unknown(),
|
||||||
|
preview: z.unknown(),
|
||||||
|
requested_by_agent: z.string().uuid(),
|
||||||
|
taint_sources: z.array(z.string()),
|
||||||
|
status: z.enum(["pending", "approved", "rejected", "expired"]),
|
||||||
|
decided_by: z.string().uuid().nullable(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
export type Approval = z.infer<typeof ApprovalSchema>;
|
||||||
|
|
||||||
|
export function fetchPendingApprovals(): Promise<Approval[]> {
|
||||||
|
return apiFetch(z.array(ApprovalSchema), "/api/approvals");
|
||||||
|
}
|
||||||
@@ -18,6 +18,14 @@ export const GatewayEventSchema = z.discriminatedUnion("type", [
|
|||||||
status: z.enum(["ok", "error"]),
|
status: z.enum(["ok", "error"]),
|
||||||
output: z.unknown(),
|
output: z.unknown(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("approval_required"),
|
||||||
|
approval_id: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
action_type: z.string(),
|
||||||
|
preview: z.unknown(),
|
||||||
|
}),
|
||||||
|
z.object({ type: z.literal("run_suspended"), approval_id: z.string() }),
|
||||||
z.object({ type: z.literal("run_completed"), message_id: z.string() }),
|
z.object({ type: z.literal("run_completed"), message_id: z.string() }),
|
||||||
z.object({ type: z.literal("error"), message: z.string() }),
|
z.object({ type: z.literal("error"), message: z.string() }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -131,6 +131,65 @@ describe("transcriptReducer", () => {
|
|||||||
expect(state.messages.at(-1)!.text).toBe("x");
|
expect(state.messages.at(-1)!.text).toBe("x");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("approval flow: suspends with the card, resumes on decision", () => {
|
||||||
|
let state = transcriptReducer(initialTranscript(), {
|
||||||
|
kind: "send",
|
||||||
|
text: "send the email",
|
||||||
|
});
|
||||||
|
state = apply(
|
||||||
|
state,
|
||||||
|
{ seq: 1, event: { type: "run_started", run_id: "r1" } },
|
||||||
|
{ seq: 2, event: { type: "text_delta", delta: "I'll send it." } },
|
||||||
|
{
|
||||||
|
seq: 3,
|
||||||
|
event: {
|
||||||
|
type: "approval_required",
|
||||||
|
approval_id: "ap-1",
|
||||||
|
category: "outbound_message",
|
||||||
|
action_type: "email.send",
|
||||||
|
preview: { summary: "Send email to [email protected]" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ seq: 4, event: { type: "run_suspended", approval_id: "ap-1" } },
|
||||||
|
);
|
||||||
|
const reply = state.messages.at(-1)!;
|
||||||
|
expect(reply.status).toBe("suspended");
|
||||||
|
expect(reply.pendingApproval).toMatchObject({
|
||||||
|
id: "ap-1",
|
||||||
|
category: "outbound_message",
|
||||||
|
actionType: "email.send",
|
||||||
|
});
|
||||||
|
// Composer stays blocked while suspended.
|
||||||
|
expect(state.streaming).toBe(true);
|
||||||
|
|
||||||
|
// Human decides; the continuation streams and completes.
|
||||||
|
state = transcriptReducer(state, { kind: "decided" });
|
||||||
|
expect(state.messages.at(-1)!.status).toBe("streaming");
|
||||||
|
expect(state.messages.at(-1)!.pendingApproval).toBeUndefined();
|
||||||
|
|
||||||
|
state = apply(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
seq: 5,
|
||||||
|
event: { type: "step_started", step_seq: 1, tool: "email.send", input: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seq: 6,
|
||||||
|
event: {
|
||||||
|
type: "step_finished",
|
||||||
|
step_seq: 1,
|
||||||
|
status: "ok",
|
||||||
|
output: { queued: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ seq: 7, event: { type: "run_completed", message_id: "m9" } },
|
||||||
|
);
|
||||||
|
const done = state.messages.at(-1)!;
|
||||||
|
expect(done.status).toBe("complete");
|
||||||
|
expect(done.steps).toHaveLength(1);
|
||||||
|
expect(state.streaming).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("stream errors mark the reply failed", () => {
|
it("stream errors mark the reply failed", () => {
|
||||||
let state = transcriptReducer(initialTranscript(), {
|
let state = transcriptReducer(initialTranscript(), {
|
||||||
kind: "send",
|
kind: "send",
|
||||||
|
|||||||
@@ -12,12 +12,21 @@ export interface UiStep {
|
|||||||
status: "running" | "ok" | "error";
|
status: "running" | "ok" | "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A gated action awaiting review, rendered as the §10 approval card. */
|
||||||
|
export interface UiPendingApproval {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
actionType: string;
|
||||||
|
preview: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiMessage {
|
export interface UiMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "agent";
|
role: "user" | "agent";
|
||||||
text: string;
|
text: string;
|
||||||
steps: UiStep[];
|
steps: UiStep[];
|
||||||
status: "sending" | "streaming" | "complete" | "error";
|
status: "sending" | "streaming" | "suspended" | "complete" | "error";
|
||||||
|
pendingApproval?: UiPendingApproval;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptState {
|
export interface TranscriptState {
|
||||||
@@ -31,6 +40,8 @@ export type TranscriptAction =
|
|||||||
| { kind: "history"; messages: UiMessage[] }
|
| { kind: "history"; messages: UiMessage[] }
|
||||||
| { kind: "send"; text: string }
|
| { kind: "send"; text: string }
|
||||||
| { kind: "gateway"; envelope: GatewayEnvelope }
|
| { kind: "gateway"; envelope: GatewayEnvelope }
|
||||||
|
/** A human decided the pending approval; the continuation will stream. */
|
||||||
|
| { kind: "decided" }
|
||||||
| { kind: "transport_error"; message: string };
|
| { kind: "transport_error"; message: string };
|
||||||
|
|
||||||
export function initialTranscript(): TranscriptState {
|
export function initialTranscript(): TranscriptState {
|
||||||
@@ -95,6 +106,13 @@ export function transcriptReducer(
|
|||||||
case "transport_error":
|
case "transport_error":
|
||||||
return failPending(state);
|
return failPending(state);
|
||||||
|
|
||||||
|
case "decided":
|
||||||
|
return updateReply(state, (reply) => ({
|
||||||
|
...reply,
|
||||||
|
status: "streaming",
|
||||||
|
pendingApproval: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
case "gateway": {
|
case "gateway": {
|
||||||
const { seq, event } = action.envelope;
|
const { seq, event } = action.envelope;
|
||||||
if (seq <= state.lastSeq) {
|
if (seq <= state.lastSeq) {
|
||||||
@@ -137,12 +155,29 @@ export function transcriptReducer(
|
|||||||
: step,
|
: step,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
case "approval_required":
|
||||||
|
return updateReply(next, (reply) => ({
|
||||||
|
...reply,
|
||||||
|
pendingApproval: {
|
||||||
|
id: event.approval_id,
|
||||||
|
category: event.category,
|
||||||
|
actionType: event.action_type,
|
||||||
|
preview: event.preview,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
case "run_suspended":
|
||||||
|
// Blocked until a human decides; the composer stays disabled.
|
||||||
|
return updateReply(next, (reply) => ({
|
||||||
|
...reply,
|
||||||
|
status: "suspended",
|
||||||
|
}));
|
||||||
case "run_completed":
|
case "run_completed":
|
||||||
return {
|
return {
|
||||||
...updateReply(next, (reply) => ({
|
...updateReply(next, (reply) => ({
|
||||||
...reply,
|
...reply,
|
||||||
id: event.message_id,
|
id: event.message_id,
|
||||||
status: "complete",
|
status: "complete",
|
||||||
|
pendingApproval: undefined,
|
||||||
})),
|
})),
|
||||||
streaming: false,
|
streaming: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Client chat engine: optimistic sends through the same-origin gateway
|
// Client chat engine: optimistic sends through the same-origin gateway
|
||||||
// proxy, incremental SSE parsing, reducer-owned streaming state.
|
// proxy, incremental SSE parsing, reducer-owned streaming state, and the
|
||||||
|
// approval decide-then-resume flow (§15).
|
||||||
|
|
||||||
import { useCallback, useReducer, useRef } from "react";
|
import { useCallback, useReducer, useRef } from "react";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ import { createSseParser } from "./stream-parser";
|
|||||||
import {
|
import {
|
||||||
initialTranscript,
|
initialTranscript,
|
||||||
transcriptReducer,
|
transcriptReducer,
|
||||||
|
type TranscriptAction,
|
||||||
type TranscriptState,
|
type TranscriptState,
|
||||||
type UiMessage,
|
type UiMessage,
|
||||||
} from "./transcript";
|
} from "./transcript";
|
||||||
@@ -17,6 +19,8 @@ import {
|
|||||||
export interface ChatHandle {
|
export interface ChatHandle {
|
||||||
state: TranscriptState;
|
state: TranscriptState;
|
||||||
send: (text: string) => Promise<void>;
|
send: (text: string) => Promise<void>;
|
||||||
|
/** Decide a pending approval, then stream the run's continuation. */
|
||||||
|
decide: (approvalId: string, decision: "approve" | "reject") => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat(
|
export function useChat(
|
||||||
@@ -33,26 +37,30 @@ export function useChat(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
// The reducer owns lastSeq; mirror it for use inside async callbacks.
|
||||||
|
const lastSeqRef = useRef(0);
|
||||||
|
lastSeqRef.current = state.lastSeq;
|
||||||
|
|
||||||
const send = useCallback(
|
const readStream = useCallback(
|
||||||
async (text: string) => {
|
async (
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
emit: (action: TranscriptAction) => void,
|
||||||
|
) => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
dispatch({ kind: "send", text });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
|
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sessionKey, message: text }),
|
body: JSON.stringify({ sessionKey, ...body }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
dispatch({
|
emit({
|
||||||
kind: "transport_error",
|
kind: "transport_error",
|
||||||
message: `gateway returned ${response.status}`,
|
message: `gateway returned ${response.status}`,
|
||||||
});
|
});
|
||||||
@@ -64,7 +72,7 @@ export function useChat(
|
|||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
const frames = done ? parser.flush() : parser.push(value);
|
const frames = done ? parser.flush() : parser.push(value);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
dispatch({
|
emit({
|
||||||
kind: "gateway",
|
kind: "gateway",
|
||||||
envelope: parseEnvelope(frame.id, frame.data),
|
envelope: parseEnvelope(frame.id, frame.data),
|
||||||
});
|
});
|
||||||
@@ -75,12 +83,40 @@ export function useChat(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
dispatch({ kind: "transport_error", message: String(error) });
|
emit({ kind: "transport_error", message: String(error) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clawId, sessionKey],
|
[clawId, sessionKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { state, send };
|
const send = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
dispatch({ kind: "send", text });
|
||||||
|
await readStream({ message: text }, dispatch);
|
||||||
|
},
|
||||||
|
[readStream],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decide = useCallback(
|
||||||
|
async (approvalId: string, decision: "approve" | "reject") => {
|
||||||
|
const response = await fetch(`/api/approvals/${approvalId}/${decision}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
dispatch({
|
||||||
|
kind: "transport_error",
|
||||||
|
message: `decision returned ${response.status}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ kind: "decided" });
|
||||||
|
// Re-attach from where the suspended stream stopped; the journal
|
||||||
|
// replay plus live tail carries the continuation.
|
||||||
|
await readStream({ resumeFrom: lastSeqRef.current }, dispatch);
|
||||||
|
},
|
||||||
|
[readStream],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { state, send, decide };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ async function openScout(page: Page) {
|
|||||||
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
|
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clicks "New" and waits for the navigation: the previous session may show
|
||||||
|
* the same welcome heading, so the URL change is the reliable signal. */
|
||||||
|
async function newSession(page: Page) {
|
||||||
|
const before = page.url();
|
||||||
|
await page.getByRole("button", { name: "New" }).click();
|
||||||
|
await page.waitForURL((url) => url.toString() !== before);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage(page: Page, text: string) {
|
async function sendMessage(page: Page, text: string) {
|
||||||
const box = page.getByLabel("Message Scout");
|
const box = page.getByLabel("Message Scout");
|
||||||
await box.fill(text);
|
await box.fill(text);
|
||||||
@@ -29,7 +37,7 @@ async function sendMessage(page: Page, text: string) {
|
|||||||
test("welcome state appears for a fresh session", async ({ page }) => {
|
test("welcome state appears for a fresh session", async ({ page }) => {
|
||||||
await signIn(page);
|
await signIn(page);
|
||||||
await openScout(page);
|
await openScout(page);
|
||||||
await page.getByRole("button", { name: "New" }).click();
|
await newSession(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -51,7 +59,7 @@ test("a message streams back a scripted reply", async ({ page }) => {
|
|||||||
test("tool runs render a step trace that survives reload", async ({ page }) => {
|
test("tool runs render a step trace that survives reload", async ({ page }) => {
|
||||||
await signIn(page);
|
await signIn(page);
|
||||||
await openScout(page);
|
await openScout(page);
|
||||||
await page.getByRole("button", { name: "New" }).click();
|
await newSession(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -77,7 +85,7 @@ test("multiple sessions hold separate transcripts", async ({ page }) => {
|
|||||||
await openScout(page);
|
await openScout(page);
|
||||||
|
|
||||||
// First session.
|
// First session.
|
||||||
await page.getByRole("button", { name: "New" }).click();
|
await newSession(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -85,7 +93,7 @@ test("multiple sessions hold separate transcripts", async ({ page }) => {
|
|||||||
await expect(page.getByText("I received: first session message")).toBeVisible();
|
await expect(page.getByText("I received: first session message")).toBeVisible();
|
||||||
|
|
||||||
// Second session.
|
// Second session.
|
||||||
await page.getByRole("button", { name: "New" }).click();
|
await newSession(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// P2 BLOCKING exit criterion (spec §15/§17): a sensitive call is
|
||||||
|
// intercepted → previewed → queued → blocks → executes ONLY on approval →
|
||||||
|
// is audited. Runs against the real backend with the scripted provider.
|
||||||
|
|
||||||
|
const OWNER_EMAIL = "[email protected]";
|
||||||
|
const OWNER_PASSWORD = "e2e-password";
|
||||||
|
const GATED_PROMPT = "email the CEO [[scenario:gated-email]]";
|
||||||
|
|
||||||
|
async function signIn(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Email").fill(OWNER_EMAIL);
|
||||||
|
await page.getByLabel("Password").fill(OWNER_PASSWORD);
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFreshScoutSession(page: Page) {
|
||||||
|
await page.getByRole("link", { name: /Scout/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
|
||||||
|
// Wait for the navigation to the NEW session: the previous (possibly
|
||||||
|
// empty) session shows the same welcome heading, so the URL is the only
|
||||||
|
// reliable signal that the fresh session is mounted.
|
||||||
|
const before = page.url();
|
||||||
|
await page.getByRole("button", { name: "New" }).click();
|
||||||
|
await page.waitForURL((url) => url.toString() !== before);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerGatedEmail(page: Page) {
|
||||||
|
const box = page.getByLabel("Message Scout");
|
||||||
|
await box.fill(GATED_PROMPT);
|
||||||
|
await box.press("Enter");
|
||||||
|
// Intercepted + previewed: the approval card shows the EXACT payload.
|
||||||
|
const card = page.getByRole("region", { name: "Review and approve" });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.getByText(/wants to:/)).toContainText(
|
||||||
|
"Send email to [email protected]",
|
||||||
|
);
|
||||||
|
await expect(card).toContainText("Revenue is up 14% quarter over quarter.");
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("gated email blocks, previews, and executes only after approval", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openFreshScoutSession(page);
|
||||||
|
const card = await triggerGatedEmail(page);
|
||||||
|
|
||||||
|
// Blocked: the composer is disabled while the run is suspended.
|
||||||
|
await expect(page.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||||
|
|
||||||
|
await card.getByRole("button", { name: "Approve" }).click();
|
||||||
|
|
||||||
|
// The continuation streams the approved execution.
|
||||||
|
await expect(page.getByText(/The email step is finished/)).toBeVisible();
|
||||||
|
const trace = page.getByRole("button", { name: /1 step/ });
|
||||||
|
await trace.click();
|
||||||
|
await expect(page.getByText(/✓ email\.send/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/"queued": ?true/)).toBeVisible();
|
||||||
|
|
||||||
|
// The transcript and trace replay identically after reload (journal).
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(/The email step is finished/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /1 step/ }).click();
|
||||||
|
await expect(page.getByText(/✓ email\.send/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejection executes nothing and the agent continues in-band", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openFreshScoutSession(page);
|
||||||
|
const card = await triggerGatedEmail(page);
|
||||||
|
|
||||||
|
await card.getByRole("button", { name: "Reject" }).click();
|
||||||
|
|
||||||
|
// The run completes; the step trace records the refusal, not an execution.
|
||||||
|
await expect(page.getByText(/The email step is finished/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /1 step/ }).click();
|
||||||
|
await expect(page.getByText(/✗ email\.send/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/rejected/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the approvals queue lists pending actions and decides them", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openFreshScoutSession(page);
|
||||||
|
await triggerGatedEmail(page);
|
||||||
|
|
||||||
|
// Queued: the global approvals page shows the same exact preview.
|
||||||
|
// Earlier sessions may have left pending approvals; decide them all.
|
||||||
|
await page.getByRole("link", { name: "Approvals" }).click();
|
||||||
|
await expect(page.getByText(/awaits? your review/)).toBeVisible();
|
||||||
|
const cards = page.getByRole("region", { name: "Review and approve" });
|
||||||
|
await expect(cards.first()).toContainText("Send email to [email protected]");
|
||||||
|
|
||||||
|
const total = await cards.count();
|
||||||
|
for (let remaining = total; remaining > 0; remaining--) {
|
||||||
|
await cards.first().getByRole("button", { name: "Approve" }).click();
|
||||||
|
// Wait for the refresh to apply before touching the next card.
|
||||||
|
await expect(cards).toHaveCount(remaining - 1);
|
||||||
|
}
|
||||||
|
await expect(page.getByText(/All clear/)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Outbound message queue: the real effect of the gated email.send tool.
|
||||||
|
-- Rows land here ONLY after human approval (§15); the delivery transport
|
||||||
|
-- (SMTP / Slack / etc.) drains the queue in P4.
|
||||||
|
|
||||||
|
CREATE TABLE outbox (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
agent_id UUID NOT NULL REFERENCES agents (id),
|
||||||
|
recipient TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'failed')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX outbox_workspace_idx ON outbox (workspace_id, created_at DESC);
|
||||||
Reference in New Issue
Block a user