P0: workspace scaffold, CI gates, tc-domain, tc-config, tc-db vs real Postgres
- Cargo workspace with 1250-line and no-placeholder CI gates wired first - tc-domain: id newtypes, SessionKey codec (proptest round-trip), Role, GatedCategory (spec §15), AccessPolicy, core entities - tc-config: figment TOML+env config, DeployTarget/provider/auth selection with semantic validation - migrations/0001: full spec §14 schema incl. DB-enforced append-only audit_log - tc-db: compile-time-checked sqlx repos (workspaces, users, agents+policies, credits, audit) with committed .sqlx offline metadata - tc-testkit: per-test real-Postgres databases (testcontainers or TC_TEST_DATABASE_URL), embedded migrations Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: File size budget (1250 lines)
|
||||||
|
run: ./ci/check-loc.sh
|
||||||
|
- name: No placeholder markers
|
||||||
|
run: ./ci/check-no-placeholders.sh
|
||||||
|
|
||||||
|
rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: gates
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: 1.96.0
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Format
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: gates
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
|
||||||
|
- name: Unit and component tests
|
||||||
|
run: npm test
|
||||||
|
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/target
|
||||||
|
**/node_modules
|
||||||
|
frontend/.next
|
||||||
|
frontend/out
|
||||||
|
frontend/coverage
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/test-results
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO agents\n (id, workspace_id, name, job_title, system_prompt, avatar, accent,\n wallpaper, managed_by, status)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "203aea258d31f9dfac64e7f12a148ffa2ba35ace2f85ef1408eab19a357590e5"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COALESCE(SUM(remaining), 0)::BIGINT AS \"balance!\"\n FROM credit_lots WHERE workspace_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "balance!",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "77fb84f5567b8a7553b750d42275393f179553c74841083db508fabc02ac74bf"
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, email, role, display_name, created_at\n FROM users WHERE email = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "role",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8624eb567bd3d5a5b9ef40e09add83cb8c82486416c221a0ea513ab519ab02ad"
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO users (id, workspace_id, email, role, display_name)\n VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "9321b7efd4c9a11b82d030ff17ca5697dc31c20ae6aa7b4f7fc38d4b57135acc"
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT humans_mode, human_ids, agents_mode, agent_ids\n FROM access_policies WHERE agent_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "humans_mode",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "human_ids",
|
||||||
|
"type_info": "UuidArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agents_mode",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "agent_ids",
|
||||||
|
"type_info": "UuidArray"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9f214997187dd16cb319b3ab58195e75efa03f2aba22eac2a9669d412ca50f94"
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, email, role, display_name, created_at\n FROM users WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "role",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9fb5fc0b2cb418076e40451994443d8d0d56e2b35762c0f0e4610cb34e672663"
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, name, job_title, system_prompt, avatar,\n accent, wallpaper, managed_by, status\n FROM agents\n WHERE workspace_id = $1 AND deleted_at IS NULL\n ORDER BY created_at, id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "job_title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "system_prompt",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "avatar",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "accent",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "wallpaper",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "managed_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "aa5c479959536065ee2fb42e64bd8b0b9ac37aba2883d6bdcbb544676911c495"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE agents SET deleted_at = now(), status = 'offline'\n WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "aedf7bd6a046b1c06800db5d5890104119da58816d01bac3449ed9154ad3d84b"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO workspaces (id, name, plan) VALUES ($1, $2, $3)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b3c5ef4da7ac657b162e5c9464a6f629e5f8da163436fa046d890d2d0bf15fc7"
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, email, role, display_name, created_at\n FROM users WHERE workspace_id = $1\n ORDER BY created_at, id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "role",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d19f4165e8945345caad6bde9d32e49c0f77e70f3d0a7e417f436aa1dad9674c"
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO credit_lots (id, workspace_id, amount, remaining, source)\n VALUES ($1, $2, $3, $3, $4)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Numeric",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "d1ee499d845959ce334323e6f314802d91aa105f6f8b6fca212a31928bc0c88b"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE agents SET status = $2 WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e9086e580cd1811762e76ea786eb9fa4a2ab172b382336e7e9feea7a6a4923e4"
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO access_policies\n (agent_id, humans_mode, human_ids, agents_mode, agent_ids)\n VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"UuidArray",
|
||||||
|
"Text",
|
||||||
|
"UuidArray"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "efbcb1697b7f0f5f8aabc04fb059e1b9e302c9fadb5b5baf5cf1b4d1aad43104"
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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, $2, $3, $4, $5, $6, $7)\n RETURNING id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Jsonb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f899f45e5d7a01a2a5ac868e33dddab7dbb8650de76cc4087c3e38338492a4bd"
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, name, plan FROM workspaces WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "plan",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f93c1a9655bab412d7cc93dadff86de8a68ad6bd503475426f764ba7e1061f5d"
|
||||||
|
}
|
||||||
Generated
+3721
File diff suppressed because it is too large
Load Diff
+44
@@ -0,0 +1,44 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/tc-domain",
|
||||||
|
"crates/tc-config",
|
||||||
|
"crates/tc-db",
|
||||||
|
"crates/tc-testkit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.96"
|
||||||
|
license = "UNLICENSED"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Shared dependency versions; crates opt in via { workspace = true }.
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
|
proptest = "1"
|
||||||
|
time = { version = "0.3", features = ["serde", "serde-well-known"] }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"tls-rustls",
|
||||||
|
"postgres",
|
||||||
|
"uuid",
|
||||||
|
"time",
|
||||||
|
"json",
|
||||||
|
"migrate",
|
||||||
|
"macros",
|
||||||
|
"bigdecimal",
|
||||||
|
] }
|
||||||
|
testcontainers-modules = { version = "0.13", features = ["postgres"] }
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
todo = "deny"
|
||||||
|
unimplemented = "deny"
|
||||||
|
dbg_macro = "deny"
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
# AI Coworker Platform — Build Spec, PRD & Roadmap
|
||||||
|
_Internal working name: **TeamClaw** (replace with your brand). Original specification authored from product research. Art assets (avatars, wallpapers, logo) are generated separately and intentionally excluded._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. Product Overview
|
||||||
|
2. Design System & Tokens
|
||||||
|
3. Motion / Animation Spec
|
||||||
|
4. Global App Shell & Layout
|
||||||
|
5. Agent Chat Workspace
|
||||||
|
6. Sessions Column
|
||||||
|
7. The "Computer" Slide-out Panel (full spec)
|
||||||
|
8. Global Pages (Skills, Apps, Team, Credits)
|
||||||
|
9. Agent Creation Wizard
|
||||||
|
10. Dialogs & Overlays
|
||||||
|
11. Component Inventory
|
||||||
|
12. Routing & URL State Model
|
||||||
|
13. API Contract
|
||||||
|
14. Data Model
|
||||||
|
15. Agent Runtime & Safety Layer
|
||||||
|
16. PRD Summary
|
||||||
|
17. Delivery Roadmap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Overview
|
||||||
|
An enterprise platform of customizable, collaborative AI agents ("claws"). Each agent has a persistent identity, a configurable system prompt, its own tool/app connections, files, scheduled routines, an inter-agent inbox, and a sandboxed runtime ("Computer") surfaced through a chat-first UI. A human-in-the-loop approval layer gates every sensitive or irreversible action.
|
||||||
|
|
||||||
|
**Personas:** Owner/Admin (provisioning, billing, access policy), Builder (creates/tunes agents), Operator (chats, approves gated actions), Agent (first-class actor with identity, inbox, routines, files, runtime).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Design System & Tokens
|
||||||
|
Dark-first. Coral brand accent.
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| --background | #0a0a0a |
|
||||||
|
| --foreground | #fafafa |
|
||||||
|
| --card / --popover | #0a0a0a |
|
||||||
|
| --border / --input / --muted / --secondary | #262626 |
|
||||||
|
| --muted-foreground | #a3a3a3 |
|
||||||
|
| --subtle / --surface-warm | #141414 |
|
||||||
|
| --surface-warm-muted | #1f1f1f |
|
||||||
|
| --accent (coral) | #f96565 |
|
||||||
|
| --coral-light / --coral-dark | #fa7575 / #e85555 |
|
||||||
|
| --destructive | #7f1d1d |
|
||||||
|
| --primary | #fafafa |
|
||||||
|
| --radius (base) | 0.5rem |
|
||||||
|
| --radius-button (pill) | 9999px |
|
||||||
|
| app sidebar width | 176px |
|
||||||
|
|
||||||
|
**Type:** Geist (UI) + Geist Mono (code). Reserved: Satoshi, Familjen Grotesk, Ranade, Inter. Scale: xxs 10px → base 16px → xxxl 36px.
|
||||||
|
**Shadows:** bubble, button, card, dialog, popover, dock-tile, cta.
|
||||||
|
**Texture:** inline SVG feTurbulence fractal-noise grain layered over wallpaper PNGs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Motion / Animation Spec
|
||||||
|
- Primary easing: `--ease-app: cubic-bezier(.32,.72,0,1)`
|
||||||
|
- Secondary easing: `--ease-out: cubic-bezier(.16,1,.3,1)`
|
||||||
|
- Durations: fast .15s / normal .25s / slow .4s
|
||||||
|
- **Slide-out panels animate WIDTH, not transform:** `transition: width .25s var(--ease-app), border-color .5s var(--ease-out)` collapsing 0 ↔ target (176 / 208 / 448px). This makes panels "push" layout rather than overlay.
|
||||||
|
- Keyframes to implement: slide-in-right, sheet-in, slide-up-in, scale-in, fade-in/out, fade-up, collapsible-expand/collapse, route-fade-in, shimmer, ripple, gradient-shift, caret-blink, shake, spin, ping, pulse, toolSlideIn, toolAccentPulse.
|
||||||
|
- Provisioning (new-agent spin-up): avatar-breathe, photo-rotate, photo-bob, tip-slide, tip-fade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Global App Shell & Layout
|
||||||
|
Three persistent zones; up to three columns visible in chat at full width.
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------+-----------------------------+--------------------------+
|
||||||
|
| LEFT | MAIN (chat / page) | COMPUTER PANEL (slide) |
|
||||||
|
| RAIL | | (right, ~448px) |
|
||||||
|
| 176px | header (h-14 / lg:h-20) | ?device=full|tablet| |
|
||||||
|
| | | phone |
|
||||||
|
| logo | ...content... | |
|
||||||
|
| [agent]| | |
|
||||||
|
| [agent]| | |
|
||||||
|
| [agent]| | |
|
||||||
|
| ( + ) | | |
|
||||||
|
| | | |
|
||||||
|
| Skills | | |
|
||||||
|
| Apps | | |
|
||||||
|
| Team | | |
|
||||||
|
| Credits| | |
|
||||||
|
| [user] | | |
|
||||||
|
+--------+-----------------------------+--------------------------+
|
||||||
|
```
|
||||||
|
Left rail: logo (top), agent roster (avatar + name + green online dot), add-agent (+), then nav: Skills / Apps / Team / Credits, current user pinned bottom. Active item uses coral. Rail width animates on collapse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Agent Chat Workspace
|
||||||
|
Route: `/claws/{clawId}/chat/{sessionKey}`
|
||||||
|
|
||||||
|
### 5a. Empty / Welcome state
|
||||||
|
```
|
||||||
|
+---------------------------------------------+
|
||||||
|
| (avatar) |
|
||||||
|
| Hi, I'm {Name}. What can I |
|
||||||
|
| help with? |
|
||||||
|
| {Role} · Shared with your team |
|
||||||
|
| +-------------------------------------+ |
|
||||||
|
| | Message {Name}... [clip][skill]| |
|
||||||
|
| +-------------------------------------+ |
|
||||||
|
| [Create a daily briefing] [Write a report] |
|
||||||
|
| [Make a presentation] [What can it do?]|
|
||||||
|
+---------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5b. Active transcript
|
||||||
|
```
|
||||||
|
HEADER: (avatar) {Name} / {Role} [slack][sessions][new]
|
||||||
|
------------------------------------------------------------
|
||||||
|
[ user bubble (right) ]
|
||||||
|
(avatar) agent message (left)
|
||||||
|
> N steps (collapsible tool/reasoning trace)
|
||||||
|
inline `code chip` coral action link
|
||||||
|
[Copy] [Helpful] [Not helpful]
|
||||||
|
------------------------------------------------------------
|
||||||
|
COMPOSER: [ Type your message... ] [clip] [skill]
|
||||||
|
```
|
||||||
|
- User bubbles right-aligned; agent messages left with avatar.
|
||||||
|
- "N steps" expands to show tool calls (toolSlideIn / toolAccentPulse).
|
||||||
|
- Gated actions render as coral "Review and approve" links → approvals queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sessions Column
|
||||||
|
Toggle: `?sessions=1`. Slides in on the LEFT of the chat (separate from Computer panel), narrowing the chat column.
|
||||||
|
```
|
||||||
|
+------------------+
|
||||||
|
| [ Search ] |
|
||||||
|
+------------------+
|
||||||
|
| Session title |
|
||||||
|
| 4h ago (•)| <- active = coral left border
|
||||||
|
| Session title |
|
||||||
|
| 1d ago |
|
||||||
|
+------------------+
|
||||||
|
[ + New session ]
|
||||||
|
```
|
||||||
|
Each row: title + relative timestamp. Backed by session-history retrieval; sessions are resumable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The "Computer" Slide-out Panel (FULL SPEC)
|
||||||
|
Right-hand slide-out, themed per-agent via wallpaper. Size toggles top-right: **Full** (expand to side nav) / **Tablet** (chat beside it) / **Phone**. Plus close (×). State via `?device=` and `?app=`.
|
||||||
|
|
||||||
|
### 7.0 Home screen (dock + app grid)
|
||||||
|
```
|
||||||
|
● {Name}'s Computer
|
||||||
|
+------------------------------------------+
|
||||||
|
| [Browser] [Slack] [Claw Chat] [+ Add] | <- app grid
|
||||||
|
| |
|
||||||
|
| (wallpaper area) |
|
||||||
|
| |
|
||||||
|
| +------------------------------------+ |
|
||||||
|
| | [Skills] [Files] [Routines][Settings] | <- dock (glassy)
|
||||||
|
| +------------------------------------+ |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Every app is a routable sub-view (`?app=...`) with header (title + close, optional back/tabs) and an empty state. Each sub-view title appears top-left; close (×) top-right.
|
||||||
|
|
||||||
|
### 7.1 Browser (live agent web browser)
|
||||||
|
```
|
||||||
|
[<] [>] ( address: newtab ) [⟳] [+] [×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| web viewport |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Full browser chrome: back/forward, address bar, reload, new-tab, close. Renders the agent's actual browsing session (CDP-backed). Handle profile-load error state ("Some features may be unavailable" + OK).
|
||||||
|
|
||||||
|
### 7.2 Claw Chat (inter-agent inbox) — `?app=chat`
|
||||||
|
```
|
||||||
|
Claw Chat [×]
|
||||||
|
[ Search ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (av) {Agent} 2h |
|
||||||
|
| {Thread subject} |
|
||||||
|
| {last message preview…} |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Thread list → conversation detail. Threads may be flagged "sensitive." This is the surface governed by the "Other Claws" access toggle.
|
||||||
|
|
||||||
|
### 7.3 Slack (tabbed integration) — `?app=slack`
|
||||||
|
```
|
||||||
|
Slack [×]
|
||||||
|
[ Overview ] [ Channels ] [ Connection ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (Slack glyph) |
|
||||||
|
| Bring this claw into Slack |
|
||||||
|
| Connect Slack to respond on @mention |
|
||||||
|
| [ Connect Slack ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Pre-connect: all tabs show connect gate. Post-connect: Channels (pick channels), Connection (manage auth). Agent replies on @mention; outbound posts are gated.
|
||||||
|
|
||||||
|
### 7.4 Files — `?app=files`
|
||||||
|
```
|
||||||
|
Files [×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| 📁 My Documents > |
|
||||||
|
| 📁 Received Files > |
|
||||||
|
| 📁 Shared ClawDrive (team) > |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Three drives; Shared drive is team-visible. Backed by openclaw/files + shared-drive/files.
|
||||||
|
|
||||||
|
### 7.5 Skills (per-agent installed) — `?app=skills`
|
||||||
|
```
|
||||||
|
Skills [×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (lightning) |
|
||||||
|
| {Name}'s Skills |
|
||||||
|
| Add workflows you want {Name} to do |
|
||||||
|
| |
|
||||||
|
| [ Add Skill ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Installed subset of the global Skill Library. Empty state + Add Skill.
|
||||||
|
|
||||||
|
### 7.6 Routines (scheduled tasks) — `?app=routines` (→ scheduled)
|
||||||
|
```
|
||||||
|
Routines [⟳][×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (bell) |
|
||||||
|
| No routines yet |
|
||||||
|
| Ask your claw to set up a routine — |
|
||||||
|
| daily digest, newsletter, calendar block|
|
||||||
|
| [ Schedule a task ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Routines are agent-created (interval/cron). List + refresh + create.
|
||||||
|
|
||||||
|
### 7.7 Settings — `?app=settings` (push/pop nav stack)
|
||||||
|
**Main:**
|
||||||
|
```
|
||||||
|
Settings [×]
|
||||||
|
(avatar)
|
||||||
|
{Name}
|
||||||
|
{Role subtitle}
|
||||||
|
+------------------------------------------+
|
||||||
|
| Slack handle ⚠ @handle (disconnected)|
|
||||||
|
| Managed by {Owner} |
|
||||||
|
| Edit profile > |
|
||||||
|
+------------------------------------------+
|
||||||
|
WHO ELSE HAS ACCESS
|
||||||
|
+------------------------------------------+
|
||||||
|
| (av) Other people [ON] |
|
||||||
|
| ◉ Entire team — anyone at {Org} |
|
||||||
|
| ○ Specific people — pick teammates |
|
||||||
|
+------------------------------------------+
|
||||||
|
| (av) Other Claws [ON] |
|
||||||
|
| ◉ Any Claw on the team |
|
||||||
|
| ○ Specific claws — pick claws |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ Need help? support@… ]
|
||||||
|
```
|
||||||
|
**Edit profile (drill-in, ‹ back):**
|
||||||
|
```
|
||||||
|
‹ Edit profile
|
||||||
|
(avatar) [Change photo]
|
||||||
|
+------------------------------------------+
|
||||||
|
| Name {Name} |
|
||||||
|
| Job Title {Role} |
|
||||||
|
+------------------------------------------+
|
||||||
|
JOB DESCRIPTION
|
||||||
|
[ Describe how this claw should think... ]
|
||||||
|
"becomes part of its system prompt"
|
||||||
|
PERSONALIZATION
|
||||||
|
| Wallpaper {Theme} > |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ 🗑 Delete claw ] (destructive)
|
||||||
|
```
|
||||||
|
Key: the Job Description textarea IS the system prompt. Wallpaper drives panel theming. Delete is destructive (gated/confirmed).
|
||||||
|
|
||||||
|
### 7.8 Add Apps (connect directory) — `?app=apps`
|
||||||
|
```
|
||||||
|
Add Apps [×]
|
||||||
|
[ Search apps ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (icon) Gmail [+] |
|
||||||
|
| (icon) Google Calendar [+] |
|
||||||
|
| (icon) Notion / Linear / GitHub … [+] |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ + Add custom app ]
|
||||||
|
```
|
||||||
|
Per-row [+] starts OAuth. "Add custom app" → auth-method dialog (see §10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Global Pages
|
||||||
|
|
||||||
|
### 8.1 Skill Library — `/skills`
|
||||||
|
```
|
||||||
|
Skill Library [ Create v ]
|
||||||
|
Browse skills published by your team and the catalog.
|
||||||
|
[ Search skills ] [ All categories v ]
|
||||||
|
+------------------+ +------------------+
|
||||||
|
| {Skill} [+] | | {Skill} [+] |
|
||||||
|
| by {author} | | by {author} |
|
||||||
|
| {description} | | {description} |
|
||||||
|
| N installs | | N installs |
|
||||||
|
+------------------+ +------------------+
|
||||||
|
```
|
||||||
|
Two-column cards: title, author, description, install count, install (+).
|
||||||
|
|
||||||
|
### 8.2 Apps — `/apps`
|
||||||
|
```
|
||||||
|
Apps [ + Add Custom App ]
|
||||||
|
[ Find an App ] [ Category v ]
|
||||||
|
Browse All Apps — connect via OAuth
|
||||||
|
+------------------+ +------------------+
|
||||||
|
|(logo){App} [+] | |(logo){App} [+] |
|
||||||
|
| {description} | | {description} |
|
||||||
|
+------------------+ +------------------+
|
||||||
|
```
|
||||||
|
Infinite scroll. Includes Gmail, Calendar, Drive, Notion, Linear, GitHub, Figma, Zoom, Stripe, HubSpot, Sheets, Telegram, HTTP/Webhook, Schedule, Supabase, MySQL, Postgres, AWS, SendGrid, SES, …
|
||||||
|
|
||||||
|
### 8.3 Team — `/team`
|
||||||
|
```
|
||||||
|
{Workspace} ✎ [ Invite ]
|
||||||
|
N members in your workspace
|
||||||
|
[ Members ] [ Claw org chart ] [ Leaderboard ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| Name Email Joined |
|
||||||
|
| (NM) {User} [Owner] {email} {date} |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Credits — `/credits`
|
||||||
|
```
|
||||||
|
Credits — manage balance, subscriptions, usage
|
||||||
|
+------------------------------------------+
|
||||||
|
| AVAILABLE CREDITS {N} [Buy credits][Activity]|
|
||||||
|
| Plans from $/mo · save yearly |
|
||||||
|
| CREDIT LOTS · all credits never expire |
|
||||||
|
| USAGE · LAST 7 DAYS [▓▓▓▓▓▓░] ~2 days |
|
||||||
|
+------------------------------------------+
|
||||||
|
| Promo code [ PROMOCODE ] [ Redeem ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
| Scaling beyond self-serve? [ Talk to sales ]|
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Agent Creation Wizard — `/claws/new` (?step=identity|access|slack)
|
||||||
|
```
|
||||||
|
[▬▬▬▬] [ ] [ ] (3-step progress)
|
||||||
|
Create your Claw
|
||||||
|
Claws help get your work done.
|
||||||
|
(< avatar carousel >) Name *
|
||||||
|
● ● ● ● [+] (accent swatches) [ {name} ] (⟳)
|
||||||
|
Job title
|
||||||
|
[ {role} ]
|
||||||
|
[ Continue → ]
|
||||||
|
```
|
||||||
|
Step 1 identity (avatar carousel + accent/wallpaper swatches + name w/ randomizer + job title), Step 2 access (same scopes as Settings), Step 3 Slack (optional). Provisioning shows animated spin-up states. **Note:** completing creates a live agent — confirm before submit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Dialogs & Overlays
|
||||||
|
|
||||||
|
### Add Custom App (centered modal)
|
||||||
|
```
|
||||||
|
(icon) Add Custom App [×]
|
||||||
|
Pick how this app authenticates
|
||||||
|
+------------------------------------------+
|
||||||
|
| 🔑 Keys — secret keys / API key / token |
|
||||||
|
| 🛡 Username & Password — HTTP basic auth |
|
||||||
|
| 🖥 MCP Server with OAuth — remote MCP |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ Cancel ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Approval card (safety layer)
|
||||||
|
```
|
||||||
|
Review & approve
|
||||||
|
This claw wants to: {action summary}
|
||||||
|
PREVIEW: {exact payload / diff of what executes}
|
||||||
|
Source: {agent / thread} ⚠ {sensitive/unverified}
|
||||||
|
[ Reject ] [ Approve ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Component Inventory
|
||||||
|
**Shell:** LeftRail, AgentRosterItem, AddAgentButton, GlobalNavItem, UserMenu.
|
||||||
|
**Chat:** ChatHeader, WelcomeState, SuggestedPromptChip, MessageList, UserBubble, AgentMessage, StepTrace, CodeChip, GatedActionLink, MessageActions, Composer.
|
||||||
|
**Sessions:** SessionsColumn, SessionSearch, SessionRow, NewSessionButton.
|
||||||
|
**Computer panel:** DevicePanel (resizable), DeviceSizeToggle, PanelHeader, AppGrid, AppTile, Dock, DockTile; modules: BrowserApp, ClawChatApp (ThreadList/ThreadRow/ThreadDetail), SlackApp (Tabs/ConnectGate), FilesApp (FolderRow), SkillsApp, RoutinesApp, SettingsApp (IdentityHeader/SettingRow/AccessToggleGroup/EditProfileForm/WallpaperPicker/DeleteAgent), AddAppsApp.
|
||||||
|
**Global:** SkillCard, AppCard, AddCustomAppDialog, MembersTable, CreditsCard, UsageMeter, PromoRedeem.
|
||||||
|
**Wizard:** StepProgress, AvatarCarousel, AccentSwatchRow, ProvisioningState.
|
||||||
|
**Safety:** ApprovalCard, ApprovalQueue, ActionPreview, AuditLogEntry, UntrustedContentBadge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Routing & URL State Model
|
||||||
|
- Global: `/skills`, `/apps`, `/team`, `/credits`, `/claws/new`
|
||||||
|
- Agent: `/claws/{clawId}/chat/{sessionKey}` where sessionKey = `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`
|
||||||
|
- Overlays via query: `?app=browser|slack|chat|skills|files|routines(→scheduled)|settings|apps`, `?sessions=1`, `?device=full|tablet|phone`, `?step=identity|access|slack`
|
||||||
|
- All panel/overlay state is deep-linkable & shareable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. API Contract (REST, JSON, same-origin /api/, keyed by clawId)
|
||||||
|
**Observed:**
|
||||||
|
- Identity/team: `GET /user/me`, `GET /team/permissions`, `GET /team/credits`, `GET /team/claws`
|
||||||
|
- Per-agent: `GET /claws/{id}/health`, `GET /claws/settings/full?clawId=`, `GET /claws/{id}/custom-photos`, `GET /claws/{id}/slack-channels`, `GET /claws/{id}/apps/available`
|
||||||
|
- Sessions: `GET /sessions?clawId=`, `GET /sessions/active?clawId=`, `GET /sessions/history?sessionKey=&tools=true&clawId=`
|
||||||
|
- Apps/skills/slack: `GET /apps?clawId=`, `GET /skills?clawId=`, `GET /slack/me`, `GET /apps/slack/setup`
|
||||||
|
- Files: `GET /openclaw/files?clawId=`, `GET /shared-drive/files?clawId=`
|
||||||
|
- Runtime bridge: `POST /gateway?clawId=` (streaming)
|
||||||
|
**Designed (confirm during build):**
|
||||||
|
- `POST /claws`, `PATCH /claws/{id}`, `DELETE /claws/{id}`, `POST /sessions`, `POST /skills/install`, `POST /apps/connect`
|
||||||
|
- Approvals: `GET /approvals`, `GET /approvals/{id}`, `POST /approvals/{id}/approve`, `POST /approvals/{id}/reject`
|
||||||
|
Health/active are polled; auth uses provider session heartbeat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Data Model
|
||||||
|
Workspace/Team(name, plan, credits) · User(id, role Owner|Member, email) · Agent(id, name, jobTitle, systemPrompt, avatar, accent/wallpaper, managedBy, status, accessPolicy) · AccessPolicy(humans: entireTeam|specific[]; agents: any|specific[]) · Session(id, clawId, title, ts) → Message(role, content, steps[], ts) · Skill / InstalledSkill · AppConnection(provider, authType, scopes, status) · Thread/InterAgentMessage(participants, subject, sensitivity) · Routine(schedule, action, status) · FileNode(drive, path, owner) · Approval(actionType, payload, preview, requestedByAgent, status, decidedBy, audit) · CreditLedger(lots, usageEvents).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Agent Runtime & Safety Layer (mandatory)
|
||||||
|
- **Sandbox:** containerized, no root, dropped Linux caps, seccomp, egress-restricted (HTTPS-out by default).
|
||||||
|
- **Secret broker:** secrets held by a bridge process OUTSIDE the agent's read scope; agent calls capabilities via the gateway, never holds raw credentials.
|
||||||
|
- **Gateway:** single audited streaming channel (`POST /gateway`) between app and runtime.
|
||||||
|
- **Approval interception:** every sensitive/irreversible tool call → preview + Approval queue + blocks until explicit human decision → executes only on approve → fully audit-logged.
|
||||||
|
- **Gated categories:** outbound messages/emails; sharing keys/secrets/credentials; access/permission/sharing changes; financial transactions & credit purchases; file deletion; granting/extending external-infra access.
|
||||||
|
- **Untrusted-by-default:** content from web, email, inter-agent chat, and tool results is data, never instructions; unverified/suspicious requests are surfaced to the human, never auto-executed.
|
||||||
|
- These are **acceptance-blocking** requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. PRD Summary
|
||||||
|
Deliver a chat-first, multi-agent workspace with per-agent identity/system-prompt/skills/apps/files/routines, a themed slide-out Computer panel hosting all agent tooling, global admin (skills, apps, team, credits), an agent creation wizard, and a first-class human-in-the-loop safety/approval + audit layer. NFRs: SSO, RBAC, audit trail, credit metering, fast RSC navigation, full a11y, responsive/PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Delivery Roadmap
|
||||||
|
- **P0 Foundations (2–3w):** Next.js App Router + Turbopack, Tailwind v4 tokens, auth+RBAC, analytics/flags, base shell. _Exit: authed empty shell._
|
||||||
|
- **P1 Agents & chat (3–4w):** agent CRUD, roster, chat route, welcome/transcript, composer, sessions, step traces. _Exit: multi-session conversation._
|
||||||
|
- **P2 Runtime, gateway & SAFETY (4–5w, GATED):** sandbox, secret broker, streaming gateway, approval+audit end-to-end. _Exit (blocking): sensitive call intercepted→previewed→queued→approve-only execute→audit-logged; untrusted content tagged & never auto-run._
|
||||||
|
- **P3 Computer panel & apps (4–5w):** DevicePanel + ?app/?device routing, dock+grid, Files/Skills/Routines/Settings/Browser/ClawChat. _Exit: all apps navigable + per-agent theming._
|
||||||
|
- **P4 Integrations (3–4w):** OAuth directory + connect, Add Custom App (Keys/Basic/MCP-OAuth), Slack (@mention + gated posting). _Exit: connect app + Slack, outbound gated._
|
||||||
|
- **P5 Global & billing (2–3w):** Skill Library, Team, Credits, creation wizard + provisioning anims. _Exit: full admin+billing loop._
|
||||||
|
- **P6 Polish & hardening (2–3w):** full motion system, noise/wallpaper textures, a11y, PWA, perf, sandbox+approval security review. _Exit: a11y + security sign-off; identical look-and-feel._
|
||||||
|
Cross-cutting (continuous): audit/observability, credit metering, safety layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
_End of document._
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fails the build if any source file exceeds the 1,250-line budget.
|
||||||
|
# Warns for files over the 900-line soft threshold so splits happen
|
||||||
|
# before they hurt.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HARD_LIMIT=1250
|
||||||
|
SOFT_LIMIT=900
|
||||||
|
STATUS=0
|
||||||
|
|
||||||
|
# Source extensions under budget. Generated/vendored paths are excluded.
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
lines=$(wc -l <"$f" | tr -d ' ')
|
||||||
|
if [ "$lines" -gt "$HARD_LIMIT" ]; then
|
||||||
|
echo "FAIL: $f has $lines lines (limit $HARD_LIMIT)"
|
||||||
|
STATUS=1
|
||||||
|
elif [ "$lines" -gt "$SOFT_LIMIT" ]; then
|
||||||
|
echo "WARN: $f has $lines lines (soft limit $SOFT_LIMIT)"
|
||||||
|
fi
|
||||||
|
done < <(
|
||||||
|
find "$ROOT/crates" "$ROOT/frontend/src" "$ROOT/tools" "$ROOT/tests" \
|
||||||
|
-type f \( -name '*.rs' -o -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) \
|
||||||
|
-not -path '*/node_modules/*' -not -path '*/target/*' 2>/dev/null
|
||||||
|
)
|
||||||
|
|
||||||
|
exit "$STATUS"
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fails the build if placeholder markers appear anywhere in source.
|
||||||
|
# The project ships no stubs, no TODOs, no deferred work.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
PATTERN='TODO|FIXME|XXX:|todo!\(|unimplemented!\('
|
||||||
|
|
||||||
|
MATCHES=$(grep -RInE "$PATTERN" \
|
||||||
|
"$ROOT/crates" "$ROOT/frontend/src" "$ROOT/tools" "$ROOT/tests" \
|
||||||
|
--include='*.rs' --include='*.ts' --include='*.tsx' --include='*.css' \
|
||||||
|
--exclude-dir=node_modules --exclude-dir=target 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$MATCHES" ]; then
|
||||||
|
echo "Placeholder markers found:"
|
||||||
|
echo "$MATCHES"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-config"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
figment = { version = "0.10", features = ["toml", "env"] }
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
figment = { version = "0.10", features = ["toml", "env", "test"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Configuration loading for all TeamClaw binaries.
|
||||||
|
//!
|
||||||
|
//! One TOML file plus `TEAMCLAW_*` environment overrides (nested keys split
|
||||||
|
//! on `__`, e.g. `TEAMCLAW_DATABASE__URL`). The same configuration tree
|
||||||
|
//! drives both deployment targets; semantic validation rejects combinations
|
||||||
|
//! that would only fail at runtime (e.g. an OpenAI-compatible provider with
|
||||||
|
//! no endpoint to call).
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use figment::providers::{Env, Format, Toml};
|
||||||
|
use figment::Figment;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Which of the two first-class deployment targets this instance runs as.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DeployTarget {
|
||||||
|
AirGapped,
|
||||||
|
Cloud,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which `LlmProvider` implementation the runtime instantiates.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LlmProviderKind {
|
||||||
|
Anthropic,
|
||||||
|
#[serde(rename = "openai_compat")]
|
||||||
|
OpenAiCompat,
|
||||||
|
Scripted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which `AuthProvider` implementation handles sign-in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AuthMode {
|
||||||
|
Local,
|
||||||
|
Oidc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DatabaseConfig {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default = "default_max_connections")]
|
||||||
|
pub max_connections: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_connections() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LlmConfig {
|
||||||
|
pub provider: LlmProviderKind,
|
||||||
|
/// Endpoint for `openai_compat`; unused by other providers.
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub model: String,
|
||||||
|
/// Scenario file for the deterministic `scripted` provider.
|
||||||
|
pub scenario_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub mode: AuthMode,
|
||||||
|
pub issuer_url: Option<String>,
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub deploy_target: DeployTarget,
|
||||||
|
pub listen_addr: SocketAddr,
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
pub llm: LlmConfig,
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("failed to load configuration: {0}")]
|
||||||
|
Load(String),
|
||||||
|
#[error("invalid configuration: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Loads configuration from `path`, overlaying `TEAMCLAW_*` environment
|
||||||
|
/// variables, and validates it.
|
||||||
|
pub fn load_from(path: &Path) -> Result<AppConfig, ConfigError> {
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(ConfigError::Load(format!(
|
||||||
|
"configuration file not found: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let config: AppConfig = Figment::new()
|
||||||
|
.merge(Toml::file(path))
|
||||||
|
.merge(Env::prefixed("TEAMCLAW_").split("__"))
|
||||||
|
.extract()
|
||||||
|
.map_err(|e| ConfigError::Load(e.to_string()))?;
|
||||||
|
config.validate()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
match self.llm.provider {
|
||||||
|
LlmProviderKind::OpenAiCompat if self.llm.base_url.is_none() => {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"llm.provider = \"openai_compat\" requires llm.base_url".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
LlmProviderKind::Scripted if self.llm.scenario_path.is_none() => {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"llm.provider = \"scripted\" requires llm.scenario_path".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if self.auth.mode == AuthMode::Oidc {
|
||||||
|
if self.auth.issuer_url.is_none() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"auth.mode = \"oidc\" requires auth.issuer_url".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.auth.client_id.is_none() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"auth.mode = \"oidc\" requires auth.client_id".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// figment::Jail::expect_with dictates closures returning figment's own
|
||||||
|
// (large) error type; the lint has nothing actionable here.
|
||||||
|
#![allow(clippy::result_large_err)]
|
||||||
|
|
||||||
|
use tc_config::{AppConfig, AuthMode, ConfigError, DeployTarget, LlmProviderKind};
|
||||||
|
|
||||||
|
const AIR_GAPPED_TOML: &str = r#"
|
||||||
|
deploy_target = "air_gapped"
|
||||||
|
listen_addr = "0.0.0.0:8080"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
url = "postgres://teamclaw:pw@db:5432/teamclaw"
|
||||||
|
|
||||||
|
[llm]
|
||||||
|
provider = "openai_compat"
|
||||||
|
base_url = "http://local-llm:8000/v1"
|
||||||
|
model = "qwen2.5-72b-instruct"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "local"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const CLOUD_TOML: &str = r#"
|
||||||
|
deploy_target = "cloud"
|
||||||
|
listen_addr = "0.0.0.0:8080"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
url = "postgres://teamclaw:pw@db:5432/teamclaw"
|
||||||
|
max_connections = 32
|
||||||
|
|
||||||
|
[llm]
|
||||||
|
provider = "anthropic"
|
||||||
|
model = "claude-sonnet-4-5"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "oidc"
|
||||||
|
issuer_url = "https://idp.example.com"
|
||||||
|
client_id = "teamclaw"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_air_gapped_config_from_toml() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
jail.create_file("teamclaw.toml", AIR_GAPPED_TOML)?;
|
||||||
|
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
|
||||||
|
assert_eq!(cfg.deploy_target, DeployTarget::AirGapped);
|
||||||
|
assert_eq!(cfg.listen_addr.port(), 8080);
|
||||||
|
assert_eq!(cfg.database.url, "postgres://teamclaw:pw@db:5432/teamclaw");
|
||||||
|
assert_eq!(cfg.llm.provider, LlmProviderKind::OpenAiCompat);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.llm.base_url.as_deref(),
|
||||||
|
Some("http://local-llm:8000/v1")
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.auth.mode, AuthMode::Local);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_cloud_config_with_oidc() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
jail.create_file("teamclaw.toml", CLOUD_TOML)?;
|
||||||
|
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
|
||||||
|
assert_eq!(cfg.deploy_target, DeployTarget::Cloud);
|
||||||
|
assert_eq!(cfg.database.max_connections, 32);
|
||||||
|
assert_eq!(cfg.llm.provider, LlmProviderKind::Anthropic);
|
||||||
|
assert_eq!(cfg.auth.mode, AuthMode::Oidc);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.auth.issuer_url.as_deref(),
|
||||||
|
Some("https://idp.example.com")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_overrides_toml_values() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
jail.create_file("teamclaw.toml", AIR_GAPPED_TOML)?;
|
||||||
|
jail.set_env("TEAMCLAW_DATABASE__URL", "postgres://other:pw@x:5432/y");
|
||||||
|
jail.set_env("TEAMCLAW_LLM__MODEL", "llama-3.1-8b-instruct");
|
||||||
|
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
|
||||||
|
assert_eq!(cfg.database.url, "postgres://other:pw@x:5432/y");
|
||||||
|
assert_eq!(cfg.llm.model, "llama-3.1-8b-instruct");
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_max_connections_defaults_to_ten() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
jail.create_file("teamclaw.toml", AIR_GAPPED_TOML)?;
|
||||||
|
let cfg = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
|
||||||
|
assert_eq!(cfg.database.max_connections, 10);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compat_without_base_url_is_rejected() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
let toml = AIR_GAPPED_TOML.replace("base_url = \"http://local-llm:8000/v1\"\n", "");
|
||||||
|
jail.create_file("teamclaw.toml", &toml)?;
|
||||||
|
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("base_url")));
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripted_provider_requires_scenario_path() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
let toml = AIR_GAPPED_TOML
|
||||||
|
.replace("provider = \"openai_compat\"", "provider = \"scripted\"")
|
||||||
|
.replace("base_url = \"http://local-llm:8000/v1\"\n", "");
|
||||||
|
jail.create_file("teamclaw.toml", &toml)?;
|
||||||
|
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("scenario_path")));
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oidc_mode_requires_issuer_and_client_id() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
let toml = CLOUD_TOML.replace("issuer_url = \"https://idp.example.com\"\n", "");
|
||||||
|
jail.create_file("teamclaw.toml", &toml)?;
|
||||||
|
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("issuer_url")));
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_is_a_clear_error() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
let err = AppConfig::load_from(&jail.directory().join("absent.toml")).unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::Load(_)));
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-db"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
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,43 @@
|
|||||||
|
//! Postgres persistence for TeamClaw.
|
||||||
|
//!
|
||||||
|
//! Queries are compile-time checked (`sqlx::query!`) against the schema in
|
||||||
|
//! `/migrations`, and every repository is tested only against a real
|
||||||
|
//! Postgres via `tc-testkit` — no in-memory store exists.
|
||||||
|
|
||||||
|
pub mod repo;
|
||||||
|
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// All schema migrations, embedded so binaries can self-migrate at boot.
|
||||||
|
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for DbError {
|
||||||
|
fn from(err: sqlx::Error) -> Self {
|
||||||
|
match &err {
|
||||||
|
sqlx::Error::RowNotFound => DbError::NotFound,
|
||||||
|
sqlx::Error::Database(db) if db.is_unique_violation() => {
|
||||||
|
DbError::Conflict(db.message().to_owned())
|
||||||
|
}
|
||||||
|
_ => DbError::Other(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a pool sized from configuration. Callers run `MIGRATOR` at boot.
|
||||||
|
pub async fn connect(url: &str, max_connections: u32) -> Result<PgPool, DbError> {
|
||||||
|
Ok(PgPoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect(url)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, UserId, WorkspaceId,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
/// Inserts an agent together with its access policy in one transaction —
|
||||||
|
/// an agent without a policy must never be observable (§7.7).
|
||||||
|
pub async fn insert(pool: &PgPool, agent: &Agent, policy: &AccessPolicy) -> Result<(), DbError> {
|
||||||
|
let (humans_mode, human_ids): (&str, Vec<Uuid>) = match &policy.humans {
|
||||||
|
HumanScope::EntireTeam => ("entire_team", Vec::new()),
|
||||||
|
HumanScope::Specific(ids) => ("specific", ids.iter().map(UserId::as_uuid).collect()),
|
||||||
|
};
|
||||||
|
let (agents_mode, agent_ids): (&str, Vec<Uuid>) = match &policy.agents {
|
||||||
|
AgentScope::Any => ("any", Vec::new()),
|
||||||
|
AgentScope::Specific(ids) => ("specific", ids.iter().map(AgentId::as_uuid).collect()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(DbError::from)?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agents
|
||||||
|
(id, workspace_id, name, job_title, system_prompt, avatar, accent,
|
||||||
|
wallpaper, managed_by, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||||
|
agent.id.as_uuid(),
|
||||||
|
agent.workspace_id.as_uuid(),
|
||||||
|
agent.name,
|
||||||
|
agent.job_title,
|
||||||
|
agent.system_prompt,
|
||||||
|
agent.avatar,
|
||||||
|
agent.accent,
|
||||||
|
agent.wallpaper,
|
||||||
|
agent.managed_by.as_uuid(),
|
||||||
|
agent.status.as_str(),
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO access_policies
|
||||||
|
(agent_id, humans_mode, human_ids, agents_mode, agent_ids)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
agent.id.as_uuid(),
|
||||||
|
humans_mode,
|
||||||
|
&human_ids,
|
||||||
|
agents_mode,
|
||||||
|
&agent_ids,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await.map_err(DbError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The left-rail roster (§4): live agents of a workspace, oldest first.
|
||||||
|
pub async fn roster(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Agent>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
|
||||||
|
accent, wallpaper, managed_by, status
|
||||||
|
FROM agents
|
||||||
|
WHERE workspace_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at, id",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| Agent {
|
||||||
|
id: AgentId::from(row.id),
|
||||||
|
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||||
|
name: row.name,
|
||||||
|
job_title: row.job_title,
|
||||||
|
system_prompt: row.system_prompt,
|
||||||
|
avatar: row.avatar,
|
||||||
|
accent: row.accent,
|
||||||
|
wallpaper: row.wallpaper,
|
||||||
|
managed_by: UserId::from(row.managed_by),
|
||||||
|
status: row.status.parse().expect("status CHECK constraint"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn access_policy(pool: &PgPool, agent_id: AgentId) -> Result<AccessPolicy, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT humans_mode, human_ids, agents_mode, agent_ids
|
||||||
|
FROM access_policies WHERE agent_id = $1",
|
||||||
|
agent_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let humans = if row.humans_mode == "entire_team" {
|
||||||
|
HumanScope::EntireTeam
|
||||||
|
} else {
|
||||||
|
HumanScope::Specific(row.human_ids.into_iter().map(UserId::from).collect())
|
||||||
|
};
|
||||||
|
let agents = if row.agents_mode == "any" {
|
||||||
|
AgentScope::Any
|
||||||
|
} else {
|
||||||
|
AgentScope::Specific(row.agent_ids.into_iter().map(AgentId::from).collect())
|
||||||
|
};
|
||||||
|
Ok(AccessPolicy { humans, agents })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
agent_id: AgentId,
|
||||||
|
status: AgentStatus,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE agents SET status = $2 WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
agent_id.as_uuid(),
|
||||||
|
status.as_str(),
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleting a claw is destructive and gated (§7.7); rows are kept for audit.
|
||||||
|
pub async fn soft_delete(pool: &PgPool, agent_id: AgentId) -> Result<(), DbError> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE agents SET deleted_at = now(), status = 'offline'
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
agent_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{AgentId, UserId, WorkspaceId};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
/// Who performed an audited action (§15: every decision is attributable).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Actor {
|
||||||
|
User(UserId),
|
||||||
|
Agent(AgentId),
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor {
|
||||||
|
fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Actor::User(_) => "user",
|
||||||
|
Actor::Agent(_) => "agent",
|
||||||
|
Actor::System => "system",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(&self) -> Option<Uuid> {
|
||||||
|
match self {
|
||||||
|
Actor::User(id) => Some(id.as_uuid()),
|
||||||
|
Actor::Agent(id) => Some(id.as_uuid()),
|
||||||
|
Actor::System => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends an audit entry and returns its sequence id. The table rejects
|
||||||
|
/// UPDATE/DELETE at the database level (see migration 0001).
|
||||||
|
pub async fn append(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
actor: Actor,
|
||||||
|
event_type: &str,
|
||||||
|
subject_type: &str,
|
||||||
|
subject_id: &str,
|
||||||
|
detail: serde_json::Value,
|
||||||
|
) -> Result<i64, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"INSERT INTO audit_log
|
||||||
|
(workspace_id, actor_kind, actor_id, event_type, subject_type,
|
||||||
|
subject_id, detail)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
actor.kind(),
|
||||||
|
actor.id(),
|
||||||
|
event_type,
|
||||||
|
subject_type,
|
||||||
|
subject_id,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::WorkspaceId;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
/// Available credits = sum of remaining balances across all lots; credits
|
||||||
|
/// never expire (§8.4).
|
||||||
|
pub async fn balance(pool: &PgPool, workspace_id: WorkspaceId) -> Result<i64, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"SELECT COALESCE(SUM(remaining), 0)::BIGINT AS "balance!"
|
||||||
|
FROM credit_lots WHERE workspace_id = $1"#,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_lot(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
amount: i64,
|
||||||
|
source: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO credit_lots (id, workspace_id, amount, remaining, source)
|
||||||
|
VALUES ($1, $2, $3, $3, $4)",
|
||||||
|
Uuid::now_v7(),
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
sqlx::types::BigDecimal::from(amount),
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod agents;
|
||||||
|
pub mod audit;
|
||||||
|
pub mod credits;
|
||||||
|
pub mod users;
|
||||||
|
pub mod workspaces;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{Role, User, UserId, WorkspaceId};
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
fn role_to_str(role: Role) -> &'static str {
|
||||||
|
match role {
|
||||||
|
Role::Owner => "owner",
|
||||||
|
Role::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_from_str(s: &str) -> Role {
|
||||||
|
// The CHECK constraint guarantees only these two values exist.
|
||||||
|
if s == "owner" {
|
||||||
|
Role::Owner
|
||||||
|
} else {
|
||||||
|
Role::Member
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a user. `created_at` is assigned by the database; the value on
|
||||||
|
/// the input struct is ignored.
|
||||||
|
pub async fn insert(pool: &PgPool, user: &User) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO users (id, workspace_id, email, role, display_name)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
user.id.as_uuid(),
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
user.email,
|
||||||
|
role_to_str(user.role),
|
||||||
|
user.display_name,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(pool: &PgPool, id: UserId) -> Result<User, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, workspace_id, email, role, display_name, created_at
|
||||||
|
FROM users WHERE id = $1",
|
||||||
|
id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(User {
|
||||||
|
id: UserId::from(row.id),
|
||||||
|
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||||
|
email: row.email,
|
||||||
|
role: role_from_str(&row.role),
|
||||||
|
display_name: row.display_name,
|
||||||
|
created_at: row.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<User, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, workspace_id, email, role, display_name, created_at
|
||||||
|
FROM users WHERE email = $1",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(User {
|
||||||
|
id: UserId::from(row.id),
|
||||||
|
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||||
|
email: row.email,
|
||||||
|
role: role_from_str(&row.role),
|
||||||
|
display_name: row.display_name,
|
||||||
|
created_at: row.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Members table for the Team page (§8.3), in join order.
|
||||||
|
pub async fn list_by_workspace(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<Vec<User>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT id, workspace_id, email, role, display_name, created_at
|
||||||
|
FROM users WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at, id",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| User {
|
||||||
|
id: UserId::from(row.id),
|
||||||
|
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||||
|
email: row.email,
|
||||||
|
role: role_from_str(&row.role),
|
||||||
|
display_name: row.display_name,
|
||||||
|
created_at: row.created_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{Workspace, WorkspaceId};
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
pub async fn insert(pool: &PgPool, workspace: &Workspace) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO workspaces (id, name, plan) VALUES ($1, $2, $3)",
|
||||||
|
workspace.id.as_uuid(),
|
||||||
|
workspace.name,
|
||||||
|
workspace.plan,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(pool: &PgPool, id: WorkspaceId) -> Result<Workspace, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, name, plan FROM workspaces WHERE id = $1",
|
||||||
|
id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Workspace {
|
||||||
|
id: WorkspaceId::from(row.id),
|
||||||
|
name: row.name,
|
||||||
|
plan: row.plan,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use tc_db::repo::{agents, audit, credits, users, workspaces};
|
||||||
|
use tc_db::DbError;
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, Role, User, UserId,
|
||||||
|
Workspace, WorkspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn workspace() -> Workspace {
|
||||||
|
Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_in(ws: &Workspace, role: Role) -> User {
|
||||||
|
User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role,
|
||||||
|
display_name: "Test User".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_in(ws: &Workspace, owner: &User) -> Agent {
|
||||||
|
Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Research Analyst".into(),
|
||||||
|
system_prompt: "You research things.".into(),
|
||||||
|
avatar: "scout-1".into(),
|
||||||
|
accent: "#f96565".into(),
|
||||||
|
wallpaper: "dunes".into(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Provisioning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_round_trips() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let found = workspaces::get(&pool, ws.id).await.unwrap();
|
||||||
|
assert_eq!(found, ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_workspace_is_not_found() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let err = workspaces::get(&pool, WorkspaceId::new())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DbError::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn user_round_trips_and_finds_by_email() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let user = user_in(&ws, Role::Owner);
|
||||||
|
users::insert(&pool, &user).await.unwrap();
|
||||||
|
|
||||||
|
let by_id = users::get(&pool, user.id).await.unwrap();
|
||||||
|
assert_eq!(by_id.email, user.email);
|
||||||
|
assert_eq!(by_id.role, Role::Owner);
|
||||||
|
|
||||||
|
let by_email = users::find_by_email(&pool, &user.email).await.unwrap();
|
||||||
|
assert_eq!(by_email.id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_email_is_a_conflict() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let mut a = user_in(&ws, Role::Member);
|
||||||
|
let mut b = user_in(&ws, Role::Member);
|
||||||
|
b.email = a.email.clone();
|
||||||
|
users::insert(&pool, &a).await.unwrap();
|
||||||
|
let err = users::insert(&pool, &b).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DbError::Conflict(_)));
|
||||||
|
// Silence unused warnings for fields we only compare implicitly.
|
||||||
|
a.display_name.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_members_lists_in_join_order() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = user_in(&ws, Role::Owner);
|
||||||
|
let member = user_in(&ws, Role::Member);
|
||||||
|
users::insert(&pool, &owner).await.unwrap();
|
||||||
|
users::insert(&pool, &member).await.unwrap();
|
||||||
|
|
||||||
|
let members = users::list_by_workspace(&pool, ws.id).await.unwrap();
|
||||||
|
assert_eq!(members.len(), 2);
|
||||||
|
assert_eq!(members[0].id, owner.id);
|
||||||
|
assert_eq!(members[1].id, member.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_insert_creates_default_access_policy() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = user_in(&ws, Role::Owner);
|
||||||
|
users::insert(&pool, &owner).await.unwrap();
|
||||||
|
let agent = agent_in(&ws, &owner);
|
||||||
|
|
||||||
|
agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let policy = agents::access_policy(&pool, agent.id).await.unwrap();
|
||||||
|
assert_eq!(policy.humans, HumanScope::EntireTeam);
|
||||||
|
assert_eq!(policy.agents, AgentScope::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_roster_excludes_deleted_and_round_trips_fields() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = user_in(&ws, Role::Owner);
|
||||||
|
users::insert(&pool, &owner).await.unwrap();
|
||||||
|
|
||||||
|
let keep = agent_in(&ws, &owner);
|
||||||
|
let remove = agent_in(&ws, &owner);
|
||||||
|
agents::insert(&pool, &keep, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
agents::insert(&pool, &remove, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
agents::soft_delete(&pool, remove.id).await.unwrap();
|
||||||
|
|
||||||
|
let roster = agents::roster(&pool, ws.id).await.unwrap();
|
||||||
|
assert_eq!(roster.len(), 1);
|
||||||
|
assert_eq!(roster[0], keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_status_updates() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = user_in(&ws, Role::Owner);
|
||||||
|
users::insert(&pool, &owner).await.unwrap();
|
||||||
|
let agent = agent_in(&ws, &owner);
|
||||||
|
agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
agents::set_status(&pool, agent.id, AgentStatus::Online)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let roster = agents::roster(&pool, ws.id).await.unwrap();
|
||||||
|
assert_eq!(roster[0].status, AgentStatus::Online);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn specific_access_policy_round_trips() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = user_in(&ws, Role::Owner);
|
||||||
|
users::insert(&pool, &owner).await.unwrap();
|
||||||
|
let agent = agent_in(&ws, &owner);
|
||||||
|
|
||||||
|
let policy = AccessPolicy {
|
||||||
|
humans: HumanScope::Specific(vec![owner.id]),
|
||||||
|
agents: AgentScope::Specific(vec![AgentId::from_str(&agent.id.to_string()).unwrap()]),
|
||||||
|
};
|
||||||
|
agents::insert(&pool, &agent, &policy).await.unwrap();
|
||||||
|
let stored = agents::access_policy(&pool, agent.id).await.unwrap();
|
||||||
|
assert_eq!(stored, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credit_balance_sums_remaining_lots() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 0);
|
||||||
|
credits::add_lot(&pool, ws.id, 1000, "purchase")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
credits::add_lot(&pool, ws.id, 250, "promo").await.unwrap();
|
||||||
|
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 1250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn audit_log_appends_and_rejects_mutation() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = workspace();
|
||||||
|
workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
|
||||||
|
let entry_id = audit::append(
|
||||||
|
&pool,
|
||||||
|
ws.id,
|
||||||
|
audit::Actor::System,
|
||||||
|
"workspace.created",
|
||||||
|
"workspace",
|
||||||
|
&ws.id.to_string(),
|
||||||
|
serde_json::json!({"plan": "team"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(entry_id > 0);
|
||||||
|
|
||||||
|
// Append-only is enforced by the database itself, not convention.
|
||||||
|
let update = sqlx::query("UPDATE audit_log SET event_type = 'tampered' WHERE id = $1")
|
||||||
|
.bind(entry_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(update.is_err());
|
||||||
|
let delete = sqlx::query("DELETE FROM audit_log WHERE id = $1")
|
||||||
|
.bind(entry_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(delete.is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
time = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::ids::{AgentId, UserId};
|
||||||
|
|
||||||
|
/// Which humans may use an agent (spec §7.7 "Other people" toggle).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "mode", content = "ids", rename_all = "snake_case")]
|
||||||
|
pub enum HumanScope {
|
||||||
|
EntireTeam,
|
||||||
|
Specific(Vec<UserId>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which other agents may message an agent (spec §7.7 "Other Claws" toggle).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "mode", content = "ids", rename_all = "snake_case")]
|
||||||
|
pub enum AgentScope {
|
||||||
|
Any,
|
||||||
|
Specific(Vec<AgentId>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-agent access policy (spec §14 AccessPolicy).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AccessPolicy {
|
||||||
|
pub humans: HumanScope,
|
||||||
|
pub agents: AgentScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AccessPolicy {
|
||||||
|
/// New agents are shared with the whole team and reachable by any claw,
|
||||||
|
/// matching the wizard's defaults (spec §9 step 2).
|
||||||
|
fn default() -> Self {
|
||||||
|
AccessPolicy {
|
||||||
|
humans: HumanScope::EntireTeam,
|
||||||
|
agents: AgentScope::Any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
use crate::ids::{AgentId, UserId, WorkspaceId};
|
||||||
|
use crate::role::Role;
|
||||||
|
|
||||||
|
/// A tenant team (spec §14 Workspace/Team).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub id: WorkspaceId,
|
||||||
|
pub name: String,
|
||||||
|
pub plan: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A human member of a workspace (spec §14 User).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: UserId,
|
||||||
|
pub workspace_id: WorkspaceId,
|
||||||
|
pub email: String,
|
||||||
|
pub role: Role,
|
||||||
|
pub display_name: String,
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifecycle of an agent; `Online` renders the green roster dot (§4).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentStatus {
|
||||||
|
Provisioning,
|
||||||
|
Online,
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AgentStatus::Provisioning => "provisioning",
|
||||||
|
AgentStatus::Online => "online",
|
||||||
|
AgentStatus::Offline => "offline",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for AgentStatus {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"provisioning" => Ok(AgentStatus::Provisioning),
|
||||||
|
"online" => Ok(AgentStatus::Online),
|
||||||
|
"offline" => Ok(AgentStatus::Offline),
|
||||||
|
other => Err(format!("unknown agent status: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An AI coworker (spec §14 Agent). The `system_prompt` is the Settings
|
||||||
|
/// "Job Description" textarea verbatim (§7.7).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Agent {
|
||||||
|
pub id: AgentId,
|
||||||
|
pub workspace_id: WorkspaceId,
|
||||||
|
pub name: String,
|
||||||
|
pub job_title: String,
|
||||||
|
pub system_prompt: String,
|
||||||
|
pub avatar: String,
|
||||||
|
pub accent: String,
|
||||||
|
pub wallpaper: String,
|
||||||
|
pub managed_by: UserId,
|
||||||
|
pub status: AgentStatus,
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The six action categories that always require human approval before an
|
||||||
|
/// agent may execute them (spec §15, acceptance-blocking).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum GatedCategory {
|
||||||
|
/// Outbound messages and emails leaving the workspace.
|
||||||
|
OutboundMessage,
|
||||||
|
/// Sharing keys, secrets, or credentials.
|
||||||
|
SecretSharing,
|
||||||
|
/// Access, permission, or sharing changes.
|
||||||
|
AccessChange,
|
||||||
|
/// Financial transactions and credit purchases.
|
||||||
|
FinancialTransaction,
|
||||||
|
/// File deletion.
|
||||||
|
FileDeletion,
|
||||||
|
/// Granting or extending external-infrastructure access.
|
||||||
|
InfraAccessGrant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatedCategory {
|
||||||
|
/// Every gated category, in spec order. The safety layer iterates this
|
||||||
|
/// to prove exhaustive coverage in tests.
|
||||||
|
pub const ALL: [GatedCategory; 6] = [
|
||||||
|
GatedCategory::OutboundMessage,
|
||||||
|
GatedCategory::SecretSharing,
|
||||||
|
GatedCategory::AccessChange,
|
||||||
|
GatedCategory::FinancialTransaction,
|
||||||
|
GatedCategory::FileDeletion,
|
||||||
|
GatedCategory::InfraAccessGrant,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Defines a strongly-typed UUID wrapper so ids of different entities can
|
||||||
|
/// never be swapped for one another at compile time. New ids are UUIDv7 so
|
||||||
|
/// they sort by creation time in Postgres indexes.
|
||||||
|
macro_rules! define_id {
|
||||||
|
($(#[$doc:meta])* $name:ident) => {
|
||||||
|
$(#[$doc])*
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(Uuid);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
#[allow(clippy::new_without_default)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::now_v7())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_uuid(&self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uuid> for $name {
|
||||||
|
fn from(value: Uuid) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for $name {
|
||||||
|
type Err = uuid::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(Self(Uuid::parse_str(s)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
define_id!(
|
||||||
|
/// A tenant workspace (team).
|
||||||
|
WorkspaceId
|
||||||
|
);
|
||||||
|
define_id!(
|
||||||
|
/// A human member of a workspace.
|
||||||
|
UserId
|
||||||
|
);
|
||||||
|
define_id!(
|
||||||
|
/// An AI agent ("claw").
|
||||||
|
AgentId
|
||||||
|
);
|
||||||
|
define_id!(
|
||||||
|
/// A chat session between humans and one agent.
|
||||||
|
SessionId
|
||||||
|
);
|
||||||
|
define_id!(
|
||||||
|
/// A single message within a session.
|
||||||
|
MessageId
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! Core domain types for the TeamClaw platform (spec §14).
|
||||||
|
//!
|
||||||
|
//! This crate has no I/O: every other crate depends on it for identifiers,
|
||||||
|
//! the session-key codec, role/RBAC vocabulary, agent access policies, and
|
||||||
|
//! the fixed set of approval-gated action categories from spec §15.
|
||||||
|
|
||||||
|
mod access;
|
||||||
|
mod entities;
|
||||||
|
mod gated;
|
||||||
|
mod ids;
|
||||||
|
mod role;
|
||||||
|
mod session_key;
|
||||||
|
|
||||||
|
pub use access::{AccessPolicy, AgentScope, HumanScope};
|
||||||
|
pub use entities::{Agent, AgentStatus, User, Workspace};
|
||||||
|
pub use gated::GatedCategory;
|
||||||
|
pub use ids::{AgentId, MessageId, SessionId, UserId, WorkspaceId};
|
||||||
|
pub use role::Role;
|
||||||
|
pub use session_key::{SessionKey, SessionKeyError};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Workspace-level RBAC role (spec §14: User.role Owner|Member).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
Owner,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
/// Owners administer provisioning, billing, and access policy (spec §1).
|
||||||
|
pub fn is_owner(&self) -> bool {
|
||||||
|
matches!(self, Role::Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use serde::de::Error as _;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
use crate::ids::{AgentId, MessageId, SessionId};
|
||||||
|
|
||||||
|
/// The deep-linkable chat session key from spec §12:
|
||||||
|
/// `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`.
|
||||||
|
///
|
||||||
|
/// This codec is the single source of truth for the format; the frontend
|
||||||
|
/// mirrors it in `frontend/src/lib/url/session-key.ts` and a contract test
|
||||||
|
/// keeps the two in lockstep.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SessionKey {
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// Deterministic hash bucket of the agent id, reserved for future
|
||||||
|
/// partitioning. Carried verbatim through parse/format.
|
||||||
|
pub shard: u16,
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub message_id: MessageId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SessionKeyError {
|
||||||
|
#[error("malformed session key: {0}")]
|
||||||
|
Malformed(String),
|
||||||
|
#[error("invalid shard: {0}")]
|
||||||
|
InvalidShard(String),
|
||||||
|
#[error("invalid id in session key: {0}")]
|
||||||
|
InvalidId(#[from] uuid::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SessionKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"agent:{}-claw-{}:session:{}:{}",
|
||||||
|
self.agent_id, self.shard, self.session_id, self.message_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SessionKey {
|
||||||
|
type Err = SessionKeyError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let malformed = || SessionKeyError::Malformed(s.to_owned());
|
||||||
|
|
||||||
|
let rest = s.strip_prefix("agent:").ok_or_else(malformed)?;
|
||||||
|
let (agent_part, rest) = rest.split_once(":session:").ok_or_else(malformed)?;
|
||||||
|
|
||||||
|
// `-claw-` cannot occur inside a UUID (hex digits and dashes only),
|
||||||
|
// so the rightmost occurrence cleanly separates id from shard.
|
||||||
|
let (agent_str, shard_str) = agent_part.rsplit_once("-claw-").ok_or_else(malformed)?;
|
||||||
|
let agent_id = AgentId::from_str(agent_str)?;
|
||||||
|
let shard: u16 = shard_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| SessionKeyError::InvalidShard(shard_str.to_owned()))?;
|
||||||
|
|
||||||
|
let mut tail = rest.split(':');
|
||||||
|
let session_str = tail.next().ok_or_else(malformed)?;
|
||||||
|
let message_str = tail.next().ok_or_else(malformed)?;
|
||||||
|
if tail.next().is_some() {
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SessionKey {
|
||||||
|
agent_id,
|
||||||
|
shard,
|
||||||
|
session_id: SessionId::from_str(session_str)?,
|
||||||
|
message_id: MessageId::from_str(message_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for SessionKey {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.collect_str(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SessionKey {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
SessionKey::from_str(&s).map_err(D::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use tc_domain::{AccessPolicy, AgentId, AgentScope, GatedCategory, HumanScope, Role, UserId};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_display_as_uuid_and_parse_back() {
|
||||||
|
let id = AgentId::new();
|
||||||
|
let parsed = AgentId::from_str(&id.to_string()).unwrap();
|
||||||
|
assert_eq!(parsed, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_ids_are_unique_and_v7() {
|
||||||
|
let a = UserId::new();
|
||||||
|
let b = UserId::new();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_eq!(a.as_uuid().get_version_num(), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_id_types_serialize_as_plain_uuid_strings() {
|
||||||
|
let id = AgentId::new();
|
||||||
|
let json = serde_json::to_string(&id).unwrap();
|
||||||
|
assert_eq!(json, format!("\"{id}\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_serde_uses_lowercase() {
|
||||||
|
assert_eq!(serde_json::to_string(&Role::Owner).unwrap(), "\"owner\"");
|
||||||
|
assert_eq!(serde_json::to_string(&Role::Member).unwrap(), "\"member\"");
|
||||||
|
let r: Role = serde_json::from_str("\"owner\"").unwrap();
|
||||||
|
assert_eq!(r, Role::Owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gated_categories_cover_spec_section_15() {
|
||||||
|
// The six gated categories are fixed by spec §15; serde names are the
|
||||||
|
// wire contract with the frontend approval queue.
|
||||||
|
let all = GatedCategory::ALL;
|
||||||
|
assert_eq!(all.len(), 6);
|
||||||
|
let names: Vec<String> = all
|
||||||
|
.iter()
|
||||||
|
.map(|c| serde_json::to_string(c).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
vec![
|
||||||
|
"\"outbound_message\"",
|
||||||
|
"\"secret_sharing\"",
|
||||||
|
"\"access_change\"",
|
||||||
|
"\"financial_transaction\"",
|
||||||
|
"\"file_deletion\"",
|
||||||
|
"\"infra_access_grant\"",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_access_policy_is_entire_team_and_any_claw() {
|
||||||
|
let policy = AccessPolicy::default();
|
||||||
|
assert_eq!(policy.humans, HumanScope::EntireTeam);
|
||||||
|
assert_eq!(policy.agents, AgentScope::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn specific_scopes_carry_member_lists() {
|
||||||
|
let user = UserId::new();
|
||||||
|
let agent = AgentId::new();
|
||||||
|
let policy = AccessPolicy {
|
||||||
|
humans: HumanScope::Specific(vec![user]),
|
||||||
|
agents: AgentScope::Specific(vec![agent]),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&policy).unwrap();
|
||||||
|
assert_eq!(json["humans"]["mode"], "specific");
|
||||||
|
assert_eq!(json["humans"]["ids"][0], user.to_string());
|
||||||
|
assert_eq!(json["agents"]["mode"], "specific");
|
||||||
|
assert_eq!(json["agents"]["ids"][0], agent.to_string());
|
||||||
|
let back: AccessPolicy = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(back, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entire_team_scope_serializes_with_mode_tag() {
|
||||||
|
let policy = AccessPolicy::default();
|
||||||
|
let json = serde_json::to_value(&policy).unwrap();
|
||||||
|
assert_eq!(json["humans"]["mode"], "entire_team");
|
||||||
|
assert_eq!(json["agents"]["mode"], "any");
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use tc_domain::{AgentId, MessageId, SessionId, SessionKey, SessionKeyError};
|
||||||
|
|
||||||
|
fn sample() -> SessionKey {
|
||||||
|
SessionKey {
|
||||||
|
agent_id: AgentId::from_str("018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e").unwrap(),
|
||||||
|
shard: 3,
|
||||||
|
session_id: SessionId::from_str("018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f").unwrap(),
|
||||||
|
message_id: MessageId::from_str("018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_to_spec_layout() {
|
||||||
|
let key = sample();
|
||||||
|
assert_eq!(
|
||||||
|
key.to_string(),
|
||||||
|
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3\
|
||||||
|
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
|
||||||
|
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_its_own_output() {
|
||||||
|
let key = sample();
|
||||||
|
let parsed = SessionKey::from_str(&key.to_string()).unwrap();
|
||||||
|
assert_eq!(parsed, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_agent_prefix() {
|
||||||
|
let err = SessionKey::from_str(
|
||||||
|
"claw:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3\
|
||||||
|
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
|
||||||
|
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, SessionKeyError::Malformed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_session_segment() {
|
||||||
|
let err = SessionKey::from_str(
|
||||||
|
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3\
|
||||||
|
:chat:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
|
||||||
|
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, SessionKeyError::Malformed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_numeric_shard() {
|
||||||
|
let err = SessionKey::from_str(
|
||||||
|
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-x\
|
||||||
|
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
|
||||||
|
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, SessionKeyError::InvalidShard(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_agent_uuid() {
|
||||||
|
let err = SessionKey::from_str(
|
||||||
|
"agent:not-a-uuid-claw-3\
|
||||||
|
:session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f\
|
||||||
|
:018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, SessionKeyError::InvalidId(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_trailing_garbage() {
|
||||||
|
let mut s = sample().to_string();
|
||||||
|
s.push_str(":extra");
|
||||||
|
let err = SessionKey::from_str(&s).unwrap_err();
|
||||||
|
assert!(matches!(err, SessionKeyError::Malformed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trips_as_string() {
|
||||||
|
let key = sample();
|
||||||
|
let json = serde_json::to_string(&key).unwrap();
|
||||||
|
assert_eq!(json, format!("\"{key}\""));
|
||||||
|
let back: SessionKey = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
mod properties {
|
||||||
|
use super::*;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
fn arb_uuid() -> impl Strategy<Value = uuid::Uuid> {
|
||||||
|
any::<u128>().prop_map(uuid::Uuid::from_u128)
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn round_trips_for_any_ids(
|
||||||
|
agent in arb_uuid(),
|
||||||
|
session in arb_uuid(),
|
||||||
|
message in arb_uuid(),
|
||||||
|
shard in any::<u16>(),
|
||||||
|
) {
|
||||||
|
let key = SessionKey {
|
||||||
|
agent_id: AgentId::from(agent),
|
||||||
|
shard,
|
||||||
|
session_id: SessionId::from(session),
|
||||||
|
message_id: MessageId::from(message),
|
||||||
|
};
|
||||||
|
let parsed = SessionKey::from_str(&key.to_string()).unwrap();
|
||||||
|
prop_assert_eq!(parsed, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-testkit"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
tc-db = { path = "../tc-db" }
|
||||||
|
testcontainers-modules = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Test harness for real-Postgres integration tests.
|
||||||
|
//!
|
||||||
|
//! Every test gets its own freshly-migrated database on a shared server:
|
||||||
|
//! either the one named by `TC_TEST_DATABASE_URL` (air-gapped CI runs a
|
||||||
|
//! preloaded Postgres) or a `postgres:16-alpine` testcontainer started once
|
||||||
|
//! per test process. There are no in-memory fakes; the suite proves behavior
|
||||||
|
//! against the same engine production runs.
|
||||||
|
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use testcontainers_modules::postgres::Postgres;
|
||||||
|
use testcontainers_modules::testcontainers::runners::AsyncRunner;
|
||||||
|
use testcontainers_modules::testcontainers::ContainerAsync;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
static SERVER: OnceCell<PgServer> = OnceCell::const_new();
|
||||||
|
|
||||||
|
struct PgServer {
|
||||||
|
/// Connection URL whose path component is a maintenance database we can
|
||||||
|
/// issue `CREATE DATABASE` from.
|
||||||
|
admin_url: String,
|
||||||
|
/// Keeps the container alive for the whole test process; `None` when an
|
||||||
|
/// external server is provided via `TC_TEST_DATABASE_URL`.
|
||||||
|
_container: Option<ContainerAsync<Postgres>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn server() -> &'static PgServer {
|
||||||
|
SERVER
|
||||||
|
.get_or_init(|| async {
|
||||||
|
if let Ok(url) = std::env::var("TC_TEST_DATABASE_URL") {
|
||||||
|
return PgServer {
|
||||||
|
admin_url: url,
|
||||||
|
_container: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let container = Postgres::default()
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
.expect("start postgres testcontainer");
|
||||||
|
let port = container
|
||||||
|
.get_host_port_ipv4(5432)
|
||||||
|
.await
|
||||||
|
.expect("resolve postgres port");
|
||||||
|
PgServer {
|
||||||
|
admin_url: format!("postgres://postgres:[email protected]:{port}/postgres"),
|
||||||
|
_container: Some(container),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a unique database, runs all migrations, and returns a pool
|
||||||
|
/// connected to it.
|
||||||
|
pub async fn test_pool() -> PgPool {
|
||||||
|
let server = server().await;
|
||||||
|
let db_name = format!("test_{}", Uuid::now_v7().simple());
|
||||||
|
|
||||||
|
let admin = PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(&server.admin_url)
|
||||||
|
.await
|
||||||
|
.expect("connect admin database");
|
||||||
|
sqlx::query(&format!("CREATE DATABASE {db_name}"))
|
||||||
|
.execute(&admin)
|
||||||
|
.await
|
||||||
|
.expect("create test database");
|
||||||
|
admin.close().await;
|
||||||
|
|
||||||
|
let test_url = swap_database(&server.admin_url, &db_name);
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&test_url)
|
||||||
|
.await
|
||||||
|
.expect("connect test database");
|
||||||
|
tc_db::MIGRATOR.run(&pool).await.expect("run migrations");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the database name (the path segment) of a Postgres URL.
|
||||||
|
fn swap_database(url: &str, db_name: &str) -> String {
|
||||||
|
let (head, tail) = url.rsplit_once('/').expect("postgres url has a path");
|
||||||
|
// Preserve any query string on the original URL.
|
||||||
|
match tail.split_once('?') {
|
||||||
|
Some((_, query)) => format!("{head}/{db_name}?{query}"),
|
||||||
|
None => format!("{head}/{db_name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+457
@@ -0,0 +1,457 @@
|
|||||||
|
# AI Coworker Platform — Build Spec, PRD & Roadmap
|
||||||
|
_Internal working name: **TeamClaw** (replace with your brand). Original specification authored from product research. Art assets (avatars, wallpapers, logo) are generated separately and intentionally excluded._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. Product Overview
|
||||||
|
2. Design System & Tokens
|
||||||
|
3. Motion / Animation Spec
|
||||||
|
4. Global App Shell & Layout
|
||||||
|
5. Agent Chat Workspace
|
||||||
|
6. Sessions Column
|
||||||
|
7. The "Computer" Slide-out Panel (full spec)
|
||||||
|
8. Global Pages (Skills, Apps, Team, Credits)
|
||||||
|
9. Agent Creation Wizard
|
||||||
|
10. Dialogs & Overlays
|
||||||
|
11. Component Inventory
|
||||||
|
12. Routing & URL State Model
|
||||||
|
13. API Contract
|
||||||
|
14. Data Model
|
||||||
|
15. Agent Runtime & Safety Layer
|
||||||
|
16. PRD Summary
|
||||||
|
17. Delivery Roadmap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Overview
|
||||||
|
An enterprise platform of customizable, collaborative AI agents ("claws"). Each agent has a persistent identity, a configurable system prompt, its own tool/app connections, files, scheduled routines, an inter-agent inbox, and a sandboxed runtime ("Computer") surfaced through a chat-first UI. A human-in-the-loop approval layer gates every sensitive or irreversible action.
|
||||||
|
|
||||||
|
**Personas:** Owner/Admin (provisioning, billing, access policy), Builder (creates/tunes agents), Operator (chats, approves gated actions), Agent (first-class actor with identity, inbox, routines, files, runtime).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Design System & Tokens
|
||||||
|
Dark-first. Coral brand accent.
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| --background | #0a0a0a |
|
||||||
|
| --foreground | #fafafa |
|
||||||
|
| --card / --popover | #0a0a0a |
|
||||||
|
| --border / --input / --muted / --secondary | #262626 |
|
||||||
|
| --muted-foreground | #a3a3a3 |
|
||||||
|
| --subtle / --surface-warm | #141414 |
|
||||||
|
| --surface-warm-muted | #1f1f1f |
|
||||||
|
| --accent (coral) | #f96565 |
|
||||||
|
| --coral-light / --coral-dark | #fa7575 / #e85555 |
|
||||||
|
| --destructive | #7f1d1d |
|
||||||
|
| --primary | #fafafa |
|
||||||
|
| --radius (base) | 0.5rem |
|
||||||
|
| --radius-button (pill) | 9999px |
|
||||||
|
| app sidebar width | 176px |
|
||||||
|
|
||||||
|
**Type:** Geist (UI) + Geist Mono (code). Reserved: Satoshi, Familjen Grotesk, Ranade, Inter. Scale: xxs 10px → base 16px → xxxl 36px.
|
||||||
|
**Shadows:** bubble, button, card, dialog, popover, dock-tile, cta.
|
||||||
|
**Texture:** inline SVG feTurbulence fractal-noise grain layered over wallpaper PNGs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Motion / Animation Spec
|
||||||
|
- Primary easing: `--ease-app: cubic-bezier(.32,.72,0,1)`
|
||||||
|
- Secondary easing: `--ease-out: cubic-bezier(.16,1,.3,1)`
|
||||||
|
- Durations: fast .15s / normal .25s / slow .4s
|
||||||
|
- **Slide-out panels animate WIDTH, not transform:** `transition: width .25s var(--ease-app), border-color .5s var(--ease-out)` collapsing 0 ↔ target (176 / 208 / 448px). This makes panels "push" layout rather than overlay.
|
||||||
|
- Keyframes to implement: slide-in-right, sheet-in, slide-up-in, scale-in, fade-in/out, fade-up, collapsible-expand/collapse, route-fade-in, shimmer, ripple, gradient-shift, caret-blink, shake, spin, ping, pulse, toolSlideIn, toolAccentPulse.
|
||||||
|
- Provisioning (new-agent spin-up): avatar-breathe, photo-rotate, photo-bob, tip-slide, tip-fade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Global App Shell & Layout
|
||||||
|
Three persistent zones; up to three columns visible in chat at full width.
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------+-----------------------------+--------------------------+
|
||||||
|
| LEFT | MAIN (chat / page) | COMPUTER PANEL (slide) |
|
||||||
|
| RAIL | | (right, ~448px) |
|
||||||
|
| 176px | header (h-14 / lg:h-20) | ?device=full|tablet| |
|
||||||
|
| | | phone |
|
||||||
|
| logo | ...content... | |
|
||||||
|
| [agent]| | |
|
||||||
|
| [agent]| | |
|
||||||
|
| [agent]| | |
|
||||||
|
| ( + ) | | |
|
||||||
|
| | | |
|
||||||
|
| Skills | | |
|
||||||
|
| Apps | | |
|
||||||
|
| Team | | |
|
||||||
|
| Credits| | |
|
||||||
|
| [user] | | |
|
||||||
|
+--------+-----------------------------+--------------------------+
|
||||||
|
```
|
||||||
|
Left rail: logo (top), agent roster (avatar + name + green online dot), add-agent (+), then nav: Skills / Apps / Team / Credits, current user pinned bottom. Active item uses coral. Rail width animates on collapse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Agent Chat Workspace
|
||||||
|
Route: `/claws/{clawId}/chat/{sessionKey}`
|
||||||
|
|
||||||
|
### 5a. Empty / Welcome state
|
||||||
|
```
|
||||||
|
+---------------------------------------------+
|
||||||
|
| (avatar) |
|
||||||
|
| Hi, I'm {Name}. What can I |
|
||||||
|
| help with? |
|
||||||
|
| {Role} · Shared with your team |
|
||||||
|
| +-------------------------------------+ |
|
||||||
|
| | Message {Name}... [clip][skill]| |
|
||||||
|
| +-------------------------------------+ |
|
||||||
|
| [Create a daily briefing] [Write a report] |
|
||||||
|
| [Make a presentation] [What can it do?]|
|
||||||
|
+---------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5b. Active transcript
|
||||||
|
```
|
||||||
|
HEADER: (avatar) {Name} / {Role} [slack][sessions][new]
|
||||||
|
------------------------------------------------------------
|
||||||
|
[ user bubble (right) ]
|
||||||
|
(avatar) agent message (left)
|
||||||
|
> N steps (collapsible tool/reasoning trace)
|
||||||
|
inline `code chip` coral action link
|
||||||
|
[Copy] [Helpful] [Not helpful]
|
||||||
|
------------------------------------------------------------
|
||||||
|
COMPOSER: [ Type your message... ] [clip] [skill]
|
||||||
|
```
|
||||||
|
- User bubbles right-aligned; agent messages left with avatar.
|
||||||
|
- "N steps" expands to show tool calls (toolSlideIn / toolAccentPulse).
|
||||||
|
- Gated actions render as coral "Review and approve" links → approvals queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sessions Column
|
||||||
|
Toggle: `?sessions=1`. Slides in on the LEFT of the chat (separate from Computer panel), narrowing the chat column.
|
||||||
|
```
|
||||||
|
+------------------+
|
||||||
|
| [ Search ] |
|
||||||
|
+------------------+
|
||||||
|
| Session title |
|
||||||
|
| 4h ago (•)| <- active = coral left border
|
||||||
|
| Session title |
|
||||||
|
| 1d ago |
|
||||||
|
+------------------+
|
||||||
|
[ + New session ]
|
||||||
|
```
|
||||||
|
Each row: title + relative timestamp. Backed by session-history retrieval; sessions are resumable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The "Computer" Slide-out Panel (FULL SPEC)
|
||||||
|
Right-hand slide-out, themed per-agent via wallpaper. Size toggles top-right: **Full** (expand to side nav) / **Tablet** (chat beside it) / **Phone**. Plus close (×). State via `?device=` and `?app=`.
|
||||||
|
|
||||||
|
### 7.0 Home screen (dock + app grid)
|
||||||
|
```
|
||||||
|
● {Name}'s Computer
|
||||||
|
+------------------------------------------+
|
||||||
|
| [Browser] [Slack] [Claw Chat] [+ Add] | <- app grid
|
||||||
|
| |
|
||||||
|
| (wallpaper area) |
|
||||||
|
| |
|
||||||
|
| +------------------------------------+ |
|
||||||
|
| | [Skills] [Files] [Routines][Settings] | <- dock (glassy)
|
||||||
|
| +------------------------------------+ |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Every app is a routable sub-view (`?app=...`) with header (title + close, optional back/tabs) and an empty state. Each sub-view title appears top-left; close (×) top-right.
|
||||||
|
|
||||||
|
### 7.1 Browser (live agent web browser)
|
||||||
|
```
|
||||||
|
[<] [>] ( address: newtab ) [⟳] [+] [×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| web viewport |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Full browser chrome: back/forward, address bar, reload, new-tab, close. Renders the agent's actual browsing session (CDP-backed). Handle profile-load error state ("Some features may be unavailable" + OK).
|
||||||
|
|
||||||
|
### 7.2 Claw Chat (inter-agent inbox) — `?app=chat`
|
||||||
|
```
|
||||||
|
Claw Chat [×]
|
||||||
|
[ Search ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (av) {Agent} 2h |
|
||||||
|
| {Thread subject} |
|
||||||
|
| {last message preview…} |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Thread list → conversation detail. Threads may be flagged "sensitive." This is the surface governed by the "Other Claws" access toggle.
|
||||||
|
|
||||||
|
### 7.3 Slack (tabbed integration) — `?app=slack`
|
||||||
|
```
|
||||||
|
Slack [×]
|
||||||
|
[ Overview ] [ Channels ] [ Connection ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (Slack glyph) |
|
||||||
|
| Bring this claw into Slack |
|
||||||
|
| Connect Slack to respond on @mention |
|
||||||
|
| [ Connect Slack ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Pre-connect: all tabs show connect gate. Post-connect: Channels (pick channels), Connection (manage auth). Agent replies on @mention; outbound posts are gated.
|
||||||
|
|
||||||
|
### 7.4 Files — `?app=files`
|
||||||
|
```
|
||||||
|
Files [×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| 📁 My Documents > |
|
||||||
|
| 📁 Received Files > |
|
||||||
|
| 📁 Shared ClawDrive (team) > |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Three drives; Shared drive is team-visible. Backed by openclaw/files + shared-drive/files.
|
||||||
|
|
||||||
|
### 7.5 Skills (per-agent installed) — `?app=skills`
|
||||||
|
```
|
||||||
|
Skills [×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (lightning) |
|
||||||
|
| {Name}'s Skills |
|
||||||
|
| Add workflows you want {Name} to do |
|
||||||
|
| |
|
||||||
|
| [ Add Skill ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Installed subset of the global Skill Library. Empty state + Add Skill.
|
||||||
|
|
||||||
|
### 7.6 Routines (scheduled tasks) — `?app=routines` (→ scheduled)
|
||||||
|
```
|
||||||
|
Routines [⟳][×]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (bell) |
|
||||||
|
| No routines yet |
|
||||||
|
| Ask your claw to set up a routine — |
|
||||||
|
| daily digest, newsletter, calendar block|
|
||||||
|
| [ Schedule a task ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
Routines are agent-created (interval/cron). List + refresh + create.
|
||||||
|
|
||||||
|
### 7.7 Settings — `?app=settings` (push/pop nav stack)
|
||||||
|
**Main:**
|
||||||
|
```
|
||||||
|
Settings [×]
|
||||||
|
(avatar)
|
||||||
|
{Name}
|
||||||
|
{Role subtitle}
|
||||||
|
+------------------------------------------+
|
||||||
|
| Slack handle ⚠ @handle (disconnected)|
|
||||||
|
| Managed by {Owner} |
|
||||||
|
| Edit profile > |
|
||||||
|
+------------------------------------------+
|
||||||
|
WHO ELSE HAS ACCESS
|
||||||
|
+------------------------------------------+
|
||||||
|
| (av) Other people [ON] |
|
||||||
|
| ◉ Entire team — anyone at {Org} |
|
||||||
|
| ○ Specific people — pick teammates |
|
||||||
|
+------------------------------------------+
|
||||||
|
| (av) Other Claws [ON] |
|
||||||
|
| ◉ Any Claw on the team |
|
||||||
|
| ○ Specific claws — pick claws |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ Need help? support@… ]
|
||||||
|
```
|
||||||
|
**Edit profile (drill-in, ‹ back):**
|
||||||
|
```
|
||||||
|
‹ Edit profile
|
||||||
|
(avatar) [Change photo]
|
||||||
|
+------------------------------------------+
|
||||||
|
| Name {Name} |
|
||||||
|
| Job Title {Role} |
|
||||||
|
+------------------------------------------+
|
||||||
|
JOB DESCRIPTION
|
||||||
|
[ Describe how this claw should think... ]
|
||||||
|
"becomes part of its system prompt"
|
||||||
|
PERSONALIZATION
|
||||||
|
| Wallpaper {Theme} > |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ 🗑 Delete claw ] (destructive)
|
||||||
|
```
|
||||||
|
Key: the Job Description textarea IS the system prompt. Wallpaper drives panel theming. Delete is destructive (gated/confirmed).
|
||||||
|
|
||||||
|
### 7.8 Add Apps (connect directory) — `?app=apps`
|
||||||
|
```
|
||||||
|
Add Apps [×]
|
||||||
|
[ Search apps ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| (icon) Gmail [+] |
|
||||||
|
| (icon) Google Calendar [+] |
|
||||||
|
| (icon) Notion / Linear / GitHub … [+] |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ + Add custom app ]
|
||||||
|
```
|
||||||
|
Per-row [+] starts OAuth. "Add custom app" → auth-method dialog (see §10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Global Pages
|
||||||
|
|
||||||
|
### 8.1 Skill Library — `/skills`
|
||||||
|
```
|
||||||
|
Skill Library [ Create v ]
|
||||||
|
Browse skills published by your team and the catalog.
|
||||||
|
[ Search skills ] [ All categories v ]
|
||||||
|
+------------------+ +------------------+
|
||||||
|
| {Skill} [+] | | {Skill} [+] |
|
||||||
|
| by {author} | | by {author} |
|
||||||
|
| {description} | | {description} |
|
||||||
|
| N installs | | N installs |
|
||||||
|
+------------------+ +------------------+
|
||||||
|
```
|
||||||
|
Two-column cards: title, author, description, install count, install (+).
|
||||||
|
|
||||||
|
### 8.2 Apps — `/apps`
|
||||||
|
```
|
||||||
|
Apps [ + Add Custom App ]
|
||||||
|
[ Find an App ] [ Category v ]
|
||||||
|
Browse All Apps — connect via OAuth
|
||||||
|
+------------------+ +------------------+
|
||||||
|
|(logo){App} [+] | |(logo){App} [+] |
|
||||||
|
| {description} | | {description} |
|
||||||
|
+------------------+ +------------------+
|
||||||
|
```
|
||||||
|
Infinite scroll. Includes Gmail, Calendar, Drive, Notion, Linear, GitHub, Figma, Zoom, Stripe, HubSpot, Sheets, Telegram, HTTP/Webhook, Schedule, Supabase, MySQL, Postgres, AWS, SendGrid, SES, …
|
||||||
|
|
||||||
|
### 8.3 Team — `/team`
|
||||||
|
```
|
||||||
|
{Workspace} ✎ [ Invite ]
|
||||||
|
N members in your workspace
|
||||||
|
[ Members ] [ Claw org chart ] [ Leaderboard ]
|
||||||
|
+------------------------------------------+
|
||||||
|
| Name Email Joined |
|
||||||
|
| (NM) {User} [Owner] {email} {date} |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Credits — `/credits`
|
||||||
|
```
|
||||||
|
Credits — manage balance, subscriptions, usage
|
||||||
|
+------------------------------------------+
|
||||||
|
| AVAILABLE CREDITS {N} [Buy credits][Activity]|
|
||||||
|
| Plans from $/mo · save yearly |
|
||||||
|
| CREDIT LOTS · all credits never expire |
|
||||||
|
| USAGE · LAST 7 DAYS [▓▓▓▓▓▓░] ~2 days |
|
||||||
|
+------------------------------------------+
|
||||||
|
| Promo code [ PROMOCODE ] [ Redeem ] |
|
||||||
|
+------------------------------------------+
|
||||||
|
| Scaling beyond self-serve? [ Talk to sales ]|
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Agent Creation Wizard — `/claws/new` (?step=identity|access|slack)
|
||||||
|
```
|
||||||
|
[▬▬▬▬] [ ] [ ] (3-step progress)
|
||||||
|
Create your Claw
|
||||||
|
Claws help get your work done.
|
||||||
|
(< avatar carousel >) Name *
|
||||||
|
● ● ● ● [+] (accent swatches) [ {name} ] (⟳)
|
||||||
|
Job title
|
||||||
|
[ {role} ]
|
||||||
|
[ Continue → ]
|
||||||
|
```
|
||||||
|
Step 1 identity (avatar carousel + accent/wallpaper swatches + name w/ randomizer + job title), Step 2 access (same scopes as Settings), Step 3 Slack (optional). Provisioning shows animated spin-up states. **Note:** completing creates a live agent — confirm before submit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Dialogs & Overlays
|
||||||
|
|
||||||
|
### Add Custom App (centered modal)
|
||||||
|
```
|
||||||
|
(icon) Add Custom App [×]
|
||||||
|
Pick how this app authenticates
|
||||||
|
+------------------------------------------+
|
||||||
|
| 🔑 Keys — secret keys / API key / token |
|
||||||
|
| 🛡 Username & Password — HTTP basic auth |
|
||||||
|
| 🖥 MCP Server with OAuth — remote MCP |
|
||||||
|
+------------------------------------------+
|
||||||
|
[ Cancel ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Approval card (safety layer)
|
||||||
|
```
|
||||||
|
Review & approve
|
||||||
|
This claw wants to: {action summary}
|
||||||
|
PREVIEW: {exact payload / diff of what executes}
|
||||||
|
Source: {agent / thread} ⚠ {sensitive/unverified}
|
||||||
|
[ Reject ] [ Approve ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Component Inventory
|
||||||
|
**Shell:** LeftRail, AgentRosterItem, AddAgentButton, GlobalNavItem, UserMenu.
|
||||||
|
**Chat:** ChatHeader, WelcomeState, SuggestedPromptChip, MessageList, UserBubble, AgentMessage, StepTrace, CodeChip, GatedActionLink, MessageActions, Composer.
|
||||||
|
**Sessions:** SessionsColumn, SessionSearch, SessionRow, NewSessionButton.
|
||||||
|
**Computer panel:** DevicePanel (resizable), DeviceSizeToggle, PanelHeader, AppGrid, AppTile, Dock, DockTile; modules: BrowserApp, ClawChatApp (ThreadList/ThreadRow/ThreadDetail), SlackApp (Tabs/ConnectGate), FilesApp (FolderRow), SkillsApp, RoutinesApp, SettingsApp (IdentityHeader/SettingRow/AccessToggleGroup/EditProfileForm/WallpaperPicker/DeleteAgent), AddAppsApp.
|
||||||
|
**Global:** SkillCard, AppCard, AddCustomAppDialog, MembersTable, CreditsCard, UsageMeter, PromoRedeem.
|
||||||
|
**Wizard:** StepProgress, AvatarCarousel, AccentSwatchRow, ProvisioningState.
|
||||||
|
**Safety:** ApprovalCard, ApprovalQueue, ActionPreview, AuditLogEntry, UntrustedContentBadge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Routing & URL State Model
|
||||||
|
- Global: `/skills`, `/apps`, `/team`, `/credits`, `/claws/new`
|
||||||
|
- Agent: `/claws/{clawId}/chat/{sessionKey}` where sessionKey = `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`
|
||||||
|
- Overlays via query: `?app=browser|slack|chat|skills|files|routines(→scheduled)|settings|apps`, `?sessions=1`, `?device=full|tablet|phone`, `?step=identity|access|slack`
|
||||||
|
- All panel/overlay state is deep-linkable & shareable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. API Contract (REST, JSON, same-origin /api/, keyed by clawId)
|
||||||
|
**Observed:**
|
||||||
|
- Identity/team: `GET /user/me`, `GET /team/permissions`, `GET /team/credits`, `GET /team/claws`
|
||||||
|
- Per-agent: `GET /claws/{id}/health`, `GET /claws/settings/full?clawId=`, `GET /claws/{id}/custom-photos`, `GET /claws/{id}/slack-channels`, `GET /claws/{id}/apps/available`
|
||||||
|
- Sessions: `GET /sessions?clawId=`, `GET /sessions/active?clawId=`, `GET /sessions/history?sessionKey=&tools=true&clawId=`
|
||||||
|
- Apps/skills/slack: `GET /apps?clawId=`, `GET /skills?clawId=`, `GET /slack/me`, `GET /apps/slack/setup`
|
||||||
|
- Files: `GET /openclaw/files?clawId=`, `GET /shared-drive/files?clawId=`
|
||||||
|
- Runtime bridge: `POST /gateway?clawId=` (streaming)
|
||||||
|
**Designed (confirm during build):**
|
||||||
|
- `POST /claws`, `PATCH /claws/{id}`, `DELETE /claws/{id}`, `POST /sessions`, `POST /skills/install`, `POST /apps/connect`
|
||||||
|
- Approvals: `GET /approvals`, `GET /approvals/{id}`, `POST /approvals/{id}/approve`, `POST /approvals/{id}/reject`
|
||||||
|
Health/active are polled; auth uses provider session heartbeat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Data Model
|
||||||
|
Workspace/Team(name, plan, credits) · User(id, role Owner|Member, email) · Agent(id, name, jobTitle, systemPrompt, avatar, accent/wallpaper, managedBy, status, accessPolicy) · AccessPolicy(humans: entireTeam|specific[]; agents: any|specific[]) · Session(id, clawId, title, ts) → Message(role, content, steps[], ts) · Skill / InstalledSkill · AppConnection(provider, authType, scopes, status) · Thread/InterAgentMessage(participants, subject, sensitivity) · Routine(schedule, action, status) · FileNode(drive, path, owner) · Approval(actionType, payload, preview, requestedByAgent, status, decidedBy, audit) · CreditLedger(lots, usageEvents).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Agent Runtime & Safety Layer (mandatory)
|
||||||
|
- **Sandbox:** containerized, no root, dropped Linux caps, seccomp, egress-restricted (HTTPS-out by default).
|
||||||
|
- **Secret broker:** secrets held by a bridge process OUTSIDE the agent's read scope; agent calls capabilities via the gateway, never holds raw credentials.
|
||||||
|
- **Gateway:** single audited streaming channel (`POST /gateway`) between app and runtime.
|
||||||
|
- **Approval interception:** every sensitive/irreversible tool call → preview + Approval queue + blocks until explicit human decision → executes only on approve → fully audit-logged.
|
||||||
|
- **Gated categories:** outbound messages/emails; sharing keys/secrets/credentials; access/permission/sharing changes; financial transactions & credit purchases; file deletion; granting/extending external-infra access.
|
||||||
|
- **Untrusted-by-default:** content from web, email, inter-agent chat, and tool results is data, never instructions; unverified/suspicious requests are surfaced to the human, never auto-executed.
|
||||||
|
- These are **acceptance-blocking** requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. PRD Summary
|
||||||
|
Deliver a chat-first, multi-agent workspace with per-agent identity/system-prompt/skills/apps/files/routines, a themed slide-out Computer panel hosting all agent tooling, global admin (skills, apps, team, credits), an agent creation wizard, and a first-class human-in-the-loop safety/approval + audit layer. NFRs: SSO, RBAC, audit trail, credit metering, fast RSC navigation, full a11y, responsive/PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Delivery Roadmap
|
||||||
|
- **P0 Foundations (2–3w):** Next.js App Router + Turbopack, Tailwind v4 tokens, auth+RBAC, analytics/flags, base shell. _Exit: authed empty shell._
|
||||||
|
- **P1 Agents & chat (3–4w):** agent CRUD, roster, chat route, welcome/transcript, composer, sessions, step traces. _Exit: multi-session conversation._
|
||||||
|
- **P2 Runtime, gateway & SAFETY (4–5w, GATED):** sandbox, secret broker, streaming gateway, approval+audit end-to-end. _Exit (blocking): sensitive call intercepted→previewed→queued→approve-only execute→audit-logged; untrusted content tagged & never auto-run._
|
||||||
|
- **P3 Computer panel & apps (4–5w):** DevicePanel + ?app/?device routing, dock+grid, Files/Skills/Routines/Settings/Browser/ClawChat. _Exit: all apps navigable + per-agent theming._
|
||||||
|
- **P4 Integrations (3–4w):** OAuth directory + connect, Add Custom App (Keys/Basic/MCP-OAuth), Slack (@mention + gated posting). _Exit: connect app + Slack, outbound gated._
|
||||||
|
- **P5 Global & billing (2–3w):** Skill Library, Team, Credits, creation wizard + provisioning anims. _Exit: full admin+billing loop._
|
||||||
|
- **P6 Polish & hardening (2–3w):** full motion system, noise/wallpaper textures, a11y, PWA, perf, sandbox+approval security review. _Exit: a11y + security sign-off; identical look-and-feel._
|
||||||
|
Cross-cutting (continuous): audit/observability, credit metering, safety layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
_End of document._
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
-- TeamClaw schema, spec §14. Single source of truth for the data model.
|
||||||
|
-- Ids are UUIDv7 generated by the application (tc-domain).
|
||||||
|
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
plan TEXT NOT NULL DEFAULT 'free',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
-- Subject claim for OIDC users; NULL for local-auth users.
|
||||||
|
auth_subject TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX users_workspace_idx ON users (workspace_id);
|
||||||
|
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
job_title TEXT NOT NULL,
|
||||||
|
-- The Settings "Job Description" textarea IS the system prompt (§7.7).
|
||||||
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
accent TEXT NOT NULL DEFAULT '',
|
||||||
|
wallpaper TEXT NOT NULL DEFAULT '',
|
||||||
|
managed_by UUID NOT NULL REFERENCES users (id),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('provisioning', 'online', 'offline')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX agents_workspace_idx ON agents (workspace_id) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE access_policies (
|
||||||
|
agent_id UUID PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
|
||||||
|
humans_mode TEXT NOT NULL CHECK (humans_mode IN ('entire_team', 'specific')),
|
||||||
|
human_ids UUID[] NOT NULL DEFAULT '{}',
|
||||||
|
agents_mode TEXT NOT NULL CHECK (agents_mode IN ('any', 'specific')),
|
||||||
|
agent_ids UUID[] NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
agent_id UUID NOT NULL REFERENCES agents (id),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
shard SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_active_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_agent_idx ON sessions (agent_id, last_active_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions (id),
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')),
|
||||||
|
content JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (session_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE steps (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
message_id UUID NOT NULL REFERENCES messages (id),
|
||||||
|
seq INT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
tool_name TEXT,
|
||||||
|
input JSONB,
|
||||||
|
output JSONB,
|
||||||
|
-- Taint sources: 'web', 'email', 'inter_agent', 'tool_result' (§15).
|
||||||
|
taint TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (message_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE agent_runs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions (id),
|
||||||
|
state TEXT NOT NULL CHECK
|
||||||
|
(state IN ('running', 'awaiting_approval', 'completed', 'failed', 'cancelled')),
|
||||||
|
checkpoint JSONB,
|
||||||
|
last_event_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE approvals (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
run_id UUID NOT NULL REFERENCES agent_runs (id),
|
||||||
|
session_key TEXT NOT NULL,
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN (
|
||||||
|
'outbound_message', 'secret_sharing', 'access_change',
|
||||||
|
'financial_transaction', 'file_deletion', 'infra_access_grant')),
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
-- The exact rendered preview shown to the human (§10 approval card).
|
||||||
|
preview JSONB NOT NULL,
|
||||||
|
requested_by_agent UUID NOT NULL REFERENCES agents (id),
|
||||||
|
taint_sources TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL CHECK
|
||||||
|
(status IN ('pending', 'approved', 'rejected', 'expired')),
|
||||||
|
decided_by UUID REFERENCES users (id),
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX approvals_pending_idx ON approvals (workspace_id, created_at)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE TABLE execution_grants (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
approval_id UUID NOT NULL UNIQUE REFERENCES approvals (id),
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
consumed BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
consumed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'agent', 'system')),
|
||||||
|
actor_id UUID,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
subject_type TEXT NOT NULL,
|
||||||
|
subject_id TEXT NOT NULL,
|
||||||
|
detail JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The audit log is append-only at the database level, not by convention.
|
||||||
|
CREATE FUNCTION audit_log_immutable() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'audit_log is append-only';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER audit_log_no_update
|
||||||
|
BEFORE UPDATE OR DELETE ON audit_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION audit_log_immutable();
|
||||||
|
|
||||||
|
CREATE TABLE skills (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
-- NULL workspace = catalog skill visible to every workspace (§8.1).
|
||||||
|
workspace_id UUID REFERENCES workspaces (id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
installs INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE installed_skills (
|
||||||
|
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
|
||||||
|
skill_id UUID NOT NULL REFERENCES skills (id),
|
||||||
|
installed_by UUID NOT NULL REFERENCES users (id),
|
||||||
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (agent_id, skill_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE secrets (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
ciphertext BYTEA NOT NULL,
|
||||||
|
nonce BYTEA NOT NULL,
|
||||||
|
key_version INT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE app_connections (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
agent_id UUID REFERENCES agents (id),
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
auth_type TEXT NOT NULL CHECK
|
||||||
|
(auth_type IN ('oauth', 'keys', 'basic', 'mcp_oauth')),
|
||||||
|
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
secret_ref UUID REFERENCES secrets (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE threads (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK
|
||||||
|
(sensitivity IN ('normal', 'sensitive')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE thread_participants (
|
||||||
|
thread_id UUID NOT NULL REFERENCES threads (id) ON DELETE CASCADE,
|
||||||
|
agent_id UUID NOT NULL REFERENCES agents (id),
|
||||||
|
PRIMARY KEY (thread_id, agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE thread_messages (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
thread_id UUID NOT NULL REFERENCES threads (id),
|
||||||
|
from_agent UUID NOT NULL REFERENCES agents (id),
|
||||||
|
content JSONB NOT NULL,
|
||||||
|
taint TEXT[] NOT NULL DEFAULT '{inter_agent}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE routines (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
schedule_cron TEXT NOT NULL,
|
||||||
|
action JSONB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK
|
||||||
|
(status IN ('active', 'paused')),
|
||||||
|
next_run_at TIMESTAMPTZ,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE file_nodes (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
-- NULL for the team-wide shared drive; set for per-agent drives (§7.4).
|
||||||
|
agent_id UUID REFERENCES agents (id),
|
||||||
|
drive TEXT NOT NULL CHECK (drive IN ('documents', 'received', 'shared')),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('file', 'folder')),
|
||||||
|
size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
blob_ref TEXT,
|
||||||
|
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user', 'agent')),
|
||||||
|
owner_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX file_nodes_path_idx
|
||||||
|
ON file_nodes (workspace_id, drive, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), path);
|
||||||
|
|
||||||
|
CREATE TABLE credit_lots (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
amount NUMERIC NOT NULL CHECK (amount >= 0),
|
||||||
|
remaining NUMERIC NOT NULL CHECK (remaining >= 0),
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
purchased_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE usage_events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id),
|
||||||
|
agent_id UUID REFERENCES agents (id),
|
||||||
|
run_id UUID REFERENCES agent_runs (id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
tokens_in BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tokens_out BIGINT NOT NULL DEFAULT 0,
|
||||||
|
credits NUMERIC NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.96.0"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
Reference in New Issue
Block a user