From 2badcf04fbc8e411dee37b15a5e6e6394d30daf1 Mon Sep 17 00:00:00 2001 From: Xy718 Date: Sat, 30 May 2026 08:35:27 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(agent-device):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8A=9E=E5=85=AC=E8=AE=BE=E6=96=BD=E5=AE=9E=E9=AA=8C=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal-docs/agent-device-prd.md | 157 ++ .../fccc870bdf4d_add_agent_device_models.py | 186 +++ nodeskclaw-backend/app/api/agent_devices.py | 529 +++++++ nodeskclaw-backend/app/api/corridors.py | 11 + nodeskclaw-backend/app/api/router.py | 3 + nodeskclaw-backend/app/api/workspaces.py | 21 + nodeskclaw-backend/app/core/config.py | 5 + .../gene_scripts/deskclaw_agent_device.py | 116 ++ .../agent_device_browser_bpilot.json | 22 + nodeskclaw-backend/app/main.py | 5 + nodeskclaw-backend/app/models/__init__.py | 7 + nodeskclaw-backend/app/models/agent_device.py | 162 +++ .../app/models/workspace_member.py | 3 +- .../app/schemas/agent_device.py | 134 ++ .../agent_device_gene_sync_service.py | 27 + .../app/services/agent_device_provider.py | 126 ++ .../app/services/agent_device_service.py | 1295 +++++++++++++++++ .../app/services/audit_handler.py | 4 +- .../app/services/corridor_router.py | 8 + .../runtime/registries/node_type_registry.py | 12 + .../app/services/workspace_service.py | 1 + .../tests/test_agent_device_service.py | 555 +++++++ .../tests/test_ce_audit_handler.py | 16 +- .../test_workspace_agent_node_card_sync.py | 80 +- .../src/components/hex2d/Workspace2D.vue | 86 +- .../src/components/hex3d/Workspace3D.vue | 88 +- .../workspace/DeviceDetailDrawer.vue | 377 +++++ .../components/workspace/HexActionDrawer.vue | 95 +- nodeskclaw-portal/src/i18n/locales/en-US.ts | 57 + nodeskclaw-portal/src/i18n/locales/zh-CN.ts | 57 + nodeskclaw-portal/src/stores/workspace.ts | 261 +++- nodeskclaw-portal/src/views/WorkspaceView.vue | 142 +- 32 files changed, 4585 insertions(+), 63 deletions(-) create mode 100644 internal-docs/agent-device-prd.md create mode 100644 nodeskclaw-backend/alembic/versions/fccc870bdf4d_add_agent_device_models.py create mode 100644 nodeskclaw-backend/app/api/agent_devices.py create mode 100644 nodeskclaw-backend/app/data/gene_scripts/deskclaw_agent_device.py create mode 100644 nodeskclaw-backend/app/data/gene_templates/agent_device_browser_bpilot.json create mode 100644 nodeskclaw-backend/app/models/agent_device.py create mode 100644 nodeskclaw-backend/app/schemas/agent_device.py create mode 100644 nodeskclaw-backend/app/services/agent_device_gene_sync_service.py create mode 100644 nodeskclaw-backend/app/services/agent_device_provider.py create mode 100644 nodeskclaw-backend/app/services/agent_device_service.py create mode 100644 nodeskclaw-backend/tests/test_agent_device_service.py create mode 100644 nodeskclaw-portal/src/components/workspace/DeviceDetailDrawer.vue diff --git a/internal-docs/agent-device-prd.md b/internal-docs/agent-device-prd.md new file mode 100644 index 00000000..f252cb22 --- /dev/null +++ b/internal-docs/agent-device-prd.md @@ -0,0 +1,157 @@ +# Agent Device 实验 PRD + +## 背景 + +DeskClaw 的办公室 Hex 已经可以放置 AI 员工、人类、黑板和过道。Agent Device 实验把外部可操作资源也作为办公室中的一类可放置对象,让 Agent 通过拓扑可达关系、显式授权、租约和审计来使用设备。 + +产品展示名约定: + +| 场景 | 名称 | +| --- | --- | +| 中文 UI | 办公设施 | +| 英文 UI / 协议名 | Agent Device | + +## 目标 + +1. 在办公室拓扑中放置受治理的设备节点,而不是把设备伪装成某个 Agent 的私有 Skill / MCP。 +2. 先建立可扩展抽象,再用第一批内置预设验证;MVP 只预置 Browser Pilot。 +3. Agent 通过 Controller 暴露的发现、可见性、授权、租约和调用接口使用设备。 +4. 人类和 Agent 都可以参与设备治理;Agent 之间允许在授权链内自动委托权限。 +5. 所有设备发现、授权、租约、调用和回收动作必须进入审计链路。 + +## 非目标 + +1. MVP 不做公开市场、第三方设备商店或用户自定义设备发布流程。 +2. 不把设备身份、设备状态、授权、租约和审计下放给某个 Agent 私有 Skill / MCP。 +3. 不在核心抽象里硬编码 Browser Pilot 的页面操作细节;Browser Pilot 只是第一个 Provider。 +4. 不支持无拓扑直接本地访问设备;MVP 必须拓扑可达。后续保留 remote reachability 扩展口。 + +## 核心概念 + +| 概念 | 说明 | +| --- | --- | +| Agent Device / 办公设施 | 放置在办公室 Hex 上的受治理设备实例 | +| Device Preset | 内置设备预设,例如 `browser.bpilot.session` | +| Device Provider | 真正执行设备动作的适配器,例如 `browser.bpilot` | +| Device Grant | 授予某个 Agent 或 Human 的设备权限 | +| Device Lease | Agent 操作设备前必须获取的独占租约 | +| Agent Device Gene | 给 Agent 的操作说明和受控脚本入口,不拥有设备身份和治理状态 | +| Controller | 当前由 DeskClaw 后端承担,管理设备实例、拓扑可达、授权、租约、调用和审计 | + +## Agent 如何发现和使用设备 + +Agent 不靠“自己装了某个 Skill”来判断设备存在,而是通过 Agent Device Gene 获得受控入口: + +```bash +python3 ~/.deskclaw/tools/deskclaw_agent_device.py list_reachable +python3 ~/.deskclaw/tools/deskclaw_agent_device.py visibility --device-id +python3 ~/.deskclaw/tools/deskclaw_agent_device.py acquire_lease --device-id +python3 ~/.deskclaw/tools/deskclaw_agent_device.py invoke --device-id --lease-id --provider-action page.goto --payload-json '{"url":"https://example.com"}' +python3 ~/.deskclaw/tools/deskclaw_agent_device.py release_lease --device-id --lease-id +``` + +这些脚本只负责调用 Controller API。设备身份、设备状态、拓扑可达、授权、租约和审计都来自 Controller 返回的结构化结果。 + +Agent 可见一个设备需要同时满足: + +1. 设备预设已启用。 +2. Provider 状态可用。 +3. Agent 在办公室拓扑中放置。 +4. 办公室存在连接关系,且 Agent 到设备 Hex 拓扑可达。 +5. Agent 持有 `discover` 授权。 + +Agent 使用设备需要进一步满足: + +1. 持有 `lease` 授权并成功获取独占租约。 +2. 持有 `invoke` 授权。 +3. 调用时提交有效 `lease_id`。 + +## 权限模型 + +MVP 设备权限范围: + +| Scope | 含义 | +| --- | --- | +| `discover` | 可以发现和读取可见性 | +| `lease` | 可以获取和续租设备 | +| `invoke` | 可以通过有效租约调用 Provider 动作 | +| `delegate` | 可以把自己拥有的部分权限委托给其他 Agent | + +权限来源: + +| 授予方 | 能力 | +| --- | --- | +| Human with `manage_devices` | 可以创建、撤销设备授权,回收任意活跃租约 | +| Agent with delegable grant | 可以把自己拥有的 scope 子集委托给其他 Agent | + +Agent 委托规则: + +1. Agent 只能委托给其他 Agent。 +2. 子授权 scope 必须是父授权 scope 的子集。 +3. 父授权必须包含 `delegate` 且 `can_delegate=true`。 +4. 未显式设置过期时间时,Agent 委托默认短 TTL。 +5. 子授权过期时间不能超过父授权。 +6. Agent 只能回收自己持有的租约,或自己授权链下游 Agent 持有的租约。 + +## 人类如何参与治理 + +人类通过办公室 UI 管理办公设施: + +1. 在空 Hex 放置内置设备预设。 +2. 查看设备状态、Provider 状态、拓扑位置和可见性原因。 +3. 给办公室中的 Agent 授权。 +4. 撤销授权。 +5. 回收活跃租约。 +6. 移动、重命名或删除设备。 + +这些动作要求工作区成员权限中的 `manage_devices`;涉及 Hex 放置或移动时还要求 `edit_topology`。 + +## MVP 范围 + +后端: + +1. 设备预设启停:`/workspaces/{workspace_id}/device-presets` +2. 设备实例 CRUD:`/workspaces/{workspace_id}/devices` +3. 设备授权:`/devices/{device_id}/grants` +4. 可达设备发现:`/reachable-devices` +5. 可见性检查:`/devices/{device_id}/visibility` +6. 租约获取、续租、释放、回收 +7. Provider 调用:`/devices/{device_id}/invoke` +8. 操作审计记录 user / agent 两类 actor +9. 拓扑变化后同步 Agent Device Gene + +前端: + +1. 2D / 3D Hex 渲染设备节点。 +2. 空 Hex 可放置办公设施。 +3. 设备详情抽屉展示状态、可见性、租约和授权。 +4. 支持重命名、移动、删除、授权和回收租约。 + +首个 Provider: + +| 字段 | 值 | +| --- | --- | +| Preset | `browser.bpilot.session` | +| Provider | `browser.bpilot` | +| Gene | `agent-device-browser-bpilot` | +| Lease | exclusive | +| Actions | `session.create`, `session.use`, `page.goto`, `page.observe`, `page.click`, `page.type` | + +## Remote 预留口 + +MVP 强制拓扑可达,`visibility.reachability_source` 当前为 `topology` 或空。后续如需要 remote access,应增加显式 remote grant / policy,而不是把无拓扑访问混入普通授权。 + +建议后续扩展: + +1. `reachability_source=remote` +2. 独立 `remote` scope 或 policy flag +3. remote 授权必须有更短 TTL 和更明确审计原因 +4. UI 明确标识“远程使用”,避免和拓扑可达混淆 + +## 待讨论 + +1. Human subject grant 是否要在 MVP 中开放,还是仅作为模型预留。 +2. Agent 委托默认 TTL 是否固定 30 分钟,还是由工作区策略控制。 +3. Provider 返回语义是否需要在 UI 中展示更细的“下一步建议”。 +4. remote 入口是设备级策略、Agent 级策略,还是工作区级策略。 +5. Browser Pilot Provider 的动作 schema 是否要在 Controller 层做白名单校验。 diff --git a/nodeskclaw-backend/alembic/versions/fccc870bdf4d_add_agent_device_models.py b/nodeskclaw-backend/alembic/versions/fccc870bdf4d_add_agent_device_models.py new file mode 100644 index 00000000..1c5bacad --- /dev/null +++ b/nodeskclaw-backend/alembic/versions/fccc870bdf4d_add_agent_device_models.py @@ -0,0 +1,186 @@ +"""add agent device models + +Revision ID: fccc870bdf4d +Revises: 30a1bcac4c80 +Create Date: 2026-05-29 20:16:36.189898 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'fccc870bdf4d' +down_revision: str | Sequence[str] | None = '30a1bcac4c80' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('agent_device_instances', + sa.Column('workspace_id', sa.String(length=36), nullable=False), + sa.Column('preset_id', sa.String(length=128), nullable=False), + sa.Column('provider_id', sa.String(length=128), nullable=False), + sa.Column('display_name', sa.String(length=128), nullable=False), + sa.Column('hex_q', sa.Integer(), nullable=False), + sa.Column('hex_r', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=32), server_default='available', nullable=False), + sa.Column('status_reason', sa.String(length=128), nullable=True), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_by', sa.String(length=36), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_device_instances_deleted_at'), 'agent_device_instances', ['deleted_at'], unique=False) + op.create_index(op.f('ix_agent_device_instances_preset_id'), 'agent_device_instances', ['preset_id'], unique=False) + op.create_index(op.f('ix_agent_device_instances_provider_id'), 'agent_device_instances', ['provider_id'], unique=False) + op.create_index(op.f('ix_agent_device_instances_workspace_id'), 'agent_device_instances', ['workspace_id'], unique=False) + op.create_index('uq_agent_device_instance_hex', 'agent_device_instances', ['workspace_id', 'hex_q', 'hex_r'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + op.create_table('agent_device_preset_enablements', + sa.Column('workspace_id', sa.String(length=36), nullable=False), + sa.Column('preset_id', sa.String(length=128), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default='true', nullable=False), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_by', sa.String(length=36), nullable=True), + sa.Column('updated_by', sa.String(length=36), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_device_preset_enablements_deleted_at'), 'agent_device_preset_enablements', ['deleted_at'], unique=False) + op.create_index(op.f('ix_agent_device_preset_enablements_preset_id'), 'agent_device_preset_enablements', ['preset_id'], unique=False) + op.create_index(op.f('ix_agent_device_preset_enablements_workspace_id'), 'agent_device_preset_enablements', ['workspace_id'], unique=False) + op.create_index('uq_agent_device_preset_enablement', 'agent_device_preset_enablements', ['workspace_id', 'preset_id'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + op.create_table('agent_device_grants', + sa.Column('workspace_id', sa.String(length=36), nullable=False), + sa.Column('device_id', sa.String(length=36), nullable=False), + sa.Column('subject_type', sa.String(length=16), nullable=False), + sa.Column('subject_id', sa.String(length=36), nullable=False), + sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False), + sa.Column('can_delegate', sa.Boolean(), server_default='false', nullable=False), + sa.Column('parent_grant_id', sa.String(length=36), nullable=True), + sa.Column('granted_by_type', sa.String(length=16), nullable=False), + sa.Column('granted_by_id', sa.String(length=36), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['device_id'], ['agent_device_instances.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_grant_id'], ['agent_device_grants.id'], ), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_device_grants_deleted_at'), 'agent_device_grants', ['deleted_at'], unique=False) + op.create_index(op.f('ix_agent_device_grants_device_id'), 'agent_device_grants', ['device_id'], unique=False) + op.create_index('ix_agent_device_grants_parent', 'agent_device_grants', ['parent_grant_id'], unique=False) + op.create_index('ix_agent_device_grants_subject', 'agent_device_grants', ['workspace_id', 'subject_type', 'subject_id'], unique=False) + op.create_index(op.f('ix_agent_device_grants_subject_id'), 'agent_device_grants', ['subject_id'], unique=False) + op.create_index(op.f('ix_agent_device_grants_workspace_id'), 'agent_device_grants', ['workspace_id'], unique=False) + op.create_table('agent_device_leases', + sa.Column('workspace_id', sa.String(length=36), nullable=False), + sa.Column('device_id', sa.String(length=36), nullable=False), + sa.Column('holder_agent_id', sa.String(length=36), nullable=False), + sa.Column('grant_id', sa.String(length=36), nullable=False), + sa.Column('status', sa.String(length=16), server_default='active', nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('renewed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('released_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['device_id'], ['agent_device_instances.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['grant_id'], ['agent_device_grants.id'], ), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_device_leases_deleted_at'), 'agent_device_leases', ['deleted_at'], unique=False) + op.create_index(op.f('ix_agent_device_leases_device_id'), 'agent_device_leases', ['device_id'], unique=False) + op.create_index(op.f('ix_agent_device_leases_grant_id'), 'agent_device_leases', ['grant_id'], unique=False) + op.create_index('ix_agent_device_leases_holder', 'agent_device_leases', ['workspace_id', 'holder_agent_id'], unique=False) + op.create_index(op.f('ix_agent_device_leases_holder_agent_id'), 'agent_device_leases', ['holder_agent_id'], unique=False) + op.create_index(op.f('ix_agent_device_leases_workspace_id'), 'agent_device_leases', ['workspace_id'], unique=False) + op.create_index('uq_agent_device_active_lease', 'agent_device_leases', ['device_id'], unique=True, postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'")) + op.create_table('agent_device_gene_bindings', + sa.Column('workspace_id', sa.String(length=36), nullable=False), + sa.Column('device_id', sa.String(length=36), nullable=False), + sa.Column('instance_id', sa.String(length=36), nullable=False), + sa.Column('gene_id', sa.String(length=36), nullable=True), + sa.Column('gene_slug', sa.String(length=128), nullable=False), + sa.Column('instance_gene_id', sa.String(length=36), nullable=True), + sa.Column('was_preexisting', sa.Boolean(), server_default='false', nullable=False), + sa.Column('sync_reason', sa.String(length=64), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['device_id'], ['agent_device_instances.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['gene_id'], ['genes.id'], ), + sa.ForeignKeyConstraint(['instance_gene_id'], ['instance_genes.id'], ), + sa.ForeignKeyConstraint(['instance_id'], ['instances.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_device_gene_bindings_deleted_at'), 'agent_device_gene_bindings', ['deleted_at'], unique=False) + op.create_index(op.f('ix_agent_device_gene_bindings_device_id'), 'agent_device_gene_bindings', ['device_id'], unique=False) + op.create_index(op.f('ix_agent_device_gene_bindings_gene_slug'), 'agent_device_gene_bindings', ['gene_slug'], unique=False) + op.create_index(op.f('ix_agent_device_gene_bindings_instance_id'), 'agent_device_gene_bindings', ['instance_id'], unique=False) + op.create_index(op.f('ix_agent_device_gene_bindings_workspace_id'), 'agent_device_gene_bindings', ['workspace_id'], unique=False) + op.create_index('uq_agent_device_gene_binding', 'agent_device_gene_bindings', ['workspace_id', 'device_id', 'instance_id', 'gene_slug'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('uq_agent_device_gene_binding', table_name='agent_device_gene_bindings', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_index(op.f('ix_agent_device_gene_bindings_workspace_id'), table_name='agent_device_gene_bindings') + op.drop_index(op.f('ix_agent_device_gene_bindings_instance_id'), table_name='agent_device_gene_bindings') + op.drop_index(op.f('ix_agent_device_gene_bindings_gene_slug'), table_name='agent_device_gene_bindings') + op.drop_index(op.f('ix_agent_device_gene_bindings_device_id'), table_name='agent_device_gene_bindings') + op.drop_index(op.f('ix_agent_device_gene_bindings_deleted_at'), table_name='agent_device_gene_bindings') + op.drop_table('agent_device_gene_bindings') + op.drop_index('uq_agent_device_active_lease', table_name='agent_device_leases', postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'")) + op.drop_index(op.f('ix_agent_device_leases_workspace_id'), table_name='agent_device_leases') + op.drop_index(op.f('ix_agent_device_leases_holder_agent_id'), table_name='agent_device_leases') + op.drop_index('ix_agent_device_leases_holder', table_name='agent_device_leases') + op.drop_index(op.f('ix_agent_device_leases_grant_id'), table_name='agent_device_leases') + op.drop_index(op.f('ix_agent_device_leases_device_id'), table_name='agent_device_leases') + op.drop_index(op.f('ix_agent_device_leases_deleted_at'), table_name='agent_device_leases') + op.drop_table('agent_device_leases') + op.drop_index(op.f('ix_agent_device_grants_workspace_id'), table_name='agent_device_grants') + op.drop_index(op.f('ix_agent_device_grants_subject_id'), table_name='agent_device_grants') + op.drop_index('ix_agent_device_grants_subject', table_name='agent_device_grants') + op.drop_index('ix_agent_device_grants_parent', table_name='agent_device_grants') + op.drop_index(op.f('ix_agent_device_grants_device_id'), table_name='agent_device_grants') + op.drop_index(op.f('ix_agent_device_grants_deleted_at'), table_name='agent_device_grants') + op.drop_table('agent_device_grants') + op.drop_index('uq_agent_device_preset_enablement', table_name='agent_device_preset_enablements', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_index(op.f('ix_agent_device_preset_enablements_workspace_id'), table_name='agent_device_preset_enablements') + op.drop_index(op.f('ix_agent_device_preset_enablements_preset_id'), table_name='agent_device_preset_enablements') + op.drop_index(op.f('ix_agent_device_preset_enablements_deleted_at'), table_name='agent_device_preset_enablements') + op.drop_table('agent_device_preset_enablements') + op.drop_index('uq_agent_device_instance_hex', table_name='agent_device_instances', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_index(op.f('ix_agent_device_instances_workspace_id'), table_name='agent_device_instances') + op.drop_index(op.f('ix_agent_device_instances_provider_id'), table_name='agent_device_instances') + op.drop_index(op.f('ix_agent_device_instances_preset_id'), table_name='agent_device_instances') + op.drop_index(op.f('ix_agent_device_instances_deleted_at'), table_name='agent_device_instances') + op.drop_table('agent_device_instances') + # ### end Alembic commands ### diff --git a/nodeskclaw-backend/app/api/agent_devices.py b/nodeskclaw-backend/app/api/agent_devices.py new file mode 100644 index 00000000..4b76a9ef --- /dev/null +++ b/nodeskclaw-backend/app/api/agent_devices.py @@ -0,0 +1,529 @@ +"""Agent Device API — governed Agent Devices for workspace topology.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.workspaces import broadcast_event +from app.core import hooks +from app.core.deps import get_current_org, get_current_org_or_agent, get_db +from app.core.exceptions import BadRequestError, ForbiddenError +from app.core.security import get_auth_actor +from app.schemas.agent_device import ( + AgentDeviceCreate, + AgentDeviceGrantCreate, + AgentDeviceInvokeRequest, + AgentDeviceLeaseAcquire, + AgentDeviceLeaseRenew, + AgentDevicePresetEnablementUpdate, + AgentDeviceUpdate, +) +from app.services import agent_device_service as device_service +from app.services import workspace_member_service as wm_service +from app.services.workspace_actor_access import require_workspace_actor_member + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _ok(data=None, message: str = "success"): + return {"code": 0, "message": message, "data": data} + + +def _org_id(org) -> str | None: + if org is None: + return None + return org.id if hasattr(org, "id") else org.get("org_id") + + +def _actor_from_context(user) -> tuple[str, str]: + actor = get_auth_actor() + if actor and actor.actor_type == "agent": + return "agent", actor.actor_id + return "user", str(user.id) + + +def _current_agent_id() -> str | None: + actor = get_auth_actor() + if actor and actor.actor_type == "agent": + return actor.actor_id + return None + + +async def _sync_device_genes(db: AsyncSession, workspace_id: str, reason: str) -> None: + try: + await device_service.sync_workspace_device_genes(db, workspace_id=workspace_id, reason=reason) + await db.commit() + except Exception as exc: + await db.rollback() + logger.warning("Agent Device Gene 同步失败 workspace=%s reason=%s err=%s", workspace_id, reason, exc) + + +async def _check_workspace_for_user( + workspace_id: str, + org, + db: AsyncSession, +) -> None: + await device_service.check_workspace(workspace_id, _org_id(org), db) + + +@router.get("/{workspace_id}/device-presets") +async def list_device_presets( + workspace_id: str, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_member(workspace_id, user, db) + return _ok(await device_service.list_preset_infos(db, workspace_id=workspace_id)) + + +@router.get("/{workspace_id}/device-presets/{preset_id}") +async def get_device_preset( + workspace_id: str, + preset_id: str, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_member(workspace_id, user, db) + return _ok(await device_service.get_preset_info(db, workspace_id=workspace_id, preset_id=preset_id)) + + +@router.put("/{workspace_id}/device-presets/{preset_id}") +async def update_device_preset( + workspace_id: str, + preset_id: str, + body: AgentDevicePresetEnablementUpdate, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + data = await device_service.set_preset_enablement( + db, + workspace_id=workspace_id, + preset_id=preset_id, + enabled=body.enabled, + config=body.config, + actor_id=user.id, + org_id=_org_id(org), + ) + await db.commit() + broadcast_event(workspace_id, "device:preset_updated", {"preset_id": preset_id, "enabled": body.enabled}) + await _sync_device_genes(db, workspace_id, "preset_updated") + return _ok(data) + + +@router.get("/{workspace_id}/devices") +async def list_devices( + workspace_id: str, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_member(workspace_id, user, db) + devices = await device_service.list_devices(db, workspace_id=workspace_id) + return _ok([device_service.device_summary(device) for device in devices]) + + +@router.post("/{workspace_id}/devices") +async def create_device( + workspace_id: str, + body: AgentDeviceCreate, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + await wm_service.check_workspace_access(workspace_id, user, "edit_topology", db) + device = await device_service.create_device( + db, + workspace_id=workspace_id, + preset_id=body.preset_id, + display_name=body.display_name, + hex_q=body.hex_q, + hex_r=body.hex_r, + config=body.config, + metadata=body.metadata, + actor_id=user.id, + org_id=_org_id(org), + ) + await db.commit() + await db.refresh(device) + broadcast_event(workspace_id, "device:created", device_service.device_summary(device)) + await hooks.emit( + "topology_change", + db=db, + workspace_id=workspace_id, + action="device_created", + target_type="agent_device", + target_id=device.id, + actor_type="user", + actor_id=user.id, + ) + await _sync_device_genes(db, workspace_id, "device_created") + return _ok(device_service.device_summary(device)) + + +@router.patch("/{workspace_id}/devices/{device_id}") +async def update_device( + workspace_id: str, + device_id: str, + body: AgentDeviceUpdate, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + if body.hex_q is not None or body.hex_r is not None: + await wm_service.check_workspace_access(workspace_id, user, "edit_topology", db) + if (body.hex_q is None) != (body.hex_r is None): + raise BadRequestError("移动办公设施时必须同时提供 hex_q 和 hex_r", "errors.agent_device.position_required") + device, position_changed = await device_service.update_device( + db, + workspace_id=workspace_id, + device_id=device_id, + display_name=body.display_name, + hex_q=body.hex_q, + hex_r=body.hex_r, + config=body.config, + metadata=body.metadata, + actor_id=user.id, + org_id=_org_id(org), + ) + await db.commit() + await db.refresh(device) + broadcast_event(workspace_id, "device:updated", device_service.device_summary(device)) + if position_changed: + await hooks.emit( + "topology_change", + db=db, + workspace_id=workspace_id, + action="device_moved", + target_type="agent_device", + target_id=device.id, + actor_type="user", + actor_id=user.id, + ) + await _sync_device_genes(db, workspace_id, "device_updated") + return _ok(device_service.device_summary(device)) + + +@router.delete("/{workspace_id}/devices/{device_id}") +async def delete_device( + workspace_id: str, + device_id: str, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + await wm_service.check_workspace_access(workspace_id, user, "edit_topology", db) + device = await device_service.delete_device( + db, + workspace_id=workspace_id, + device_id=device_id, + actor_id=user.id, + org_id=_org_id(org), + ) + await db.commit() + broadcast_event(workspace_id, "device:deleted", {"device_id": device.id}) + await hooks.emit( + "topology_change", + db=db, + workspace_id=workspace_id, + action="device_deleted", + target_type="agent_device", + target_id=device.id, + actor_type="user", + actor_id=user.id, + ) + await _sync_device_genes(db, workspace_id, "device_deleted") + return _ok(message="deleted") + + +@router.get("/{workspace_id}/devices/{device_id}/grants") +async def list_device_grants( + workspace_id: str, + device_id: str, + org_ctx=Depends(get_current_org), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await _check_workspace_for_user(workspace_id, org, db) + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + grants = await device_service.list_grants(db, workspace_id=workspace_id, device_id=device_id) + return _ok([device_service.grant_summary(grant) for grant in grants]) + + +@router.post("/{workspace_id}/devices/{device_id}/grants") +async def create_device_grant( + workspace_id: str, + device_id: str, + body: AgentDeviceGrantCreate, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + actor_type, actor_id = _actor_from_context(user) + if actor_type == "user": + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + grant = await device_service.create_grant( + db, + workspace_id=workspace_id, + device_id=device_id, + subject_type=body.subject_type, + subject_id=body.subject_id, + scopes=body.scopes, + can_delegate=body.can_delegate, + parent_grant_id=body.parent_grant_id, + expires_at=body.expires_at, + granted_by_type=actor_type, + granted_by_id=actor_id, + org_id=_org_id(org), + ) + await db.commit() + await db.refresh(grant) + broadcast_event(workspace_id, "device:grant_created", device_service.grant_summary(grant)) + await _sync_device_genes(db, workspace_id, "grant_created") + return _ok(device_service.grant_summary(grant)) + + +@router.delete("/{workspace_id}/devices/{device_id}/grants/{grant_id}") +async def revoke_device_grant( + workspace_id: str, + device_id: str, + grant_id: str, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + actor_type, actor_id = _actor_from_context(user) + if actor_type == "user": + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + grant = await device_service.revoke_grant( + db, + workspace_id=workspace_id, + device_id=device_id, + grant_id=grant_id, + actor_type=actor_type, + actor_id=actor_id, + org_id=_org_id(org), + ) + await db.commit() + broadcast_event(workspace_id, "device:grant_revoked", {"device_id": device_id, "grant_id": grant.id}) + await _sync_device_genes(db, workspace_id, "grant_revoked") + return _ok(message="revoked") + + +@router.get("/{workspace_id}/reachable-devices") +async def get_reachable_devices( + workspace_id: str, + instance_id: str | None = Query(default=None), + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + agent_id = _current_agent_id() or instance_id + if not agent_id: + raise BadRequestError("查询可达办公设施时必须提供 instance_id", "errors.agent_device.agent_required") + return _ok(await device_service.reachable_devices(db, workspace_id=workspace_id, agent_id=agent_id)) + + +@router.get("/{workspace_id}/devices/{device_id}/visibility") +async def get_device_visibility( + workspace_id: str, + device_id: str, + instance_id: str | None = Query(default=None), + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + agent_id = _current_agent_id() or instance_id + return _ok(await device_service.device_visibility( + db, + workspace_id=workspace_id, + device_id=device_id, + agent_id=agent_id, + )) + + +@router.post("/{workspace_id}/devices/{device_id}/leases") +async def acquire_device_lease( + workspace_id: str, + device_id: str, + body: AgentDeviceLeaseAcquire, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + agent_id = _current_agent_id() + if not agent_id: + raise ForbiddenError("只有 Agent 可以获取办公设施租约", "errors.agent_device.agent_required") + lease = await device_service.acquire_lease( + db, + workspace_id=workspace_id, + device_id=device_id, + agent_id=agent_id, + ttl_seconds=body.ttl_seconds, + org_id=_org_id(org), + ) + await db.commit() + await db.refresh(lease) + broadcast_event(workspace_id, "device:lease_acquired", device_service.lease_summary(lease)) + return _ok(device_service.lease_summary(lease)) + + +@router.post("/{workspace_id}/devices/{device_id}/leases/{lease_id}/renew") +async def renew_device_lease( + workspace_id: str, + device_id: str, + lease_id: str, + body: AgentDeviceLeaseRenew, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + agent_id = _current_agent_id() + if not agent_id: + raise ForbiddenError("只有 Agent 可以续租办公设施", "errors.agent_device.agent_required") + lease = await device_service.renew_lease( + db, + workspace_id=workspace_id, + device_id=device_id, + lease_id=lease_id, + agent_id=agent_id, + ttl_seconds=body.ttl_seconds, + org_id=_org_id(org), + ) + await db.commit() + await db.refresh(lease) + broadcast_event(workspace_id, "device:lease_renewed", device_service.lease_summary(lease)) + return _ok(device_service.lease_summary(lease)) + + +async def _release_device_lease_for_agent( + workspace_id: str, + device_id: str, + lease_id: str, + org_ctx, + db: AsyncSession, +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + agent_id = _current_agent_id() + if not agent_id: + raise ForbiddenError("只有 Agent 可以释放办公设施租约", "errors.agent_device.agent_required") + lease = await device_service.release_lease( + db, + workspace_id=workspace_id, + device_id=device_id, + lease_id=lease_id, + agent_id=agent_id, + org_id=_org_id(org), + ) + await db.commit() + broadcast_event(workspace_id, "device:lease_released", device_service.lease_summary(lease)) + return _ok(device_service.lease_summary(lease)) + + +@router.post("/{workspace_id}/devices/{device_id}/leases/{lease_id}/release") +async def release_device_lease_post( + workspace_id: str, + device_id: str, + lease_id: str, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + return await _release_device_lease_for_agent(workspace_id, device_id, lease_id, org_ctx, db) + + +@router.delete("/{workspace_id}/devices/{device_id}/leases/{lease_id}") +async def release_device_lease( + workspace_id: str, + device_id: str, + lease_id: str, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + return await _release_device_lease_for_agent(workspace_id, device_id, lease_id, org_ctx, db) + + +@router.post("/{workspace_id}/devices/{device_id}/leases/{lease_id}/reclaim") +async def reclaim_device_lease( + workspace_id: str, + device_id: str, + lease_id: str, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + actor_type, actor_id = _actor_from_context(user) + if actor_type == "user": + await wm_service.check_workspace_access(workspace_id, user, "manage_devices", db) + lease = await device_service.reclaim_lease( + db, + workspace_id=workspace_id, + device_id=device_id, + lease_id=lease_id, + actor_type=actor_type, + actor_id=actor_id, + org_id=_org_id(org), + ) + await db.commit() + broadcast_event(workspace_id, "device:lease_reclaimed", device_service.lease_summary(lease)) + return _ok(device_service.lease_summary(lease)) + + +@router.post("/{workspace_id}/devices/{device_id}/invoke") +async def invoke_device( + workspace_id: str, + device_id: str, + body: AgentDeviceInvokeRequest, + org_ctx=Depends(get_current_org_or_agent), + db: AsyncSession = Depends(get_db), +): + user, org = org_ctx + await device_service.check_workspace(workspace_id, _org_id(org), db) + await require_workspace_actor_member(workspace_id, user, db) + agent_id = _current_agent_id() + if not agent_id: + raise ForbiddenError("只有 Agent 可以调用办公设施", "errors.agent_device.agent_required") + result = await device_service.invoke_device( + db, + workspace_id=workspace_id, + device_id=device_id, + agent_id=agent_id, + lease_id=body.lease_id, + action=body.action, + payload=body.payload, + org_id=_org_id(org), + ) + await db.commit() + return _ok(result) diff --git a/nodeskclaw-backend/app/api/corridors.py b/nodeskclaw-backend/app/api/corridors.py index d9e06a41..6af3a435 100644 --- a/nodeskclaw-backend/app/api/corridors.py +++ b/nodeskclaw-backend/app/api/corridors.py @@ -14,6 +14,7 @@ from app.models.base import not_deleted from app.models.corridor import CorridorHex, HexConnection, HumanHex, is_adjacent, ordered_pair from app.models.instance import Instance +from app.models.node_card import NodeCard from app.models.workspace import Workspace from app.models.workspace_agent import WorkspaceAgent from app.models.workspace_member import WorkspaceMember @@ -87,6 +88,16 @@ async def _check_workspace(workspace_id: str, org, db: AsyncSession) -> Workspac async def _is_hex_occupied(workspace_id: str, q: int, r: int, db: AsyncSession) -> bool: if (q, r) == (0, 0): return True + card_q = await db.execute( + select(NodeCard.id).where( + NodeCard.workspace_id == workspace_id, + NodeCard.hex_q == q, + NodeCard.hex_r == r, + not_deleted(NodeCard), + ).limit(1) + ) + if card_q.scalar_one_or_none(): + return True agent_result = await db.execute( select(WorkspaceAgent).where( WorkspaceAgent.workspace_id == workspace_id, diff --git a/nodeskclaw-backend/app/api/router.py b/nodeskclaw-backend/app/api/router.py index baebb09d..9ee747b8 100644 --- a/nodeskclaw-backend/app/api/router.py +++ b/nodeskclaw-backend/app/api/router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from app.api.audit import router as audit_router +from app.api.agent_devices import router as agent_device_router from app.api.auth import router as auth_router from app.api.genes import router as gene_router from app.api.clusters import router as cluster_router @@ -135,6 +136,7 @@ async def serve_local_file(file_key: str, expires: str = "", sig: str = ""): api_router.include_router(blackboard_router, prefix="/workspaces", tags=["黑板讨论区"]) api_router.include_router(conversation_router, prefix="/workspaces", tags=["群聊"]) api_router.include_router(corridor_router, prefix="/workspaces", tags=["过道系统"]) +api_router.include_router(agent_device_router, prefix="/workspaces", tags=["办公设施"]) api_router.include_router(observability_router, prefix="/workspaces", tags=["可观测性"]) api_router.include_router(trust_router, prefix="/workspaces", tags=["渐进式信任"]) api_router.include_router(instance_template_router, tags=["AI 员工模板"]) @@ -162,6 +164,7 @@ async def serve_local_file(file_key: str, expires: str = "", sig: str = ""): admin_router.include_router(blackboard_router, prefix="/workspaces", tags=["Admin - 黑板讨论区"]) admin_router.include_router(conversation_router, prefix="/workspaces", tags=["Admin - 群聊"]) admin_router.include_router(corridor_router, prefix="/workspaces", tags=["Admin - 过道系统"]) +admin_router.include_router(agent_device_router, prefix="/workspaces", tags=["Admin - 办公设施"]) admin_router.include_router(observability_router, prefix="/workspaces", tags=["Admin - 可观测性"]) admin_router.include_router(trust_router, prefix="/workspaces", tags=["Admin - 渐进式信任"]) admin_router.include_router(channel_config_router, prefix="/instances", tags=["Admin - Channel 配置"]) diff --git a/nodeskclaw-backend/app/api/workspaces.py b/nodeskclaw-backend/app/api/workspaces.py index 04fd35c1..d4216e7b 100644 --- a/nodeskclaw-backend/app/api/workspaces.py +++ b/nodeskclaw-backend/app/api/workspaces.py @@ -188,6 +188,13 @@ async def add_agent( except ValueError as e: raise _error(400, 40031, "errors.workspace.add_agent_invalid", str(e)) await hooks.emit("operation_audit", action="workspace.agent_added", target_type="workspace", target_id=workspace_id, actor_id=user.id, details={"instance_id": data.instance_id}) + try: + from app.services.agent_device_service import sync_workspace_device_genes + await sync_workspace_device_genes(db, workspace_id=workspace_id, reason="agent_added") + await db.commit() + except Exception as e: + logger.warning("办公设施 Gene 同步失败 workspace=%s instance=%s error=%s", workspace_id, data.instance_id, e) + await db.rollback() return _ok(agent.model_dump(mode="json")) @@ -267,6 +274,13 @@ async def update_agent( if agent is None: raise _error(404, 40431, "errors.workspace.agent_not_found", "AI 员工不存在") await hooks.emit("operation_audit", action="workspace.agent_updated", target_type="workspace", target_id=workspace_id, actor_id=user.id, details={"instance_id": instance_id}) + try: + from app.services.agent_device_service import sync_workspace_device_genes + await sync_workspace_device_genes(db, workspace_id=workspace_id, reason="agent_updated") + await db.commit() + except Exception as e: + logger.warning("办公设施 Gene 同步失败 workspace=%s instance=%s error=%s", workspace_id, instance_id, e) + await db.rollback() return _ok(agent.model_dump(mode="json")) @@ -282,6 +296,13 @@ async def remove_agent( if not ok: raise _error(404, 40432, "errors.workspace.agent_not_in_workspace", "AI 员工不在该办公室中") await hooks.emit("operation_audit", action="workspace.agent_removed", target_type="workspace", target_id=workspace_id, actor_id=user.id, details={"instance_id": instance_id}) + try: + from app.services.agent_device_service import sync_workspace_device_genes + await sync_workspace_device_genes(db, workspace_id=workspace_id, reason="agent_removed") + await db.commit() + except Exception as e: + logger.warning("办公设施 Gene 同步失败 workspace=%s instance=%s error=%s", workspace_id, instance_id, e) + await db.rollback() return _ok(message="已移除") diff --git a/nodeskclaw-backend/app/core/config.py b/nodeskclaw-backend/app/core/config.py index c53c9417..53f80ec3 100644 --- a/nodeskclaw-backend/app/core/config.py +++ b/nodeskclaw-backend/app/core/config.py @@ -144,6 +144,11 @@ def _qualify_llm_proxy_internal_url(self) -> "Settings": # ── Agent Tunnel(实例通过 WebSocket 主动连接后端的地址)──── TUNNEL_BASE_URL: str = "" + # ── Browser Pilot Agent Device(办公设施 provider,可选)──── + BPILOT_BASE_URL: str = "" + BPILOT_API_KEY: str = "" + BPILOT_TIMEOUT_SECONDS: int = 30 + # ── 出站代理(用于访问 OpenAI/Anthropic 等外部 API)──── HTTPS_PROXY: str = "" diff --git a/nodeskclaw-backend/app/data/gene_scripts/deskclaw_agent_device.py b/nodeskclaw-backend/app/data/gene_scripts/deskclaw_agent_device.py new file mode 100644 index 00000000..ad0889ec --- /dev/null +++ b/nodeskclaw-backend/app/data/gene_scripts/deskclaw_agent_device.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""DeskClaw Agent Device Tool -- discover, lease, and invoke governed Agent Devices.""" + +from __future__ import annotations + +import argparse +import json +import os +import urllib.parse + +from _api_client import _fatal, _output, api_call + + +def _json_arg(raw: str) -> dict: + if not raw: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + _fatal(f"Invalid JSON: {exc}") + if not isinstance(data, dict): + _fatal("JSON value must be an object") + return data + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="deskclaw_agent_device", description="DeskClaw Agent Device Tool") + sub = parser.add_subparsers(dest="action", required=True) + + reachable = sub.add_parser("list_reachable", help="List Agent Devices reachable by the current agent") + reachable.add_argument("--instance-id", default=os.environ.get("DESKCLAW_INSTANCE_ID", "")) + + visibility = sub.add_parser("visibility", help="Inspect one device visibility and unavailable reasons") + visibility.add_argument("--device-id", required=True) + visibility.add_argument("--instance-id", default=os.environ.get("DESKCLAW_INSTANCE_ID", "")) + + acquire = sub.add_parser("acquire_lease", help="Acquire an exclusive lease") + acquire.add_argument("--device-id", required=True) + acquire.add_argument("--ttl-seconds", type=int, default=None) + + renew = sub.add_parser("renew_lease", help="Renew an active lease") + renew.add_argument("--device-id", required=True) + renew.add_argument("--lease-id", required=True) + renew.add_argument("--ttl-seconds", type=int, default=None) + + release = sub.add_parser("release_lease", help="Release an active lease") + release.add_argument("--device-id", required=True) + release.add_argument("--lease-id", required=True) + + invoke = sub.add_parser("invoke", help="Invoke a device provider action") + invoke.add_argument("--device-id", required=True) + invoke.add_argument("--lease-id", required=True) + invoke.add_argument("--provider-action", required=True) + invoke.add_argument("--payload-json", default="{}") + + delegate = sub.add_parser("delegate", help="Delegate device scopes to another agent") + delegate.add_argument("--device-id", required=True) + delegate.add_argument("--agent-id", required=True) + delegate.add_argument("--scopes", required=True, help="Comma-separated scopes: discover,lease,invoke,delegate") + delegate.add_argument("--can-delegate", action="store_true") + delegate.add_argument("--parent-grant-id", default=None) + + return parser + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + + if args.action == "list_reachable": + qs = "" + if args.instance_id: + qs = "?instance_id=" + urllib.parse.quote(args.instance_id) + _output(api_call("GET", f"/reachable-devices{qs}")) + + elif args.action == "visibility": + qs = "" + if args.instance_id: + qs = "?instance_id=" + urllib.parse.quote(args.instance_id) + _output(api_call("GET", f"/devices/{args.device_id}/visibility{qs}")) + + elif args.action == "acquire_lease": + body = {} + if args.ttl_seconds: + body["ttl_seconds"] = args.ttl_seconds + _output(api_call("POST", f"/devices/{args.device_id}/leases", body)) + + elif args.action == "renew_lease": + body = {} + if args.ttl_seconds: + body["ttl_seconds"] = args.ttl_seconds + _output(api_call("POST", f"/devices/{args.device_id}/leases/{args.lease_id}/renew", body)) + + elif args.action == "release_lease": + _output(api_call("DELETE", f"/devices/{args.device_id}/leases/{args.lease_id}")) + + elif args.action == "invoke": + _output(api_call("POST", f"/devices/{args.device_id}/invoke", { + "lease_id": args.lease_id, + "action": args.provider_action, + "payload": _json_arg(args.payload_json), + })) + + elif args.action == "delegate": + scopes = [s.strip() for s in args.scopes.split(",") if s.strip()] + _output(api_call("POST", f"/devices/{args.device_id}/grants", { + "subject_type": "agent", + "subject_id": args.agent_id, + "scopes": scopes, + "can_delegate": args.can_delegate, + "parent_grant_id": args.parent_grant_id, + })) + + +if __name__ == "__main__": + main() diff --git a/nodeskclaw-backend/app/data/gene_templates/agent_device_browser_bpilot.json b/nodeskclaw-backend/app/data/gene_templates/agent_device_browser_bpilot.json new file mode 100644 index 00000000..bcfa52e4 --- /dev/null +++ b/nodeskclaw-backend/app/data/gene_templates/agent_device_browser_bpilot.json @@ -0,0 +1,22 @@ +{ + "name": "agent-device-browser-bpilot", + "slug": "agent-device-browser-bpilot", + "description": "Operation guide for using Browser Pilot Agent Devices through the governed Agent Device API.", + "category": "tools", + "tags": ["tools", "agent-device", "browser", "bpilot"], + "version": "1.0.0", + "manifest": { + "skill": { + "name": "agent-device-browser-bpilot", + "content": "---\nname: agent-device-browser-bpilot\ndescription: Use Browser Pilot Agent Devices only through governed Agent Device APIs\nalways: true\n---\n\n## Browser Pilot Agent Device\n\nBrowser Pilot is exposed as an Agent Device placed in the office topology.\n\nUse the `deskclaw_agent_device.py` script to discover reachable devices, inspect visibility, acquire leases, invoke provider actions, and release leases.\n\n```bash\npython3 ~/.deskclaw/tools/deskclaw_agent_device.py list_reachable\npython3 ~/.deskclaw/tools/deskclaw_agent_device.py visibility --device-id \npython3 ~/.deskclaw/tools/deskclaw_agent_device.py acquire_lease --device-id \npython3 ~/.deskclaw/tools/deskclaw_agent_device.py invoke --device-id --lease-id --provider-action page.goto --payload-json '{\"url\":\"https://example.com\"}'\npython3 ~/.deskclaw/tools/deskclaw_agent_device.py release_lease --device-id --lease-id \n```\n\n### Rules\n- Discover devices through the office Agent Device visibility and reachable-device APIs.\n- Treat device identity, status, permission, topology reachability, grants, leases, and audit as controller-owned state.\n- Do not invent a device or assume access from this skill alone.\n- Acquire an exclusive lease before invoking the Browser Pilot provider.\n- Release the lease after the task is complete.\n\n### Capability Shape\n- Preset: `browser.bpilot.session`\n- Provider: `browser.bpilot`\n- Lease mode: exclusive\n- Typical actions: `session.create`, `session.use`, `page.goto`, `page.observe`, `page.click`, `page.type`\n\n### Provider Constraint\nInvoke only through the Agent Device invoke endpoint with a valid lease. If the provider is `provider_unconfigured`, report that Browser Pilot is not configured instead of retrying unrelated tools." + }, + "tool_allow": ["nodeskclaw_agent_device"], + "scripts": ["deskclaw_agent_device.py"], + "agent_device": { + "preset_id": "browser.bpilot.session", + "provider_id": "browser.bpilot", + "lease_mode": "exclusive", + "capabilities": ["session.create", "session.use", "page.goto", "page.observe", "page.click", "page.type"] + } + } +} diff --git a/nodeskclaw-backend/app/main.py b/nodeskclaw-backend/app/main.py index b7e3bdab..67874eb6 100644 --- a/nodeskclaw-backend/app/main.py +++ b/nodeskclaw-backend/app/main.py @@ -354,6 +354,7 @@ def _run(): "mcp_performance_reader.json", "mcp_topology_awareness.json", "mcp_shared_files.json", + "agent_device_browser_bpilot.json", "meta_gene_ai_hc.json", "meta_gene_reorg.json", "meta_gene_culture.json", @@ -933,6 +934,10 @@ async def _send(message: Message) -> None: from app.services.audit_handler import register_ce_audit_handler register_ce_audit_handler() +from app.services.agent_device_gene_sync_service import register_agent_device_gene_sync_hooks # noqa: E402 + +register_agent_device_gene_sync_hooks() + # ── Static files (前端 build 产物) ─────────────────── # 生产环境:Vite build 后的 dist 目录会被复制到 static/ static_dir = os.path.join(os.path.dirname(__file__), "..", "static") diff --git a/nodeskclaw-backend/app/models/__init__.py b/nodeskclaw-backend/app/models/__init__.py index e56aab7c..67d22a7e 100644 --- a/nodeskclaw-backend/app/models/__init__.py +++ b/nodeskclaw-backend/app/models/__init__.py @@ -1,6 +1,13 @@ """Import all models so SQLAlchemy can detect them.""" from app.models.admin_membership import AdminMembership # noqa: F401 +from app.models.agent_device import ( # noqa: F401 + AgentDeviceGeneBinding, + AgentDeviceGrant, + AgentDeviceInstance, + AgentDeviceLease, + AgentDevicePresetEnablement, +) from app.models.backup import InstanceBackup # noqa: F401 from app.models.base import Base, BaseModel # noqa: F401 from app.models.blackboard import Blackboard # noqa: F401 diff --git a/nodeskclaw-backend/app/models/agent_device.py b/nodeskclaw-backend/app/models/agent_device.py new file mode 100644 index 00000000..ebf915be --- /dev/null +++ b/nodeskclaw-backend/app/models/agent_device.py @@ -0,0 +1,162 @@ +"""Agent Device models — governable Agent Devices placed on workspace topology.""" + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import BaseModel + + +class AgentDevicePresetEnablement(BaseModel): + __tablename__ = "agent_device_preset_enablements" + __table_args__ = ( + Index( + "uq_agent_device_preset_enablement", + "workspace_id", + "preset_id", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + workspace_id: Mapped[str] = mapped_column( + String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + preset_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False) + config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + + workspace = relationship("Workspace") + + +class AgentDeviceInstance(BaseModel): + __tablename__ = "agent_device_instances" + __table_args__ = ( + Index( + "uq_agent_device_instance_hex", + "workspace_id", + "hex_q", + "hex_r", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + workspace_id: Mapped[str] = mapped_column( + String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + preset_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + provider_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + display_name: Mapped[str] = mapped_column(String(128), nullable=False) + hex_q: Mapped[int] = mapped_column(Integer, nullable=False) + hex_r: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(32), default="available", server_default="available", nullable=False) + status_reason: Mapped[str | None] = mapped_column(String(128), nullable=True) + config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + + workspace = relationship("Workspace") + + +class AgentDeviceGrant(BaseModel): + __tablename__ = "agent_device_grants" + __table_args__ = ( + Index("ix_agent_device_grants_subject", "workspace_id", "subject_type", "subject_id"), + Index("ix_agent_device_grants_parent", "parent_grant_id"), + ) + + workspace_id: Mapped[str] = mapped_column( + String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + device_id: Mapped[str] = mapped_column( + String(36), ForeignKey("agent_device_instances.id", ondelete="CASCADE"), nullable=False, index=True + ) + subject_type: Mapped[str] = mapped_column(String(16), nullable=False) + subject_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + scopes: Mapped[list] = mapped_column( + JSONB, + default=list, + server_default=text("'[]'::jsonb"), + nullable=False, + ) + can_delegate: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) + parent_grant_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("agent_device_grants.id"), nullable=True + ) + granted_by_type: Mapped[str] = mapped_column(String(16), nullable=False) + granted_by_id: Mapped[str] = mapped_column(String(36), nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) + + device = relationship("AgentDeviceInstance") + + +class AgentDeviceLease(BaseModel): + __tablename__ = "agent_device_leases" + __table_args__ = ( + Index( + "uq_agent_device_active_lease", + "device_id", + unique=True, + postgresql_where=text("deleted_at IS NULL AND status = 'active'"), + ), + Index("ix_agent_device_leases_holder", "workspace_id", "holder_agent_id"), + ) + + workspace_id: Mapped[str] = mapped_column( + String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + device_id: Mapped[str] = mapped_column( + String(36), ForeignKey("agent_device_instances.id", ondelete="CASCADE"), nullable=False, index=True + ) + holder_agent_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + grant_id: Mapped[str] = mapped_column( + String(36), ForeignKey("agent_device_grants.id"), nullable=False, index=True + ) + status: Mapped[str] = mapped_column(String(16), default="active", server_default="active", nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + renewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) + + device = relationship("AgentDeviceInstance") + grant = relationship("AgentDeviceGrant") + + +class AgentDeviceGeneBinding(BaseModel): + __tablename__ = "agent_device_gene_bindings" + __table_args__ = ( + Index( + "uq_agent_device_gene_binding", + "workspace_id", + "device_id", + "instance_id", + "gene_slug", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + workspace_id: Mapped[str] = mapped_column( + String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + device_id: Mapped[str] = mapped_column( + String(36), ForeignKey("agent_device_instances.id", ondelete="CASCADE"), nullable=False, index=True + ) + instance_id: Mapped[str] = mapped_column( + String(36), ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True + ) + gene_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("genes.id"), nullable=True) + gene_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + instance_gene_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("instance_genes.id"), nullable=True) + was_preexisting: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) + sync_reason: Mapped[str | None] = mapped_column(String(64), nullable=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) + + device = relationship("AgentDeviceInstance") diff --git a/nodeskclaw-backend/app/models/workspace_member.py b/nodeskclaw-backend/app/models/workspace_member.py index 39743697..e45525e2 100644 --- a/nodeskclaw-backend/app/models/workspace_member.py +++ b/nodeskclaw-backend/app/models/workspace_member.py @@ -12,6 +12,7 @@ WORKSPACE_PERMISSIONS: list[str] = [ "manage_settings", "manage_agents", + "manage_devices", "manage_members", "edit_blackboard", "send_chat", @@ -20,7 +21,7 @@ ] PERMISSION_PRESETS: dict[str, list[str]] = { - "collaborator": ["manage_agents", "edit_blackboard", "send_chat", "edit_topology"], + "collaborator": ["manage_agents", "manage_devices", "edit_blackboard", "send_chat", "edit_topology"], "observer": ["send_chat"], } diff --git a/nodeskclaw-backend/app/schemas/agent_device.py b/nodeskclaw-backend/app/schemas/agent_device.py new file mode 100644 index 00000000..72a00c1a --- /dev/null +++ b/nodeskclaw-backend/app/schemas/agent_device.py @@ -0,0 +1,134 @@ +"""Pydantic schemas for Agent Device API.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class AgentDevicePresetEnablementUpdate(BaseModel): + enabled: bool = True + config: dict[str, Any] | None = None + + +class AgentDevicePresetInfo(BaseModel): + preset_id: str + provider_id: str + display_name: str + description: str + gene_slug: str + capability_schema: dict[str, Any] + enabled: bool + config: dict[str, Any] | None = None + provider_status: str + provider_status_reason: str | None = None + + +class AgentDeviceCreate(BaseModel): + preset_id: str = "browser.bpilot.session" + display_name: str = Field(min_length=1, max_length=128) + hex_q: int + hex_r: int + config: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + + +class AgentDeviceUpdate(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=128) + hex_q: int | None = None + hex_r: int | None = None + config: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + + +class AgentDeviceInfo(BaseModel): + id: str + workspace_id: str + preset_id: str + provider_id: str + display_name: str + hex_q: int + hex_r: int + status: str + status_reason: str | None = None + config: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + created_by: str | None = None + created_at: datetime + updated_at: datetime + + +class AgentDeviceGrantCreate(BaseModel): + subject_type: str + subject_id: str + scopes: list[str] + can_delegate: bool = False + parent_grant_id: str | None = None + expires_at: datetime | None = None + + +class AgentDeviceGrantInfo(BaseModel): + id: str + workspace_id: str + device_id: str + subject_type: str + subject_id: str + scopes: list[str] + can_delegate: bool + parent_grant_id: str | None = None + granted_by_type: str + granted_by_id: str + expires_at: datetime | None = None + revoked_at: datetime | None = None + created_at: datetime + + +class AgentDeviceLeaseAcquire(BaseModel): + ttl_seconds: int | None = None + + +class AgentDeviceLeaseRenew(BaseModel): + ttl_seconds: int | None = None + + +class AgentDeviceLeaseInfo(BaseModel): + id: str + workspace_id: str + device_id: str + holder_agent_id: str + grant_id: str + status: str + expires_at: datetime + renewed_at: datetime | None = None + released_at: datetime | None = None + created_at: datetime + + +class AgentDeviceVisibilityInfo(BaseModel): + device_id: str + visible: bool + reasons: list[str] + status: str + status_reason: str | None = None + preset_id: str + provider_id: str + display_name: str + hex_q: int + hex_r: int + grant_id: str | None = None + topology_reachable: bool + reachability_source: str | None = None + topology_path_ref: str | None = None + topology_reason: str + active_lease: dict[str, Any] | None = None + + +class AgentDeviceInvokeRequest(BaseModel): + lease_id: str + action: str = Field(min_length=1, max_length=128) + payload: dict[str, Any] = Field(default_factory=dict) + + +class AgentDeviceInvokeResponse(BaseModel): + status: str + result: dict[str, Any] diff --git a/nodeskclaw-backend/app/services/agent_device_gene_sync_service.py b/nodeskclaw-backend/app/services/agent_device_gene_sync_service.py new file mode 100644 index 00000000..41282adc --- /dev/null +++ b/nodeskclaw-backend/app/services/agent_device_gene_sync_service.py @@ -0,0 +1,27 @@ +"""Hook registration for Agent Device Gene synchronization.""" + +from __future__ import annotations + +import logging + +from app.core import hooks +from app.core.deps import async_session_factory +from app.services.agent_device_service import sync_workspace_device_genes + +logger = logging.getLogger(__name__) + + +async def _on_topology_change(*, workspace_id: str, action: str = "topology_change", **_kwargs) -> None: + if not workspace_id: + return + try: + async with async_session_factory() as db: + await sync_workspace_device_genes(db, workspace_id=workspace_id, reason=action) + await db.commit() + except Exception as exc: + logger.warning("Agent Device Gene 拓扑同步失败 workspace=%s action=%s err=%s", workspace_id, action, exc) + + +def register_agent_device_gene_sync_hooks() -> None: + hooks.register("topology_change", _on_topology_change) + logger.info("Agent Device Gene 同步 hook 已注册") diff --git a/nodeskclaw-backend/app/services/agent_device_provider.py b/nodeskclaw-backend/app/services/agent_device_provider.py new file mode 100644 index 00000000..bd12fc89 --- /dev/null +++ b/nodeskclaw-backend/app/services/agent_device_provider.py @@ -0,0 +1,126 @@ +"""Provider adapters for Agent Device invocation.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + +import httpx + +from app.core.config import settings +from app.core.exceptions import BadRequestError +from app.models.agent_device import AgentDeviceInstance, AgentDeviceLease + + +@dataclass(frozen=True) +class AgentDevicePreset: + preset_id: str + provider_id: str + display_name: str + description: str + gene_slug: str + capability_schema: dict[str, Any] = field(default_factory=dict) + default_config: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ProviderStatus: + status: str + reason: str | None = None + + +class AgentDeviceProvider(Protocol): + provider_id: str + + def status(self) -> ProviderStatus: + ... + + async def invoke( + self, + *, + device: AgentDeviceInstance, + actor_agent_id: str, + lease: AgentDeviceLease, + action: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + ... + + +class BpilotProvider: + provider_id = "browser.bpilot" + + def status(self) -> ProviderStatus: + if not settings.BPILOT_BASE_URL: + return ProviderStatus(status="provider_unconfigured", reason="bpilot_base_url_missing") + return ProviderStatus(status="available") + + async def invoke( + self, + *, + device: AgentDeviceInstance, + actor_agent_id: str, + lease: AgentDeviceLease, + action: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + provider_status = self.status() + if provider_status.status != "available": + raise BadRequestError( + "Browser Pilot provider 未配置,请先设置 BPILOT_BASE_URL", + "errors.agent_device.provider_unconfigured", + ) + + headers: dict[str, str] = {} + if settings.BPILOT_API_KEY: + headers["Authorization"] = f"Bearer {settings.BPILOT_API_KEY}" + + body = { + "device_id": device.id, + "workspace_id": device.workspace_id, + "actor_agent_id": actor_agent_id, + "lease_id": lease.id, + "action": action, + "payload": payload, + "config": device.config or {}, + } + url = f"{settings.BPILOT_BASE_URL.rstrip('/')}/agent-devices/invoke" + timeout = httpx.Timeout(settings.BPILOT_TIMEOUT_SECONDS, connect=10) + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: + response = await client.post(url, json=body) + response.raise_for_status() + data = response.json() + return data if isinstance(data, dict) else {"result": data} + + +BUILTIN_AGENT_DEVICE_PRESETS: dict[str, AgentDevicePreset] = { + "browser.bpilot.session": AgentDevicePreset( + preset_id="browser.bpilot.session", + provider_id="browser.bpilot", + display_name="Browser Pilot", + description="Browser Pilot controlled browser session exposed as an Agent Device.", + gene_slug="agent-device-browser-bpilot", + capability_schema={ + "actions": ["session.create", "session.use", "page.goto", "page.observe", "page.click", "page.type"], + "lease_mode": "exclusive", + }, + default_config={}, + ), +} + + +PROVIDERS: dict[str, AgentDeviceProvider] = { + BpilotProvider.provider_id: BpilotProvider(), +} + + +def get_agent_device_preset(preset_id: str) -> AgentDevicePreset | None: + return BUILTIN_AGENT_DEVICE_PRESETS.get(preset_id) + + +def list_agent_device_presets() -> list[AgentDevicePreset]: + return list(BUILTIN_AGENT_DEVICE_PRESETS.values()) + + +def get_agent_device_provider(provider_id: str) -> AgentDeviceProvider | None: + return PROVIDERS.get(provider_id) diff --git a/nodeskclaw-backend/app/services/agent_device_service.py b/nodeskclaw-backend/app/services/agent_device_service.py new file mode 100644 index 00000000..7ed39dad --- /dev/null +++ b/nodeskclaw-backend/app/services/agent_device_service.py @@ -0,0 +1,1295 @@ +"""Agent Device service — placement, discovery, grants, leases, invocation and gene sync.""" + +from __future__ import annotations + +import logging +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import hooks +from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError +from app.models.agent_device import ( + AgentDeviceGeneBinding, + AgentDeviceGrant, + AgentDeviceInstance, + AgentDeviceLease, + AgentDevicePresetEnablement, +) +from app.models.base import not_deleted +from app.models.corridor import CorridorHex, HexConnection, HumanHex +from app.models.gene import Gene, InstanceGene, InstanceGeneStatus +from app.models.instance import Instance +from app.models.node_card import NodeCard +from app.models.workspace import Workspace +from app.models.workspace_agent import WorkspaceAgent +from app.services import corridor_router +from app.services.agent_device_provider import ( + get_agent_device_preset, + get_agent_device_provider, + list_agent_device_presets, +) +from app.services.runtime import node_card as node_card_service + +logger = logging.getLogger(__name__) + +DEVICE_GRANT_SCOPES = {"discover", "lease", "invoke", "delegate"} +DEVICE_SUBJECT_TYPES = {"agent", "human"} +DEFAULT_DELEGATE_TTL_MINUTES = 30 +DEFAULT_LEASE_TTL_SECONDS = 900 + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _scope_list(scopes: list[str]) -> list[str]: + normalized = sorted(set(scopes)) + invalid = [scope for scope in normalized if scope not in DEVICE_GRANT_SCOPES] + if invalid: + raise BadRequestError( + f"不支持的设备授权 scope: {', '.join(invalid)}", + "errors.agent_device.invalid_scope", + ) + if not normalized: + raise BadRequestError("授权 scope 不能为空", "errors.agent_device.scope_required") + return normalized + + +def _status_for_device(device: AgentDeviceInstance) -> tuple[str, str | None]: + provider = get_agent_device_provider(device.provider_id) + if provider is None: + return "provider_unconfigured", "provider_missing" + status = provider.status() + return status.status, status.reason + + +async def check_workspace(workspace_id: str, org_id: str | None, db: AsyncSession) -> Workspace: + stmt = select(Workspace).where(Workspace.id == workspace_id, not_deleted(Workspace)) + if org_id: + stmt = stmt.where(Workspace.org_id == org_id) + workspace = (await db.execute(stmt)).scalar_one_or_none() + if not workspace: + raise NotFoundError("办公室不存在", "errors.workspace.not_found") + return workspace + + +async def is_hex_occupied( + db: AsyncSession, + *, + workspace_id: str, + hex_q: int, + hex_r: int, + exclude_node_id: str | None = None, +) -> bool: + if (hex_q, hex_r) == (0, 0): + return True + + card = await db.execute( + select(NodeCard.node_id).where( + NodeCard.workspace_id == workspace_id, + NodeCard.hex_q == hex_q, + NodeCard.hex_r == hex_r, + not_deleted(NodeCard), + ).limit(1) + ) + card_node_id = card.scalar_one_or_none() + if card_node_id and card_node_id != exclude_node_id: + return True + + legacy_checks = ( + select(WorkspaceAgent.instance_id).where( + WorkspaceAgent.workspace_id == workspace_id, + WorkspaceAgent.hex_q == hex_q, + WorkspaceAgent.hex_r == hex_r, + not_deleted(WorkspaceAgent), + ), + select(CorridorHex.id).where( + CorridorHex.workspace_id == workspace_id, + CorridorHex.hex_q == hex_q, + CorridorHex.hex_r == hex_r, + not_deleted(CorridorHex), + ), + select(HumanHex.id).where( + HumanHex.workspace_id == workspace_id, + HumanHex.hex_q == hex_q, + HumanHex.hex_r == hex_r, + not_deleted(HumanHex), + ), + select(AgentDeviceInstance.id).where( + AgentDeviceInstance.workspace_id == workspace_id, + AgentDeviceInstance.hex_q == hex_q, + AgentDeviceInstance.hex_r == hex_r, + not_deleted(AgentDeviceInstance), + ), + ) + for stmt in legacy_checks: + found = (await db.execute(stmt.limit(1))).scalar_one_or_none() + if found and found != exclude_node_id: + return True + return False + + +async def get_preset_enablement( + db: AsyncSession, + *, + workspace_id: str, + preset_id: str, +) -> AgentDevicePresetEnablement | None: + return (await db.execute( + select(AgentDevicePresetEnablement).where( + AgentDevicePresetEnablement.workspace_id == workspace_id, + AgentDevicePresetEnablement.preset_id == preset_id, + not_deleted(AgentDevicePresetEnablement), + ) + )).scalar_one_or_none() + + +async def get_preset_info(db: AsyncSession, *, workspace_id: str, preset_id: str) -> dict: + preset = get_agent_device_preset(preset_id) + if preset is None: + raise NotFoundError("办公设施预设不存在", "errors.agent_device.preset_not_found") + + enablement = await get_preset_enablement(db, workspace_id=workspace_id, preset_id=preset_id) + enabled = True if enablement is None else enablement.enabled + provider = get_agent_device_provider(preset.provider_id) + provider_status = provider.status() if provider else None + return { + "preset_id": preset.preset_id, + "provider_id": preset.provider_id, + "display_name": preset.display_name, + "description": preset.description, + "gene_slug": preset.gene_slug, + "capability_schema": preset.capability_schema, + "enabled": enabled, + "config": enablement.config if enablement else preset.default_config, + "provider_status": provider_status.status if provider_status else "provider_unconfigured", + "provider_status_reason": provider_status.reason if provider_status else "provider_missing", + } + + +async def list_preset_infos(db: AsyncSession, *, workspace_id: str) -> list[dict]: + return [await get_preset_info(db, workspace_id=workspace_id, preset_id=p.preset_id) for p in list_agent_device_presets()] + + +async def set_preset_enablement( + db: AsyncSession, + *, + workspace_id: str, + preset_id: str, + enabled: bool, + config: dict | None, + actor_id: str | None, + org_id: str | None, +) -> dict: + preset = get_agent_device_preset(preset_id) + if preset is None: + raise NotFoundError("办公设施预设不存在", "errors.agent_device.preset_not_found") + row = await get_preset_enablement(db, workspace_id=workspace_id, preset_id=preset_id) + if row is None: + row = AgentDevicePresetEnablement( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + preset_id=preset_id, + enabled=enabled, + config=config, + created_by=actor_id, + updated_by=actor_id, + ) + db.add(row) + else: + row.enabled = enabled + row.config = config + row.updated_by = actor_id + await db.flush() + await hooks.emit( + "operation_audit", + action="agent_device.preset_enabled" if enabled else "agent_device.preset_disabled", + target_type="agent_device_preset", + target_id=preset_id, + actor_id=actor_id or "", + actor_type="user", + org_id=org_id, + workspace_id=workspace_id, + details={"preset_id": preset_id, "enabled": enabled}, + ) + return await get_preset_info(db, workspace_id=workspace_id, preset_id=preset_id) + + +async def ensure_preset_available(db: AsyncSession, *, workspace_id: str, preset_id: str) -> str: + info = await get_preset_info(db, workspace_id=workspace_id, preset_id=preset_id) + if not info["enabled"]: + raise ForbiddenError("该办公设施预设已停用", "errors.agent_device.preset_disabled") + return info["provider_id"] + + +async def get_device(db: AsyncSession, *, workspace_id: str, device_id: str) -> AgentDeviceInstance: + device = (await db.execute( + select(AgentDeviceInstance).where( + AgentDeviceInstance.id == device_id, + AgentDeviceInstance.workspace_id == workspace_id, + not_deleted(AgentDeviceInstance), + ) + )).scalar_one_or_none() + if device is None: + raise NotFoundError("办公设施不存在", "errors.agent_device.not_found") + return device + + +async def list_devices(db: AsyncSession, *, workspace_id: str) -> list[AgentDeviceInstance]: + result = await db.execute( + select(AgentDeviceInstance).where( + AgentDeviceInstance.workspace_id == workspace_id, + not_deleted(AgentDeviceInstance), + ).order_by(AgentDeviceInstance.created_at.desc()) + ) + devices = list(result.scalars().all()) + for device in devices: + device.status, device.status_reason = _status_for_device(device) + return devices + + +async def create_device( + db: AsyncSession, + *, + workspace_id: str, + preset_id: str, + display_name: str, + hex_q: int, + hex_r: int, + config: dict | None, + metadata: dict | None, + actor_id: str | None, + org_id: str | None, +) -> AgentDeviceInstance: + provider_id = await ensure_preset_available(db, workspace_id=workspace_id, preset_id=preset_id) + if await is_hex_occupied(db, workspace_id=workspace_id, hex_q=hex_q, hex_r=hex_r): + raise ConflictError("当前位置已被占用", "errors.corridor.hex_position_occupied") + + device = AgentDeviceInstance( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + preset_id=preset_id, + provider_id=provider_id, + display_name=display_name, + hex_q=hex_q, + hex_r=hex_r, + config=config, + metadata_=metadata, + created_by=actor_id, + ) + device.status, device.status_reason = _status_for_device(device) + db.add(device) + await node_card_service.create_node_card( + db, + node_type="device", + node_id=device.id, + workspace_id=workspace_id, + hex_q=hex_q, + hex_r=hex_r, + name=display_name, + status=device.status, + metadata={ + "preset_id": preset_id, + "provider_id": provider_id, + "status_reason": device.status_reason, + "protocol_name": "Agent Device", + }, + ) + await corridor_router.auto_connect_hex(workspace_id, hex_q, hex_r, actor_id, db) + await hooks.emit( + "operation_audit", + action="agent_device.created", + target_type="agent_device", + target_id=device.id, + actor_id=actor_id or "", + actor_type="user", + org_id=org_id, + workspace_id=workspace_id, + details={"preset_id": preset_id, "provider_id": provider_id, "hex_q": hex_q, "hex_r": hex_r}, + ) + return device + + +async def update_device( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + display_name: str | None = None, + hex_q: int | None = None, + hex_r: int | None = None, + config: dict | None = None, + metadata: dict | None = None, + actor_id: str | None = None, + org_id: str | None = None, +) -> tuple[AgentDeviceInstance, bool]: + device = await get_device(db, workspace_id=workspace_id, device_id=device_id) + old_q, old_r = device.hex_q, device.hex_r + new_q = device.hex_q if hex_q is None else hex_q + new_r = device.hex_r if hex_r is None else hex_r + position_changed = (new_q, new_r) != (old_q, old_r) + if position_changed and await is_hex_occupied( + db, workspace_id=workspace_id, hex_q=new_q, hex_r=new_r, exclude_node_id=device.id + ): + raise ConflictError("当前位置已被占用", "errors.corridor.hex_position_occupied") + + if display_name is not None: + device.display_name = display_name + if position_changed: + device.hex_q = new_q + device.hex_r = new_r + if config is not None: + device.config = config + if metadata is not None: + device.metadata_ = metadata + device.status, device.status_reason = _status_for_device(device) + + card = await node_card_service.get_node_card(db, node_id=device.id, workspace_id=workspace_id) + if card: + card_meta = card.metadata_ or {} + card_meta.update({ + "preset_id": device.preset_id, + "provider_id": device.provider_id, + "status_reason": device.status_reason, + "protocol_name": "Agent Device", + }) + updates: dict[str, Any] = {"status": device.status, "metadata": card_meta} + if display_name is not None: + updates["name"] = device.display_name + if position_changed: + updates["hex_q"] = device.hex_q + updates["hex_r"] = device.hex_r + await node_card_service.update_node_card(db, card, **updates) + + if position_changed: + await corridor_router.cascade_delete_connections(workspace_id, old_q, old_r, db) + await corridor_router.auto_connect_hex(workspace_id, device.hex_q, device.hex_r, actor_id, db) + + await hooks.emit( + "operation_audit", + action="agent_device.updated", + target_type="agent_device", + target_id=device.id, + actor_id=actor_id or "", + actor_type="user", + org_id=org_id, + workspace_id=workspace_id, + details={"position_changed": position_changed, "hex_q": device.hex_q, "hex_r": device.hex_r}, + ) + return device, position_changed + + +async def delete_device( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + actor_id: str | None, + org_id: str | None, +) -> AgentDeviceInstance: + device = await get_device(db, workspace_id=workspace_id, device_id=device_id) + active_leases = await db.execute( + select(AgentDeviceLease).where( + AgentDeviceLease.device_id == device_id, + AgentDeviceLease.status == "active", + not_deleted(AgentDeviceLease), + ) + ) + for lease in active_leases.scalars().all(): + lease.status = "reclaimed" + lease.released_at = utc_now() + + grants = await db.execute( + select(AgentDeviceGrant).where( + AgentDeviceGrant.device_id == device_id, + not_deleted(AgentDeviceGrant), + ) + ) + for grant in grants.scalars().all(): + grant.revoked_at = utc_now() + grant.soft_delete() + + bindings = await db.execute( + select(AgentDeviceGeneBinding).where( + AgentDeviceGeneBinding.device_id == device_id, + not_deleted(AgentDeviceGeneBinding), + ) + ) + for binding in bindings.scalars().all(): + instance = await db.get(Instance, binding.instance_id) + if instance is None: + binding.sync_reason = "device_deleted" + binding.soft_delete() + continue + await withdraw_gene_binding( + db, + workspace_id=workspace_id, + device=device, + instance=instance, + gene_slug=binding.gene_slug, + reason="device_deleted", + ) + + conns = await db.execute( + select(HexConnection).where( + HexConnection.workspace_id == workspace_id, + not_deleted(HexConnection), + or_( + and_(HexConnection.hex_a_q == device.hex_q, HexConnection.hex_a_r == device.hex_r), + and_(HexConnection.hex_b_q == device.hex_q, HexConnection.hex_b_r == device.hex_r), + ), + ) + ) + for conn in conns.scalars().all(): + conn.soft_delete() + + await node_card_service.soft_delete_node_card(db, node_id=device_id, workspace_id=workspace_id) + device.soft_delete() + await hooks.emit( + "operation_audit", + action="agent_device.deleted", + target_type="agent_device", + target_id=device.id, + actor_id=actor_id or "", + actor_type="user", + org_id=org_id, + workspace_id=workspace_id, + details={"preset_id": device.preset_id}, + ) + return device + + +async def _valid_grant_query( + *, + workspace_id: str, + device_id: str, + subject_type: str, + subject_id: str, +): + now = utc_now() + return select(AgentDeviceGrant).where( + AgentDeviceGrant.workspace_id == workspace_id, + AgentDeviceGrant.device_id == device_id, + AgentDeviceGrant.subject_type == subject_type, + AgentDeviceGrant.subject_id == subject_id, + AgentDeviceGrant.revoked_at.is_(None), + or_(AgentDeviceGrant.expires_at.is_(None), AgentDeviceGrant.expires_at > now), + not_deleted(AgentDeviceGrant), + ) + + +async def find_valid_grant( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + subject_type: str, + subject_id: str, + required_scope: str, +) -> AgentDeviceGrant | None: + stmt = await _valid_grant_query( + workspace_id=workspace_id, + device_id=device_id, + subject_type=subject_type, + subject_id=subject_id, + ) + result = await db.execute(stmt) + for grant in result.scalars().all(): + if required_scope in (grant.scopes or []): + return grant + return None + + +async def create_grant( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + subject_type: str, + subject_id: str, + scopes: list[str], + can_delegate: bool, + parent_grant_id: str | None, + expires_at: datetime | None, + granted_by_type: str, + granted_by_id: str, + org_id: str | None, +) -> AgentDeviceGrant: + await get_device(db, workspace_id=workspace_id, device_id=device_id) + if subject_type not in DEVICE_SUBJECT_TYPES: + raise BadRequestError("授权对象类型必须是 agent 或 human", "errors.agent_device.invalid_subject_type") + normalized_scopes = _scope_list(scopes) + if granted_by_type == "agent" and subject_type != "agent": + raise BadRequestError("Agent 只能委托办公设施权限给其他 Agent", "errors.agent_device.delegate_subject_required") + + parent: AgentDeviceGrant | None = None + if granted_by_type == "agent": + parent_stmt = await _valid_grant_query( + workspace_id=workspace_id, + device_id=device_id, + subject_type="agent", + subject_id=granted_by_id, + ) + if parent_grant_id: + parent_stmt = parent_stmt.where(AgentDeviceGrant.id == parent_grant_id) + parent_result = await db.execute(parent_stmt) + for candidate in parent_result.scalars().all(): + parent_scopes = set(candidate.scopes or []) + if ( + candidate.can_delegate + and "delegate" in parent_scopes + and set(normalized_scopes).issubset(parent_scopes) + ): + parent = candidate + break + if parent is None: + raise ForbiddenError("当前 Agent 没有委托该办公设施权限", "errors.agent_device.delegate_forbidden") + parent_grant_id = parent.id + parent_expiry = parent.expires_at + if expires_at is None: + expires_at = utc_now() + timedelta(minutes=DEFAULT_DELEGATE_TTL_MINUTES) + if parent_expiry is not None and expires_at > parent_expiry: + expires_at = parent_expiry + can_delegate = can_delegate and "delegate" in normalized_scopes + elif parent_grant_id: + parent = await db.get(AgentDeviceGrant, parent_grant_id) + if parent is None or parent.device_id != device_id: + raise BadRequestError("父授权不存在", "errors.agent_device.parent_grant_not_found") + + grant = AgentDeviceGrant( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + device_id=device_id, + subject_type=subject_type, + subject_id=subject_id, + scopes=normalized_scopes, + can_delegate=can_delegate, + parent_grant_id=parent_grant_id, + granted_by_type=granted_by_type, + granted_by_id=granted_by_id, + expires_at=expires_at, + ) + db.add(grant) + await hooks.emit( + "operation_audit", + action="agent_device.grant_created", + target_type="agent_device", + target_id=device_id, + actor_id=granted_by_id, + actor_type=granted_by_type, + org_id=org_id, + workspace_id=workspace_id, + details={ + "grant_id": grant.id, + "subject_type": subject_type, + "subject_id": subject_id, + "scopes": normalized_scopes, + "can_delegate": can_delegate, + "parent_grant_id": parent_grant_id, + }, + ) + return grant + + +async def revoke_grant( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + grant_id: str, + actor_type: str, + actor_id: str, + org_id: str | None, +) -> AgentDeviceGrant: + grant = (await db.execute( + select(AgentDeviceGrant).where( + AgentDeviceGrant.id == grant_id, + AgentDeviceGrant.device_id == device_id, + AgentDeviceGrant.workspace_id == workspace_id, + not_deleted(AgentDeviceGrant), + ) + )).scalar_one_or_none() + if grant is None: + raise NotFoundError("设备授权不存在", "errors.agent_device.grant_not_found") + if actor_type == "agent" and grant.granted_by_id != actor_id: + raise ForbiddenError("Agent 只能撤销自己委托的设备授权", "errors.agent_device.revoke_forbidden") + grant.revoked_at = utc_now() + grant.soft_delete() + await hooks.emit( + "operation_audit", + action="agent_device.grant_revoked", + target_type="agent_device", + target_id=device_id, + actor_id=actor_id, + actor_type=actor_type, + org_id=org_id, + workspace_id=workspace_id, + details={"grant_id": grant_id, "subject_type": grant.subject_type, "subject_id": grant.subject_id}, + ) + return grant + + +async def _is_grant_ancestor( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + ancestor_grant_id: str, + descendant_grant_id: str, +) -> bool: + current_id: str | None = descendant_grant_id + visited: set[str] = set() + while current_id and current_id not in visited: + visited.add(current_id) + grant = (await db.execute( + select(AgentDeviceGrant).where( + AgentDeviceGrant.id == current_id, + AgentDeviceGrant.workspace_id == workspace_id, + AgentDeviceGrant.device_id == device_id, + not_deleted(AgentDeviceGrant), + ) + )).scalar_one_or_none() + if grant is None: + return False + if grant.id == ancestor_grant_id: + return True + current_id = grant.parent_grant_id + return False + + +async def _agent_can_reclaim_lease( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + actor_agent_id: str, + lease: AgentDeviceLease, +) -> bool: + if lease.holder_agent_id == actor_agent_id: + return True + + grant_stmt = await _valid_grant_query( + workspace_id=workspace_id, + device_id=device_id, + subject_type="agent", + subject_id=actor_agent_id, + ) + result = await db.execute(grant_stmt) + for grant in result.scalars().all(): + if not grant.can_delegate or "delegate" not in (grant.scopes or []): + continue + if await _is_grant_ancestor( + db, + workspace_id=workspace_id, + device_id=device_id, + ancestor_grant_id=grant.id, + descendant_grant_id=lease.grant_id, + ): + return True + return False + + +async def list_grants(db: AsyncSession, *, workspace_id: str, device_id: str) -> list[AgentDeviceGrant]: + result = await db.execute( + select(AgentDeviceGrant).where( + AgentDeviceGrant.workspace_id == workspace_id, + AgentDeviceGrant.device_id == device_id, + not_deleted(AgentDeviceGrant), + ).order_by(AgentDeviceGrant.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def expire_active_leases(db: AsyncSession, *, device_id: str) -> None: + result = await db.execute( + select(AgentDeviceLease).where( + AgentDeviceLease.device_id == device_id, + AgentDeviceLease.status == "active", + AgentDeviceLease.expires_at <= utc_now(), + not_deleted(AgentDeviceLease), + ) + ) + for lease in result.scalars().all(): + lease.status = "expired" + lease.released_at = utc_now() + await db.flush() + + +async def active_lease(db: AsyncSession, *, device_id: str) -> AgentDeviceLease | None: + await expire_active_leases(db, device_id=device_id) + return (await db.execute( + select(AgentDeviceLease).where( + AgentDeviceLease.device_id == device_id, + AgentDeviceLease.status == "active", + not_deleted(AgentDeviceLease), + ).limit(1) + )).scalar_one_or_none() + + +async def agent_topology_reachable( + db: AsyncSession, + *, + workspace_id: str, + agent_id: str, + device: AgentDeviceInstance, +) -> tuple[bool, str]: + agent_hex = await corridor_router.get_agent_hex_in_workspace(agent_id, workspace_id, db) + if agent_hex is None: + return False, "agent_not_on_topology" + if not await corridor_router.has_any_connections(workspace_id, db): + return False, "topology_required" + if not await corridor_router.can_reach(workspace_id, agent_hex[0], agent_hex[1], device.hex_q, device.hex_r, db): + return False, "topology_unreachable" + return True, "ok" + + +async def device_visibility( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + agent_id: str | None, +) -> dict: + device = await get_device(db, workspace_id=workspace_id, device_id=device_id) + reasons: list[str] = [] + preset = await get_preset_info(db, workspace_id=workspace_id, preset_id=device.preset_id) + device.status, device.status_reason = _status_for_device(device) + if not preset["enabled"]: + reasons.append("preset_disabled") + if device.status != "available": + reasons.append(device.status) + + grant: AgentDeviceGrant | None = None + topology_reachable = False + topology_reason = "agent_required" + if agent_id: + topology_reachable, topology_reason = await agent_topology_reachable( + db, workspace_id=workspace_id, agent_id=agent_id, device=device, + ) + if not topology_reachable: + reasons.append(topology_reason) + grant = await find_valid_grant( + db, + workspace_id=workspace_id, + device_id=device_id, + subject_type="agent", + subject_id=agent_id, + required_scope="discover", + ) + if grant is None: + reasons.append("grant_missing") + else: + reasons.append("agent_required") + + lease = await active_lease(db, device_id=device_id) + visible = len(reasons) == 0 + return { + "device_id": device.id, + "visible": visible, + "reasons": reasons, + "status": device.status, + "status_reason": device.status_reason, + "preset_id": device.preset_id, + "provider_id": device.provider_id, + "display_name": device.display_name, + "hex_q": device.hex_q, + "hex_r": device.hex_r, + "grant_id": grant.id if grant else None, + "topology_reachable": topology_reachable, + "reachability_source": "topology" if topology_reachable else None, + "topology_path_ref": None, + "topology_reason": topology_reason, + "active_lease": lease_summary(lease) if lease else None, + } + + +async def reachable_devices(db: AsyncSession, *, workspace_id: str, agent_id: str) -> list[dict]: + devices = await list_devices(db, workspace_id=workspace_id) + visible_devices = [] + for device in devices: + visibility = await device_visibility(db, workspace_id=workspace_id, device_id=device.id, agent_id=agent_id) + if visibility["visible"]: + visible_devices.append({ + "id": device.id, + "workspace_id": device.workspace_id, + "preset_id": device.preset_id, + "provider_id": device.provider_id, + "display_name": device.display_name, + "hex_q": device.hex_q, + "hex_r": device.hex_r, + "status": device.status, + "visibility": visibility, + }) + return visible_devices + + +async def require_agent_device_access( + db: AsyncSession, + *, + workspace_id: str, + device: AgentDeviceInstance, + agent_id: str, + required_scope: str, +) -> AgentDeviceGrant: + preset = get_agent_device_preset(device.preset_id) + if preset is None: + raise NotFoundError("办公设施预设不存在", "errors.agent_device.preset_not_found") + enablement = await get_preset_enablement(db, workspace_id=workspace_id, preset_id=device.preset_id) + if enablement is not None and not enablement.enabled: + raise ForbiddenError("该办公设施预设已停用", "errors.agent_device.preset_disabled") + device.status, device.status_reason = _status_for_device(device) + if device.status != "available": + raise BadRequestError("办公设施当前不可用", "errors.agent_device.unavailable") + reachable, reason = await agent_topology_reachable(db, workspace_id=workspace_id, agent_id=agent_id, device=device) + if not reachable: + raise ForbiddenError(f"办公设施拓扑不可达: {reason}", "errors.agent_device.topology_unreachable") + grant = await find_valid_grant( + db, + workspace_id=workspace_id, + device_id=device.id, + subject_type="agent", + subject_id=agent_id, + required_scope=required_scope, + ) + if grant is None: + raise ForbiddenError("缺少办公设施授权", "errors.agent_device.grant_missing") + return grant + + +async def acquire_lease( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + agent_id: str, + ttl_seconds: int | None, + org_id: str | None, +) -> AgentDeviceLease: + device = await get_device(db, workspace_id=workspace_id, device_id=device_id) + grant = await require_agent_device_access( + db, workspace_id=workspace_id, device=device, agent_id=agent_id, required_scope="lease" + ) + await expire_active_leases(db, device_id=device_id) + current = await active_lease(db, device_id=device_id) + if current is not None: + raise ConflictError("办公设施已有活跃租约", "errors.agent_device.lease_conflict") + ttl = ttl_seconds or DEFAULT_LEASE_TTL_SECONDS + if ttl <= 0 or ttl > 24 * 3600: + raise BadRequestError("租约时长必须在 1 秒到 24 小时之间", "errors.agent_device.invalid_lease_ttl") + lease = AgentDeviceLease( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + device_id=device_id, + holder_agent_id=agent_id, + grant_id=grant.id, + status="active", + expires_at=utc_now() + timedelta(seconds=ttl), + ) + db.add(lease) + await hooks.emit( + "operation_audit", + action="agent_device.lease_acquired", + target_type="agent_device", + target_id=device_id, + actor_id=agent_id, + actor_type="agent", + org_id=org_id, + workspace_id=workspace_id, + details={"lease_id": lease.id, "grant_id": grant.id, "ttl_seconds": ttl}, + ) + return lease + + +async def renew_lease( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + lease_id: str, + agent_id: str, + ttl_seconds: int | None, + org_id: str | None, +) -> AgentDeviceLease: + lease = await get_active_holder_lease( + db, workspace_id=workspace_id, device_id=device_id, lease_id=lease_id, agent_id=agent_id + ) + device = await get_device(db, workspace_id=workspace_id, device_id=device_id) + await require_agent_device_access( + db, workspace_id=workspace_id, device=device, agent_id=agent_id, required_scope="lease" + ) + ttl = ttl_seconds or DEFAULT_LEASE_TTL_SECONDS + if ttl <= 0 or ttl > 24 * 3600: + raise BadRequestError("租约时长必须在 1 秒到 24 小时之间", "errors.agent_device.invalid_lease_ttl") + lease.expires_at = utc_now() + timedelta(seconds=ttl) + lease.renewed_at = utc_now() + await hooks.emit( + "operation_audit", + action="agent_device.lease_renewed", + target_type="agent_device", + target_id=device_id, + actor_id=agent_id, + actor_type="agent", + org_id=org_id, + workspace_id=workspace_id, + details={"lease_id": lease.id, "ttl_seconds": ttl}, + ) + return lease + + +async def get_active_holder_lease( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + lease_id: str, + agent_id: str, +) -> AgentDeviceLease: + await expire_active_leases(db, device_id=device_id) + lease = (await db.execute( + select(AgentDeviceLease).where( + AgentDeviceLease.id == lease_id, + AgentDeviceLease.workspace_id == workspace_id, + AgentDeviceLease.device_id == device_id, + AgentDeviceLease.holder_agent_id == agent_id, + AgentDeviceLease.status == "active", + not_deleted(AgentDeviceLease), + ) + )).scalar_one_or_none() + if lease is None: + raise NotFoundError("活跃租约不存在", "errors.agent_device.lease_not_found") + return lease + + +async def release_lease( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + lease_id: str, + agent_id: str, + org_id: str | None, +) -> AgentDeviceLease: + lease = await get_active_holder_lease( + db, workspace_id=workspace_id, device_id=device_id, lease_id=lease_id, agent_id=agent_id + ) + lease.status = "released" + lease.released_at = utc_now() + await hooks.emit( + "operation_audit", + action="agent_device.lease_released", + target_type="agent_device", + target_id=device_id, + actor_id=agent_id, + actor_type="agent", + org_id=org_id, + workspace_id=workspace_id, + details={"lease_id": lease.id}, + ) + return lease + + +async def reclaim_lease( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + lease_id: str, + actor_id: str, + actor_type: str, + org_id: str | None, +) -> AgentDeviceLease: + lease = (await db.execute( + select(AgentDeviceLease).where( + AgentDeviceLease.id == lease_id, + AgentDeviceLease.workspace_id == workspace_id, + AgentDeviceLease.device_id == device_id, + AgentDeviceLease.status == "active", + not_deleted(AgentDeviceLease), + ) + )).scalar_one_or_none() + if lease is None: + raise NotFoundError("活跃租约不存在", "errors.agent_device.lease_not_found") + if actor_type == "agent" and not await _agent_can_reclaim_lease( + db, + workspace_id=workspace_id, + device_id=device_id, + actor_agent_id=actor_id, + lease=lease, + ): + raise ForbiddenError("当前 Agent 无权回收该办公设施租约", "errors.agent_device.reclaim_forbidden") + lease.status = "reclaimed" + lease.released_at = utc_now() + await hooks.emit( + "operation_audit", + action="agent_device.lease_reclaimed", + target_type="agent_device", + target_id=device_id, + actor_id=actor_id, + actor_type=actor_type, + org_id=org_id, + workspace_id=workspace_id, + details={"lease_id": lease.id, "holder_agent_id": lease.holder_agent_id}, + ) + return lease + + +async def invoke_device( + db: AsyncSession, + *, + workspace_id: str, + device_id: str, + agent_id: str, + lease_id: str, + action: str, + payload: dict[str, Any], + org_id: str | None, +) -> dict[str, Any]: + device = await get_device(db, workspace_id=workspace_id, device_id=device_id) + await require_agent_device_access( + db, workspace_id=workspace_id, device=device, agent_id=agent_id, required_scope="invoke" + ) + lease = await get_active_holder_lease( + db, workspace_id=workspace_id, device_id=device_id, lease_id=lease_id, agent_id=agent_id + ) + provider = get_agent_device_provider(device.provider_id) + if provider is None: + raise BadRequestError("办公设施 provider 未注册", "errors.agent_device.provider_unconfigured") + + try: + result = await provider.invoke( + device=device, + actor_agent_id=agent_id, + lease=lease, + action=action, + payload=payload, + ) + await hooks.emit( + "operation_audit", + action="agent_device.invoke_succeeded", + target_type="agent_device", + target_id=device_id, + actor_id=agent_id, + actor_type="agent", + org_id=org_id, + workspace_id=workspace_id, + details={"lease_id": lease.id, "grant_id": lease.grant_id, "provider_id": device.provider_id, "action": action}, + ) + return {"status": "ok", "result": result} + except Exception as exc: + await hooks.emit( + "operation_audit", + action="agent_device.invoke_failed", + target_type="agent_device", + target_id=device_id, + actor_id=agent_id, + actor_type="agent", + org_id=org_id, + workspace_id=workspace_id, + details={"lease_id": lease.id, "grant_id": lease.grant_id, "provider_id": device.provider_id, "action": action, "error": str(exc)}, + ) + raise + + +def device_summary(device: AgentDeviceInstance) -> dict: + return { + "id": device.id, + "workspace_id": device.workspace_id, + "preset_id": device.preset_id, + "provider_id": device.provider_id, + "display_name": device.display_name, + "hex_q": device.hex_q, + "hex_r": device.hex_r, + "status": device.status, + "status_reason": device.status_reason, + "config": device.config or {}, + "metadata": device.metadata_ or {}, + "created_by": device.created_by, + "created_at": device.created_at, + "updated_at": device.updated_at, + } + + +def grant_summary(grant: AgentDeviceGrant) -> dict: + return { + "id": grant.id, + "workspace_id": grant.workspace_id, + "device_id": grant.device_id, + "subject_type": grant.subject_type, + "subject_id": grant.subject_id, + "scopes": grant.scopes or [], + "can_delegate": grant.can_delegate, + "parent_grant_id": grant.parent_grant_id, + "granted_by_type": grant.granted_by_type, + "granted_by_id": grant.granted_by_id, + "expires_at": grant.expires_at, + "revoked_at": grant.revoked_at, + "created_at": grant.created_at, + } + + +def lease_summary(lease: AgentDeviceLease) -> dict: + return { + "id": lease.id, + "workspace_id": lease.workspace_id, + "device_id": lease.device_id, + "holder_agent_id": lease.holder_agent_id, + "grant_id": lease.grant_id, + "status": lease.status, + "expires_at": lease.expires_at, + "renewed_at": lease.renewed_at, + "released_at": lease.released_at, + "created_at": lease.created_at, + } + + +async def sync_workspace_device_genes(db: AsyncSession, *, workspace_id: str, reason: str) -> None: + devices = await list_devices(db, workspace_id=workspace_id) + agents = (await db.execute( + select(WorkspaceAgent, Instance).join( + Instance, + and_(Instance.id == WorkspaceAgent.instance_id, not_deleted(Instance)), + ).where( + WorkspaceAgent.workspace_id == workspace_id, + not_deleted(WorkspaceAgent), + ) + )).all() + + for device in devices: + preset = get_agent_device_preset(device.preset_id) + if preset is None: + continue + for workspace_agent, instance in agents: + visibility = await device_visibility( + db, + workspace_id=workspace_id, + device_id=device.id, + agent_id=workspace_agent.instance_id, + ) + if visibility["visible"]: + await ensure_gene_binding( + db, + workspace_id=workspace_id, + device=device, + instance=instance, + gene_slug=preset.gene_slug, + reason=reason, + ) + else: + await withdraw_gene_binding( + db, + workspace_id=workspace_id, + device=device, + instance=instance, + gene_slug=preset.gene_slug, + reason=reason, + ) + + +async def ensure_gene_binding( + db: AsyncSession, + *, + workspace_id: str, + device: AgentDeviceInstance, + instance: Instance, + gene_slug: str, + reason: str, +) -> None: + existing_binding = (await db.execute( + select(AgentDeviceGeneBinding).where( + AgentDeviceGeneBinding.workspace_id == workspace_id, + AgentDeviceGeneBinding.device_id == device.id, + AgentDeviceGeneBinding.instance_id == instance.id, + AgentDeviceGeneBinding.gene_slug == gene_slug, + not_deleted(AgentDeviceGeneBinding), + ) + )).scalar_one_or_none() + if existing_binding: + return + + gene = (await db.execute( + select(Gene).where(Gene.slug == gene_slug, not_deleted(Gene)) + )).scalar_one_or_none() + if gene is None: + logger.warning("Agent Device Gene 不存在,跳过同步: %s", gene_slug) + return + + existing_instance_gene = (await db.execute( + select(InstanceGene).where( + InstanceGene.instance_id == instance.id, + InstanceGene.gene_id == gene.id, + InstanceGene.status == InstanceGeneStatus.installed, + not_deleted(InstanceGene), + ) + )).scalar_one_or_none() + was_preexisting = existing_instance_gene is not None + if existing_instance_gene is None: + try: + from app.services.gene_service import install_gene_prerestart + await install_gene_prerestart(instance.id, gene_slug) + except Exception as exc: + logger.warning( + "Agent Device Gene 自动安装失败 instance=%s gene=%s err=%s", + instance.id, + gene_slug, + exc, + ) + existing_instance_gene = (await db.execute( + select(InstanceGene).where( + InstanceGene.instance_id == instance.id, + InstanceGene.gene_id == gene.id, + InstanceGene.status == InstanceGeneStatus.installed, + not_deleted(InstanceGene), + ) + )).scalar_one_or_none() + + db.add(AgentDeviceGeneBinding( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + device_id=device.id, + instance_id=instance.id, + gene_id=gene.id, + gene_slug=gene_slug, + instance_gene_id=existing_instance_gene.id if existing_instance_gene else None, + was_preexisting=was_preexisting, + sync_reason=reason, + )) + + +async def withdraw_gene_binding( + db: AsyncSession, + *, + workspace_id: str, + device: AgentDeviceInstance, + instance: Instance, + gene_slug: str, + reason: str, +) -> None: + binding = (await db.execute( + select(AgentDeviceGeneBinding).where( + AgentDeviceGeneBinding.workspace_id == workspace_id, + AgentDeviceGeneBinding.device_id == device.id, + AgentDeviceGeneBinding.instance_id == instance.id, + AgentDeviceGeneBinding.gene_slug == gene_slug, + not_deleted(AgentDeviceGeneBinding), + ) + )).scalar_one_or_none() + if binding is None: + return + binding.sync_reason = reason + binding.soft_delete() + if binding.was_preexisting or not binding.gene_id: + return + try: + from app.services.gene_service import uninstall_gene + await uninstall_gene(db, instance.id, binding.gene_id) + except Exception as exc: + logger.warning( + "Agent Device Gene 自动撤回失败 instance=%s gene=%s err=%s", + instance.id, + gene_slug, + exc, + ) diff --git a/nodeskclaw-backend/app/services/audit_handler.py b/nodeskclaw-backend/app/services/audit_handler.py index 260fac6f..d1079d91 100644 --- a/nodeskclaw-backend/app/services/audit_handler.py +++ b/nodeskclaw-backend/app/services/audit_handler.py @@ -1,4 +1,4 @@ -"""CE 操作审计 handler — 监听 operation_audit 事件,只写 actor_type=user 的记录。 +"""CE 操作审计 handler — 监听 operation_audit 事件,记录 user / agent 操作。 EE 模式下此 handler 不会注册(由 EE 自己的 handler 接管)。 """ @@ -29,7 +29,7 @@ async def _on_operation_audit( details: dict | None = None, **_kwargs, ) -> None: - if actor_type != "user": + if actor_type not in {"user", "agent"}: hooks.mark_audited() return diff --git a/nodeskclaw-backend/app/services/corridor_router.py b/nodeskclaw-backend/app/services/corridor_router.py index 2ba0a7c2..ae36a253 100644 --- a/nodeskclaw-backend/app/services/corridor_router.py +++ b/nodeskclaw-backend/app/services/corridor_router.py @@ -84,6 +84,14 @@ async def _build_hex_map(workspace_id: str, db: AsyncSession) -> dict[tuple[int, "channel_type": meta.get("channel_type"), "channel_config": meta.get("channel_config"), } + elif card.node_type == "device": + extra = { + "preset_id": meta.get("preset_id"), + "provider_id": meta.get("provider_id"), + "status": card.status, + "status_reason": meta.get("status_reason"), + "protocol_name": meta.get("protocol_name", "Agent Device"), + } hex_map[(card.hex_q, card.hex_r)] = TopologyNode( card.hex_q, card.hex_r, card.node_type, entity_id=card.node_id, diff --git a/nodeskclaw-backend/app/services/runtime/registries/node_type_registry.py b/nodeskclaw-backend/app/services/runtime/registries/node_type_registry.py index d7adfdd8..12e5328c 100644 --- a/nodeskclaw-backend/app/services/runtime/registries/node_type_registry.py +++ b/nodeskclaw-backend/app/services/runtime/registries/node_type_registry.py @@ -172,3 +172,15 @@ async def load_from_db(self, db: AsyncSession) -> None: hooks=["on_message_passing"], description="Shared blackboard node providing workspace-wide context.", )) + +NODE_TYPE_REGISTRY.register(NodeTypeDefinitionSpec( + type_id="device", + routing_role=RoutingRole.SENSOR, + transport="agent_device", + propagates=False, + consumes=False, + is_addressable=False, + can_originate=False, + hooks=[], + description="Governed Agent Device placed on topology; discoverable through device APIs only.", +)) diff --git a/nodeskclaw-backend/app/services/workspace_service.py b/nodeskclaw-backend/app/services/workspace_service.py index f554342f..814a0061 100644 --- a/nodeskclaw-backend/app/services/workspace_service.py +++ b/nodeskclaw-backend/app/services/workspace_service.py @@ -787,6 +787,7 @@ async def _remove_channel_plugin(inst: Instance, db: AsyncSession, workspace_id: "corridor": "走廊", "agent": "AI 员工", "human": "成员", + "device": "办公设施", } diff --git a/nodeskclaw-backend/tests/test_agent_device_service.py b/nodeskclaw-backend/tests/test_agent_device_service.py new file mode 100644 index 00000000..7553cb7b --- /dev/null +++ b/nodeskclaw-backend/tests/test_agent_device_service.py @@ -0,0 +1,555 @@ +from datetime import timedelta +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.pool import NullPool +from sqlalchemy.orm import sessionmaker + +from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError +from app.models.agent_device import AgentDeviceLease +from app.models.cluster import Cluster +from app.models.corridor import HexConnection +from app.models.instance import Instance +from app.models.node_card import NodeCard +from app.models.organization import Organization +from app.models.user import User +from app.models.workspace import Workspace +from app.models.workspace_agent import WorkspaceAgent +from app.services import agent_device_service +from app.services.agent_device_provider import ProviderStatus + +TEST_DATABASE_URL = "postgresql+asyncpg://nodeskclaw:nodeskclaw@localhost:5432/nodeskclaw_test" +engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool) +TestSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest.fixture(autouse=True) +async def require_test_db(): + try: + async with engine.connect(): + yield + except Exception: + pytest.skip("PostgreSQL test database is not available") + + +async def _noop(*args, **kwargs): + return None + + +class _FakeProvider: + provider_id = "browser.bpilot" + + def status(self): + return ProviderStatus(status="available") + + async def invoke(self, *, device, actor_agent_id, lease, action, payload): + return { + "device_id": device.id, + "actor_agent_id": actor_agent_id, + "lease_id": lease.id, + "action": action, + "payload": payload, + } + + +async def _seed_workspace(db: AsyncSession, suffix: str): + org = Organization(id=f"org-device-{suffix}", name="Org", slug=f"org-device-{suffix}") + user = User(id=f"user-device-{suffix}", name="Tester", username=f"tester-device-{suffix}") + cluster = Cluster( + id=f"cluster-device-{suffix}", + name=f"Cluster {suffix}", + org_id=org.id, + created_by=user.id, + ) + workspace = Workspace( + id=f"ws-device-{suffix}", + org_id=org.id, + name="Workspace", + description="", + color="#111111", + icon="bot", + created_by=user.id, + cluster_id=cluster.id, + ) + agent = Instance( + id=f"inst-device-agent-{suffix}", + name="Agent", + slug=f"device-agent-{suffix}", + cluster_id=cluster.id, + namespace="default", + image_version="latest", + created_by=user.id, + org_id=org.id, + workspace_id=workspace.id, + status="running", + ) + delegate_agent = Instance( + id=f"inst-device-delegate-{suffix}", + name="Delegate", + slug=f"device-delegate-{suffix}", + cluster_id=cluster.id, + namespace="default", + image_version="latest", + created_by=user.id, + org_id=org.id, + workspace_id=workspace.id, + status="running", + ) + workspace_agent = WorkspaceAgent( + id=f"wa-device-agent-{suffix}", + workspace_id=workspace.id, + instance_id=agent.id, + hex_q=1, + hex_r=0, + display_name="Agent", + ) + delegate_workspace_agent = WorkspaceAgent( + id=f"wa-device-delegate-{suffix}", + workspace_id=workspace.id, + instance_id=delegate_agent.id, + hex_q=-1, + hex_r=1, + display_name="Delegate", + ) + agent_card = NodeCard( + id=f"card-device-agent-{suffix}", + node_type="agent", + node_id=agent.id, + workspace_id=workspace.id, + hex_q=1, + hex_r=0, + name="Agent", + ) + delegate_card = NodeCard( + id=f"card-device-delegate-{suffix}", + node_type="agent", + node_id=delegate_agent.id, + workspace_id=workspace.id, + hex_q=-1, + hex_r=1, + name="Delegate", + ) + db.add_all([org, user]) + await db.flush() + db.add_all([cluster, workspace]) + await db.flush() + db.add_all([agent, delegate_agent]) + await db.flush() + db.add_all([workspace_agent, delegate_workspace_agent, agent_card, delegate_card]) + await db.flush() + return org, user, workspace, agent, delegate_agent + + +async def _place_available_device(monkeypatch: pytest.MonkeyPatch, db: AsyncSession, workspace: Workspace, user: User): + monkeypatch.setattr(agent_device_service.corridor_router, "auto_connect_hex", _noop) + monkeypatch.setattr(agent_device_service, "_status_for_device", lambda _device: ("available", None)) + return await agent_device_service.create_device( + db, + workspace_id=workspace.id, + preset_id="browser.bpilot.session", + display_name="Browser Pilot", + hex_q=3, + hex_r=0, + config=None, + metadata=None, + actor_id=user.id, + org_id=workspace.org_id, + ) + + +async def _connect_agent_to_device(db: AsyncSession, workspace: Workspace, agent: Instance, device_id: str): + corridor_card = NodeCard( + id=f"card-cor-{workspace.id}", + node_type="corridor", + node_id=f"corridor-device-{workspace.id}", + workspace_id=workspace.id, + hex_q=2, + hex_r=0, + name="Corridor", + ) + db.add(corridor_card) + db.add_all([ + HexConnection( + id=f"conn-a-{workspace.id}", + workspace_id=workspace.id, + hex_a_q=1, + hex_a_r=0, + hex_b_q=2, + hex_b_r=0, + direction="both", + ), + HexConnection( + id=f"conn-b-{workspace.id}", + workspace_id=workspace.id, + hex_a_q=2, + hex_a_r=0, + hex_b_q=3, + hex_b_r=0, + direction="both", + ), + ]) + await agent_device_service.create_grant( + db, + workspace_id=workspace.id, + device_id=device_id, + subject_type="agent", + subject_id=agent.id, + scopes=["discover", "lease", "invoke", "delegate"], + can_delegate=True, + parent_grant_id=None, + expires_at=None, + granted_by_type="user", + granted_by_id=workspace.created_by, + org_id=workspace.org_id, + ) + await db.flush() + + +@pytest.mark.asyncio +async def test_create_device_registers_node_card_and_blocks_hex(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + monkeypatch.setattr(agent_device_service.corridor_router, "auto_connect_hex", _noop) + + async with TestSessionLocal() as db: + org, user, workspace, _agent, _delegate_agent = await _seed_workspace(db, "create") + device = await agent_device_service.create_device( + db, + workspace_id=workspace.id, + preset_id="browser.bpilot.session", + display_name="Browser Pilot", + hex_q=3, + hex_r=0, + config=None, + metadata={"kind": "browser"}, + actor_id=user.id, + org_id=org.id, + ) + await db.flush() + + assert device.status == "provider_unconfigured" + assert device.status_reason == "bpilot_base_url_missing" + card = (await db.execute(select(NodeCard).where(NodeCard.node_id == device.id))).scalar_one() + assert card.node_type == "device" + assert card.workspace_id == workspace.id + assert card.hex_q == 3 + assert card.hex_r == 0 + assert card.metadata_["protocol_name"] == "Agent Device" + assert await agent_device_service.is_hex_occupied(db, workspace_id=workspace.id, hex_q=3, hex_r=0) + + with pytest.raises(ConflictError): + await agent_device_service.create_device( + db, + workspace_id=workspace.id, + preset_id="browser.bpilot.session", + display_name="Conflict", + hex_q=3, + hex_r=0, + config=None, + metadata=None, + actor_id=user.id, + org_id=org.id, + ) + + +@pytest.mark.asyncio +async def test_visibility_requires_topology_and_grant(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + + async with TestSessionLocal() as db: + _org, user, workspace, agent, _delegate_agent = await _seed_workspace(db, "visibility") + device = await _place_available_device(monkeypatch, db, workspace, user) + + no_grant = await agent_device_service.device_visibility( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ) + assert no_grant["visible"] is False + assert "topology_required" in no_grant["reasons"] + assert "grant_missing" in no_grant["reasons"] + + await _connect_agent_to_device(db, workspace, agent, device.id) + + visible = await agent_device_service.device_visibility( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ) + assert visible["visible"] is True + assert visible["reachability_source"] == "topology" + assert visible["grant_id"] is not None + + +@pytest.mark.asyncio +async def test_exclusive_lease_expires_before_reacquire(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + + async with TestSessionLocal() as db: + _org, user, workspace, agent, _delegate_agent = await _seed_workspace(db, "lease") + device = await _place_available_device(monkeypatch, db, workspace, user) + await _connect_agent_to_device(db, workspace, agent, device.id) + + lease = await agent_device_service.acquire_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ttl_seconds=60, + org_id=workspace.org_id, + ) + await db.flush() + + with pytest.raises(ConflictError): + await agent_device_service.acquire_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ttl_seconds=60, + org_id=workspace.org_id, + ) + + lease.expires_at = agent_device_service.utc_now() - timedelta(seconds=1) + await db.flush() + + replacement = await agent_device_service.acquire_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ttl_seconds=60, + org_id=workspace.org_id, + ) + await db.flush() + + assert lease.status == "expired" + assert replacement.status == "active" + assert replacement.id != lease.id + + +@pytest.mark.asyncio +async def test_lease_requires_available_device(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + monkeypatch.setattr(agent_device_service.corridor_router, "auto_connect_hex", _noop) + + async with TestSessionLocal() as db: + _org, user, workspace, agent, _delegate_agent = await _seed_workspace(db, "unavail") + device = await agent_device_service.create_device( + db, + workspace_id=workspace.id, + preset_id="browser.bpilot.session", + display_name="Browser Pilot", + hex_q=3, + hex_r=0, + config=None, + metadata=None, + actor_id=user.id, + org_id=workspace.org_id, + ) + await _connect_agent_to_device(db, workspace, agent, device.id) + + with pytest.raises(BadRequestError): + await agent_device_service.acquire_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ttl_seconds=60, + org_id=workspace.org_id, + ) + + +@pytest.mark.asyncio +async def test_agent_can_delegate_subset_to_another_agent(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + + async with TestSessionLocal() as db: + _org, user, workspace, agent, delegate_agent = await _seed_workspace(db, "delegate") + device = await _place_available_device(monkeypatch, db, workspace, user) + parent = await agent_device_service.create_grant( + db, + workspace_id=workspace.id, + device_id=device.id, + subject_type="agent", + subject_id=agent.id, + scopes=["discover", "lease", "invoke", "delegate"], + can_delegate=True, + parent_grant_id=None, + expires_at=None, + granted_by_type="user", + granted_by_id=user.id, + org_id=workspace.org_id, + ) + + child = await agent_device_service.create_grant( + db, + workspace_id=workspace.id, + device_id=device.id, + subject_type="agent", + subject_id=delegate_agent.id, + scopes=["discover", "lease"], + can_delegate=False, + parent_grant_id=parent.id, + expires_at=None, + granted_by_type="agent", + granted_by_id=agent.id, + org_id=workspace.org_id, + ) + assert child.parent_grant_id == parent.id + assert child.expires_at is not None + + with pytest.raises(BadRequestError): + await agent_device_service.create_grant( + db, + workspace_id=workspace.id, + device_id=device.id, + subject_type="human", + subject_id=user.id, + scopes=["discover"], + can_delegate=False, + parent_grant_id=parent.id, + expires_at=None, + granted_by_type="agent", + granted_by_id=agent.id, + org_id=workspace.org_id, + ) + + +@pytest.mark.asyncio +async def test_invoke_requires_active_holder_lease(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + monkeypatch.setattr(agent_device_service, "get_agent_device_provider", lambda _provider_id: _FakeProvider()) + + async with TestSessionLocal() as db: + _org, user, workspace, agent, _delegate_agent = await _seed_workspace(db, "invoke") + device = await _place_available_device(monkeypatch, db, workspace, user) + await _connect_agent_to_device(db, workspace, agent, device.id) + lease = await agent_device_service.acquire_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + ttl_seconds=60, + org_id=workspace.org_id, + ) + + result = await agent_device_service.invoke_device( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + lease_id=lease.id, + action="page.goto", + payload={"url": "https://example.com"}, + org_id=workspace.org_id, + ) + assert result["status"] == "ok" + assert result["result"]["action"] == "page.goto" + assert result["result"]["payload"]["url"] == "https://example.com" + + await agent_device_service.release_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + lease_id=lease.id, + agent_id=agent.id, + org_id=workspace.org_id, + ) + with pytest.raises(NotFoundError): + await agent_device_service.invoke_device( + db, + workspace_id=workspace.id, + device_id=device.id, + agent_id=agent.id, + lease_id=lease.id, + action="page.goto", + payload={}, + org_id=workspace.org_id, + ) + + +@pytest.mark.asyncio +async def test_agent_reclaim_requires_own_or_ancestor_delegation(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_device_service.hooks, "emit", _noop) + + async with TestSessionLocal() as db: + _org, user, workspace, agent, delegate_agent = await _seed_workspace(db, "reclaim") + device = await _place_available_device(monkeypatch, db, workspace, user) + parent = await agent_device_service.create_grant( + db, + workspace_id=workspace.id, + device_id=device.id, + subject_type="agent", + subject_id=agent.id, + scopes=["discover", "lease", "invoke", "delegate"], + can_delegate=True, + parent_grant_id=None, + expires_at=None, + granted_by_type="user", + granted_by_id=user.id, + org_id=workspace.org_id, + ) + child = await agent_device_service.create_grant( + db, + workspace_id=workspace.id, + device_id=device.id, + subject_type="agent", + subject_id=delegate_agent.id, + scopes=["discover", "lease"], + can_delegate=False, + parent_grant_id=parent.id, + expires_at=None, + granted_by_type="agent", + granted_by_id=agent.id, + org_id=workspace.org_id, + ) + lease = AgentDeviceLease( + id=str(uuid.uuid4()), + workspace_id=workspace.id, + device_id=device.id, + holder_agent_id=delegate_agent.id, + grant_id=child.id, + status="active", + expires_at=agent_device_service.utc_now() + timedelta(seconds=60), + ) + db.add(lease) + await db.flush() + + reclaimed = await agent_device_service.reclaim_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + lease_id=lease.id, + actor_type="agent", + actor_id=agent.id, + org_id=workspace.org_id, + ) + assert reclaimed.status == "reclaimed" + await db.flush() + + parent_lease = AgentDeviceLease( + id=str(uuid.uuid4()), + workspace_id=workspace.id, + device_id=device.id, + holder_agent_id=agent.id, + grant_id=parent.id, + status="active", + expires_at=agent_device_service.utc_now() + timedelta(seconds=60), + ) + db.add(parent_lease) + await db.flush() + + with pytest.raises(ForbiddenError): + await agent_device_service.reclaim_lease( + db, + workspace_id=workspace.id, + device_id=device.id, + lease_id=parent_lease.id, + actor_type="agent", + actor_id=delegate_agent.id, + org_id=workspace.org_id, + ) diff --git a/nodeskclaw-backend/tests/test_ce_audit_handler.py b/nodeskclaw-backend/tests/test_ce_audit_handler.py index 919b5265..ff248d19 100644 --- a/nodeskclaw-backend/tests/test_ce_audit_handler.py +++ b/nodeskclaw-backend/tests/test_ce_audit_handler.py @@ -1,4 +1,4 @@ -"""CE 审计 handler 单元测试 — 验证只记录 user 操作,跳过 agent/org。""" +"""CE 审计 handler 单元测试 — 验证记录 user/agent 操作,跳过 org。""" from __future__ import annotations @@ -46,8 +46,8 @@ async def test_user_operation_is_persisted(): @pytest.mark.asyncio -async def test_agent_operation_is_skipped(): - """actor_type=agent 应该被跳过,不写入数据库。""" +async def test_agent_operation_is_persisted(): + """actor_type=agent 应该写入数据库。""" mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) @@ -61,11 +61,13 @@ async def test_agent_operation_is_skipped(): actor_type="agent", ) - mock_session.add.assert_not_called() - mock_session.commit.assert_not_awaited() + mock_session.add.assert_called_once() + mock_session.commit.assert_awaited_once() - from app.core.hooks import is_audited - assert is_audited() is True + audit_obj = mock_session.add.call_args[0][0] + assert audit_obj.action == "agent.message_sent" + assert audit_obj.actor_type == "agent" + assert audit_obj.actor_id == "agent-001" @pytest.mark.asyncio diff --git a/nodeskclaw-backend/tests/test_workspace_agent_node_card_sync.py b/nodeskclaw-backend/tests/test_workspace_agent_node_card_sync.py index 5ff54ded..7f455712 100644 --- a/nodeskclaw-backend/tests/test_workspace_agent_node_card_sync.py +++ b/nodeskclaw-backend/tests/test_workspace_agent_node_card_sync.py @@ -1,7 +1,10 @@ +import uuid + import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool from app.models.cluster import Cluster from app.models.corridor import HumanHex @@ -17,7 +20,7 @@ import app.services.corridor_router as corridor_router TEST_DATABASE_URL = "postgresql+asyncpg://nodeskclaw:nodeskclaw@localhost:5432/nodeskclaw_test" -engine = create_async_engine(TEST_DATABASE_URL, echo=False) +engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool) TestSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -39,16 +42,19 @@ async def noop(*args, **kwargs): monkeypatch.setattr(corridor_router, "auto_connect_hex", noop) async with TestSessionLocal() as db: - org = Organization(id="org-agent-sync", name="Org", slug="org-agent-sync") - user = User(id="user-agent-sync", name="Tester", username="tester-agent-sync") + suffix = uuid.uuid4().hex[:8] + org = Organization(id=f"org-agent-sync-{suffix}", name="Org", slug=f"org-agent-sync-{suffix}") + user = User(id=f"user-agent-sync-{suffix}", name="Tester", username=f"tester-agent-sync-{suffix}") + db.add_all([org, user]) + await db.flush() cluster = Cluster( - id="cluster-agent-sync", + id=f"cluster-agent-sync-{suffix}", name="Cluster", org_id=org.id, created_by=user.id, ) workspace = Workspace( - id="ws-agent-sync", + id=f"ws-agent-sync-{suffix}", org_id=org.id, name="Workspace", description="", @@ -57,9 +63,9 @@ async def noop(*args, **kwargs): created_by=user.id, ) instance = Instance( - id="inst-agent-sync", + id=f"inst-agent-sync-{suffix}", name="Agent", - slug="agent-sync", + slug=f"agent-sync-{suffix}", cluster_id=cluster.id, namespace="default", image_version="latest", @@ -69,7 +75,7 @@ async def noop(*args, **kwargs): status="running", ) agent = WorkspaceAgent( - id="wa-agent-sync", + id=f"wa-agent-sync-{suffix}", workspace_id=workspace.id, instance_id=instance.id, hex_q=1, @@ -77,7 +83,7 @@ async def noop(*args, **kwargs): display_name="Agent", ) card = NodeCard( - id="card-agent-sync", + id=f"card-agent-sync-{suffix}", node_type="agent", node_id=instance.id, workspace_id=workspace.id, @@ -85,7 +91,7 @@ async def noop(*args, **kwargs): hex_r=0, name="Agent", ) - db.add_all([org, user, cluster, workspace, instance, agent, card]) + db.add_all([cluster, workspace, instance, agent, card]) await db.commit() updated = await workspace_service.update_agent( @@ -112,16 +118,19 @@ async def noop(*args, **kwargs): monkeypatch.setattr(corridor_router, "auto_connect_hex", noop) async with TestSessionLocal() as db: - org = Organization(id="org-agent-rename", name="Org", slug="org-agent-rename") - user = User(id="user-agent-rename", name="Tester", username="tester-agent-rename") + suffix = uuid.uuid4().hex[:8] + org = Organization(id=f"org-agent-rename-{suffix}", name="Org", slug=f"org-agent-rename-{suffix}") + user = User(id=f"user-agent-rename-{suffix}", name="Tester", username=f"tester-agent-rename-{suffix}") + db.add_all([org, user]) + await db.flush() cluster = Cluster( - id="cluster-agent-rename", + id=f"cluster-agent-rename-{suffix}", name="Cluster", org_id=org.id, created_by=user.id, ) workspace = Workspace( - id="ws-agent-rename", + id=f"ws-agent-rename-{suffix}", org_id=org.id, name="Workspace", description="", @@ -130,9 +139,9 @@ async def noop(*args, **kwargs): created_by=user.id, ) instance = Instance( - id="inst-agent-rename", + id=f"inst-agent-rename-{suffix}", name="Agent Origin", - slug="agent-rename", + slug=f"agent-rename-{suffix}", cluster_id=cluster.id, namespace="default", image_version="latest", @@ -142,7 +151,7 @@ async def noop(*args, **kwargs): status="running", ) agent = WorkspaceAgent( - id="wa-agent-rename", + id=f"wa-agent-rename-{suffix}", workspace_id=workspace.id, instance_id=instance.id, hex_q=1, @@ -150,7 +159,7 @@ async def noop(*args, **kwargs): display_name="Agent Origin", ) card = NodeCard( - id="card-agent-rename", + id=f"card-agent-rename-{suffix}", node_type="agent", node_id=instance.id, workspace_id=workspace.id, @@ -158,7 +167,7 @@ async def noop(*args, **kwargs): hex_r=0, name="Agent Origin", ) - db.add_all([org, user, cluster, workspace, instance, agent, card]) + db.add_all([cluster, workspace, instance, agent, card]) await db.commit() updated = await workspace_service.update_agent( @@ -193,16 +202,19 @@ async def has_any_connections(*args, **kwargs): monkeypatch.setattr(workspace_service, "_send_welcome_message", noop) async with TestSessionLocal() as db: - org = Organization(id="org-agent-add", name="Org", slug="org-agent-add") - user = User(id="user-agent-add", name="Tester", username="tester-agent-add") + suffix = uuid.uuid4().hex[:8] + org = Organization(id=f"org-agent-add-{suffix}", name="Org", slug=f"org-agent-add-{suffix}") + user = User(id=f"user-agent-add-{suffix}", name="Tester", username=f"tester-agent-add-{suffix}") + db.add_all([org, user]) + await db.flush() cluster = Cluster( - id="cluster-agent-add", + id=f"cluster-agent-add-{suffix}", name="Cluster", org_id=org.id, created_by=user.id, ) workspace = Workspace( - id="ws-agent-add", + id=f"ws-agent-add-{suffix}", org_id=org.id, name="Workspace", description="", @@ -213,9 +225,9 @@ async def has_any_connections(*args, **kwargs): ) existing_instances = [ Instance( - id=f"inst-agent-add-{idx}", + id=f"inst-agent-add-{suffix}-{idx}", name=f"Agent {idx}", - slug=f"agent-add-{idx}", + slug=f"agent-add-{suffix}-{idx}", cluster_id=cluster.id, namespace="default", image_version="latest", @@ -228,7 +240,7 @@ async def has_any_connections(*args, **kwargs): existing_positions = [(1, 0), (1, -1), (0, -1)] existing_agents = [ WorkspaceAgent( - id=f"wa-agent-add-{idx}", + id=f"wa-agent-add-{suffix}-{idx}", workspace_id=workspace.id, instance_id=inst.id, hex_q=pos[0], @@ -239,7 +251,7 @@ async def has_any_connections(*args, **kwargs): ] existing_cards = [ NodeCard( - id="card-blackboard-add", + id=f"card-blackboard-add-{suffix}", node_type="blackboard", node_id=workspace.id, workspace_id=workspace.id, @@ -249,7 +261,7 @@ async def has_any_connections(*args, **kwargs): ), *[ NodeCard( - id=f"card-agent-add-{idx}", + id=f"card-agent-add-{suffix}-{idx}", node_type="agent", node_id=inst.id, workspace_id=workspace.id, @@ -260,9 +272,9 @@ async def has_any_connections(*args, **kwargs): for idx, (inst, pos) in enumerate(zip(existing_instances, existing_positions)) ], NodeCard( - id="card-human-add", + id=f"card-human-add-{suffix}", node_type="human", - node_id="human-agent-add", + node_id=f"human-agent-add-{suffix}", workspace_id=workspace.id, hex_q=-1, hex_r=0, @@ -270,7 +282,7 @@ async def has_any_connections(*args, **kwargs): ), ] human = HumanHex( - id="human-agent-add", + id=f"human-agent-add-{suffix}", workspace_id=workspace.id, user_id=user.id, hex_q=-1, @@ -279,9 +291,9 @@ async def has_any_connections(*args, **kwargs): created_by=user.id, ) new_instance = Instance( - id="inst-agent-add-new", + id=f"inst-agent-add-new-{suffix}", name="Agent New", - slug="agent-add-new", + slug=f"agent-add-new-{suffix}", cluster_id=cluster.id, namespace="default", image_version="latest", @@ -290,7 +302,7 @@ async def has_any_connections(*args, **kwargs): status="running", ) db.add_all([ - org, user, cluster, workspace, *existing_instances, *existing_agents, + cluster, workspace, *existing_instances, *existing_agents, *existing_cards, human, new_instance, ]) await db.commit() diff --git a/nodeskclaw-portal/src/components/hex2d/Workspace2D.vue b/nodeskclaw-portal/src/components/hex2d/Workspace2D.vue index bf7f8406..81d19de7 100644 --- a/nodeskclaw-portal/src/components/hex2d/Workspace2D.vue +++ b/nodeskclaw-portal/src/components/hex2d/Workspace2D.vue @@ -13,7 +13,7 @@ const { t } = useI18n() interface TopologyNode { hex_q: number hex_r: number - node_type: 'agent' | 'blackboard' | 'corridor' | 'human' + node_type: 'agent' | 'blackboard' | 'corridor' | 'human' | 'device' entity_id?: string display_name?: string extra?: Record @@ -61,7 +61,7 @@ function formatK(n: number): string { } const emit = defineEmits<{ - (e: 'hex-click', payload: { q: number, r: number, type: 'empty' | 'agent' | 'blackboard' | 'corridor' | 'human', agentId?: string, entityId?: string }): void + (e: 'hex-click', payload: { q: number, r: number, type: 'empty' | 'agent' | 'blackboard' | 'corridor' | 'human' | 'device', agentId?: string, entityId?: string }): void (e: 'agent-dblclick', id: string): void (e: 'agent-hover', id: string | null): void (e: 'toggle-node', key: string): void @@ -256,8 +256,20 @@ const humanNodes = computed(() => }) ) +const deviceNodes = computed(() => + (props.topologyNodes || []) + .filter(n => n.node_type === 'device') + .map(n => { + const pos = worldPos(n.hex_q, n.hex_r) + const status = (n.extra?.status as string) || '' + const color = status === 'provider_unconfigured' ? '#f59e0b' : '#14b8a6' + return { ...n, px: pos.px, py: pos.py, color } + }) +) + const CORRIDOR_RADIUS = HEX_RADIUS * 0.65 const HUMAN_RADIUS = HEX_RADIUS * 0.75 +const DEVICE_RADIUS = HEX_RADIUS * 0.72 const AXIAL_DIRS: [number, number][] = [[1, 0], [0, 1], [-1, 1], [-1, 0], [0, -1], [1, -1]] const ARM_LEN_2D = HEX_SIZE * 0.88 * Math.sqrt(3) / 2 * SCALE @@ -286,6 +298,7 @@ const corridorPaths = computed(() => { for (const a of props.agents) occupied.add(`${a.hex_q}:${a.hex_r}`) for (const n of corridorNodes.value) occupied.add(`${n.hex_q}:${n.hex_r}`) for (const n of humanNodes.value) occupied.add(`${n.hex_q}:${n.hex_r}`) + for (const n of deviceNodes.value) occupied.add(`${n.hex_q}:${n.hex_r}`) return corridorNodes.value.map(ch => { const arms: RailArm[] = [] @@ -321,6 +334,15 @@ function humanHexPoints(cx: number, cy: number): string { return hexPointsStr(cx, cy, HUMAN_RADIUS) } +function deviceHexPoints(cx: number, cy: number): string { + return hexPointsStr(cx, cy, DEVICE_RADIUS) +} + +function shortNodeLabel(value?: string | null): string { + if (!value) return '' + return value.length > 14 ? value.slice(0, 13) + '...' : value +} + function axialDirIndex(dq: number, dr: number): number { return AXIAL_DIRS.findIndex(([aq, ar]) => aq === dq && ar === dr) } @@ -411,6 +433,7 @@ const emptyHexes = computed(() => { for (const a of props.agents) occupied.add(`${a.hex_q}:${a.hex_r}`) for (const n of corridorNodes.value) occupied.add(`${n.hex_q}:${n.hex_r}`) for (const n of humanNodes.value) occupied.add(`${n.hex_q}:${n.hex_r}`) + for (const n of deviceNodes.value) occupied.add(`${n.hex_q}:${n.hex_r}`) const hexes: { q: number, r: number, px: number, py: number }[] = [] for (let q = -GRID_RANGE; q <= GRID_RANGE; q++) { for (let r = -GRID_RANGE; r <= GRID_RANGE; r++) { @@ -695,6 +718,65 @@ const emptyHexes = computed(() => { + + + + + + + + + {{ shortNodeLabel(dev.display_name || t('hexAction.device')) }} + + + { } else if (id.startsWith('human:')) { const node = props.topologyNodes?.find(n => n.node_type === 'human' && n.entity_id === id.slice(6)) if (node) emit('hex-click', { q: node.hex_q, r: node.hex_r, type: 'human', entityId: node.entity_id ?? undefined }) + } else if (id.startsWith('device:')) { + const node = props.topologyNodes?.find(n => n.node_type === 'device' && n.entity_id === id.slice(7)) + if (node) emit('hex-click', { q: node.hex_q, r: node.hex_r, type: 'device', entityId: node.entity_id ?? undefined }) } else { const agent = props.agents.find((a) => a.instance_id === id) if (agent) emit('hex-click', { q: agent.hex_q, r: agent.hex_r, type: 'agent', agentId: id }) } }) watch(dblclickId, (id) => { - if (id && !id.startsWith('__') && !id.startsWith('empty:')) emit('agent-dblclick', id) + if (id && props.agents.some(a => a.instance_id === id)) emit('agent-dblclick', id) }) // Environment setup @@ -335,6 +338,7 @@ watch(() => props.perfSummary, () => { }, { deep: true }) const HUMAN_HEX_GEO = new THREE.CylinderGeometry(HEX_SIZE * 0.7, HEX_SIZE * 0.7, 0.5, 6) +const DEVICE_HEX_GEO = new THREE.CylinderGeometry(HEX_SIZE * 0.68, HEX_SIZE * 0.68, 0.42, 6) function createAgentLabelSprite(name: string, label?: string | null): THREE.Sprite { const canvas = document.createElement('canvas') @@ -381,6 +385,25 @@ function createCorridorLabelSprite(name: string): THREE.Sprite { return sprite } +function createDeviceLabelSprite(name: string): THREE.Sprite { + const canvas = document.createElement('canvas') + canvas.width = 256 + canvas.height = 44 + const ctx = canvas.getContext('2d')! + ctx.fillStyle = 'transparent' + ctx.fillRect(0, 0, 256, 44) + ctx.font = 'bold 17px sans-serif' + ctx.fillStyle = '#5eead4' + ctx.textAlign = 'center' + ctx.fillText(name.slice(0, 16), 128, 28) + const texture = new THREE.CanvasTexture(canvas) + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true }) + const sprite = new THREE.Sprite(mat) + sprite.scale.set(1.2, 0.22, 1) + sprite.userData.baseScale = { x: 1.2, y: 0.22 } + return sprite +} + function createHumanHexMesh(node: TopologyNode): THREE.Group { const group = new THREE.Group() const { x, y } = axialToWorld(node.hex_q, node.hex_r) @@ -410,6 +433,35 @@ function createHumanHexMesh(node: TopologyNode): THREE.Group { return group } +function createDeviceHexMesh(node: TopologyNode): THREE.Group { + const group = new THREE.Group() + const { x, y } = axialToWorld(node.hex_q, node.hex_r) + group.position.set(x, 0.22, y) + const hexId = `device:${node.entity_id}` + const status = (node.extra?.status as string) || '' + const colorHex = status === 'provider_unconfigured' ? '#f59e0b' : '#14b8a6' + group.userData = { hexId, isHex: true, displayColor: colorHex, hexQ: node.hex_q, hexR: node.hex_r } + const color = new THREE.Color(colorHex) + const mat = new THREE.MeshStandardMaterial({ + color, + emissive: color.clone(), + emissiveIntensity: 0.28, + metalness: 0.25, + roughness: 0.5, + transparent: true, + opacity: 0.9, + }) + const mesh = new THREE.Mesh(DEVICE_HEX_GEO, mat) + mesh.userData = { hexId, isHex: true } + group.add(mesh) + + const edgeGeo = new THREE.EdgesGeometry(DEVICE_HEX_GEO) + const edgeMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }) + group.add(new THREE.LineSegments(edgeGeo, edgeMat)) + + return group +} + const GRID_RANGE = 8 const EMPTY_HEX_GEO = new THREE.CylinderGeometry(HEX_SIZE * 0.9, HEX_SIZE * 0.9, 0.05, 6) @@ -444,6 +496,7 @@ function syncScene() { const corridorNodes = (props.topologyNodes || []).filter(n => n.node_type === 'corridor') const humanNodes = (props.topologyNodes || []).filter(n => n.node_type === 'human') + const deviceNodes = (props.topologyNodes || []).filter(n => n.node_type === 'device') const occupied = new Set() occupied.add('0:0') @@ -456,6 +509,9 @@ function syncScene() { for (const node of humanNodes) { occupied.add(`${node.hex_q}:${node.hex_r}`) } + for (const node of deviceNodes) { + occupied.add(`${node.hex_q}:${node.hex_r}`) + } for (const agent of props.agents) { const group = createHexMesh(agent) @@ -491,6 +547,18 @@ function syncScene() { hexMeshes.set(`human:${node.entity_id}`, group) } + for (const node of deviceNodes) { + const group = createDeviceHexMesh(node) + if (node.display_name) { + const label = createDeviceLabelSprite(node.display_name) + label.position.set(0, 0.42, 0) + group.add(label) + labelSprites.add(label) + } + scene.add(group) + hexMeshes.set(`device:${node.entity_id}`, group) + } + for (let q = -GRID_RANGE; q <= GRID_RANGE; q++) { for (let r = -GRID_RANGE; r <= GRID_RANGE; r++) { if (Math.abs(q) + Math.abs(r) + Math.abs(-q - r) > GRID_RANGE * 2) continue @@ -770,6 +838,19 @@ addToLoop(() => { continue } + if (id.startsWith('device:')) { + const mesh = group.children[0] as THREE.Mesh + if (!mesh?.material) continue + const mat = mesh.material as THREE.MeshStandardMaterial + const isHovered = hoveredId.value === id + const isSelectedHex = props.selectedHex?.q === group.userData.hexQ && props.selectedHex?.r === group.userData.hexR + const targetY = isHovered ? 0.34 : isSelectedHex ? 0.3 : 0.22 + group.position.y += (targetY - group.position.y) * 0.1 + mat.emissive.set(group.userData.displayColor || '#14b8a6') + mat.emissiveIntensity = isSelectedHex ? 0.55 + Math.sin(t * 3) * 0.15 : isHovered ? 0.45 : 0.28 + continue + } + const isHovered = hoveredId.value === id const isSelected = props.selectedAgentId === id const isSelectedHex = props.selectedHex?.q !== undefined && @@ -821,7 +902,7 @@ addToLoop(() => { if (props.isMovingHex && props.movingHexSource) { const src = props.movingHexSource for (const [id, group] of hexMeshes) { - if (!id.startsWith('corridor:') && !id.startsWith('human:')) continue + if (!id.startsWith('corridor:') && !id.startsWith('human:') && !id.startsWith('device:')) continue const hq = group.userData.hexQ ?? (group.userData as Record).hexQ const hr = group.userData.hexR ?? (group.userData as Record).hexR if (hq === src.q && hr === src.r) { @@ -871,6 +952,7 @@ onUnmounted(() => { AGENT_BASE_EDGE_GEO.dispose() EMPTY_HEX_GEO.dispose() HUMAN_HEX_GEO.dispose() + DEVICE_HEX_GEO.dispose() FLOW_SPHERE_GEO.dispose() for (const flow of activeFlows) { flowGroup.remove(flow.mesh) diff --git a/nodeskclaw-portal/src/components/workspace/DeviceDetailDrawer.vue b/nodeskclaw-portal/src/components/workspace/DeviceDetailDrawer.vue new file mode 100644 index 00000000..c075aa71 --- /dev/null +++ b/nodeskclaw-portal/src/components/workspace/DeviceDetailDrawer.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/nodeskclaw-portal/src/components/workspace/HexActionDrawer.vue b/nodeskclaw-portal/src/components/workspace/HexActionDrawer.vue index e37544843..fe39c1c8 100644 --- a/nodeskclaw-portal/src/components/workspace/HexActionDrawer.vue +++ b/nodeskclaw-portal/src/components/workspace/HexActionDrawer.vue @@ -1,7 +1,7 @@