fuli-context 0.7.4-beta.2 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -7
- package/README.zh-CN.md +84 -7
- package/compose.graphiti.yml +6 -6
- package/dist/web/assets/AboutPage-ByCUor-j.js +1 -0
- package/dist/web/assets/ConnectionsPage-DkdkZ40Q.js +1 -0
- package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +1 -0
- package/dist/web/assets/KnowledgeInspector-CYAjAb9J.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-CV4F54R_.js +1 -0
- package/dist/web/assets/KnowledgePage-vYYXipOi.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-z4u5mXOG.js +4 -0
- package/dist/web/assets/OverviewPage-BWOfeS6k.js +1 -0
- package/dist/web/assets/PersonalProfilePage-c3w4-h7a.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +1 -0
- package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +11 -0
- package/dist/web/assets/PublicProjectsPage-DdH0oGB3.js +1 -0
- package/dist/web/assets/ReviewPage-BYIpcIHG.js +1 -0
- package/dist/web/assets/SearchableSelect-Dz13ClQb.js +1 -0
- package/dist/web/assets/SettingsPage-2VkbXgZi.js +1 -0
- package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
- package/dist/web/assets/WritingTastePage-BOPQZuva.js +1 -0
- package/dist/web/assets/{console-DMvHxKf9.js → console-BlAhujsH.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-Dnlcw7Rj.js → conversation-launcher-settings-DTYxiLJt.js} +1 -1
- package/dist/web/assets/i18n-Ci3heuiL.js +9 -0
- package/dist/web/assets/{identity-C9mLdR8L.js → identity-4_-v6cB7.js} +1 -1
- package/dist/web/assets/index-BnH5mcZx.css +1 -0
- package/dist/web/assets/index-C67Q0JY2.js +2 -0
- package/dist/web/assets/{model-CsYAR8O3.js → model-yG0YqUyZ.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-BLQiPVw0.js → useApi-CROJJdhE-twhdZArA.js} +1 -1
- package/dist/web/assets/vue-router-C-wot_Di.js +1 -0
- package/dist/web/index.html +6 -6
- package/docs/public-personal-architecture.md +73 -0
- package/graph-provider/fuli_graph/app.py +8 -0
- package/graph-provider/fuli_graph/collaboration_context.py +69 -6
- package/graph-provider/fuli_graph/knowledge_search.py +84 -5
- package/graph-provider/fuli_graph/model_validation.py +134 -0
- package/graph-provider/fuli_graph/models.py +24 -5
- package/graph-provider/fuli_graph/project_agent_access.py +54 -0
- package/graph-provider/fuli_graph/project_agent_executor_models.py +635 -0
- package/graph-provider/fuli_graph/project_agent_models.py +278 -0
- package/graph-provider/fuli_graph/project_agent_routes.py +597 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +542 -0
- package/graph-provider/fuli_graph/runtime.py +259 -0
- package/graph-provider/fuli_graph/search_projection.py +3 -0
- package/graph-provider/fuli_graph/store.py +6 -0
- package/graph-provider/fuli_graph/store_knowledge.py +62 -9
- package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
- package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
- package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
- package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +699 -0
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +409 -0
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +1144 -0
- package/graph-provider/fuli_graph/store_project_agents.py +965 -0
- package/graph-provider/pyproject.toml +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/skills/capturing-session-knowledge/SKILL.md +43 -2
- package/src/adaptive-runtime/executor-pool.js +127 -0
- package/src/adaptive-runtime/graph-services.js +166 -0
- package/src/adaptive-runtime/lease-client.js +335 -0
- package/src/adaptive-runtime/runtime-broker.js +318 -0
- package/src/adaptive-runtime/settings.js +111 -0
- package/src/adaptive-runtime/state.js +74 -0
- package/src/agent-tools/graph-definitions.js +396 -0
- package/src/agent-tools/graph-handlers.js +61 -0
- package/src/agent-tools/project-agent-definitions.js +307 -0
- package/src/backup/graph-backup.js +289 -0
- package/src/backup/identity-reconciliation.js +208 -0
- package/src/backup/runtime-adapters.js +269 -0
- package/src/cli/command-registry.js +7 -2
- package/src/cli/graph-data-command.js +112 -0
- package/src/cli/local-runtime-command.js +16 -2
- package/src/cli/setup-command.js +19 -3
- package/src/cli/update-command.js +4 -0
- package/src/cli/workspace-connection-command.js +333 -0
- package/src/cli.js +11 -0
- package/src/graphiti/collaboration-preference-projection.js +29 -1
- package/src/graphiti/collaboration-preference-workflow.js +121 -0
- package/src/graphiti/federated-application.js +74 -112
- package/src/graphiti/knowledge-provider-mapping.js +7 -1
- package/src/graphiti/project-agent-control-plane.js +263 -0
- package/src/graphiti/project-agent-mapping.js +1003 -0
- package/src/graphiti/project-agent-workflows.js +557 -0
- package/src/graphiti/provider-client.js +270 -1
- package/src/graphiti/runtime-config.js +6 -1
- package/src/graphiti/workspace-provider-client.js +403 -0
- package/src/http/api-router.js +63 -9
- package/src/http/graph-api-router.js +278 -0
- package/src/local-runtime/lifecycle.js +46 -10
- package/src/mcp/context-resources.js +15 -6
- package/src/mcp/create-mcp-server.js +51 -16
- package/src/mcp/instructions.js +7 -5
- package/src/mcp/runtime.js +17 -5
- package/src/mcp/session-id.js +38 -1
- package/src/mcp/tool-annotations.js +69 -0
- package/src/mcp-server.js +23 -6
- package/src/native-runtime/runtime.js +717 -0
- package/src/server.js +6 -2
- package/src/setup/agent-installation-status.js +7 -1
- package/src/setup/agents.js +3 -1
- package/src/setup/claude-code-config.js +3 -1
- package/src/setup/codex-bootstrap.js +11 -10
- package/src/setup/codex-config.js +7 -1
- package/src/setup/cursor-config.js +3 -1
- package/src/setup/graph-runtime.js +158 -13
- package/src/setup/neo4j-memory-profile.js +43 -0
- package/src/setup/options.js +38 -2
- package/src/setup/paths.js +14 -0
- package/src/setup/setup.js +37 -1
- package/src/system/resource-monitor.js +181 -16
- package/src/system/runtime-settings.js +10 -1
- package/src/system/system-service.js +27 -2
- package/dist/web/assets/AboutPage-CftRexSY.js +0 -1
- package/dist/web/assets/ConnectionsPage-D5J91x6d.js +0 -1
- package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +0 -1
- package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +0 -1
- package/dist/web/assets/KnowledgePage-OUT07aKH.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +0 -4
- package/dist/web/assets/OverviewPage-CHE6XhdS.js +0 -1
- package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +0 -1
- package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +0 -1
- package/dist/web/assets/ReviewPage-DUeyu7E8.js +0 -1
- package/dist/web/assets/SearchableSelect-C5RiB5F8.js +0 -1
- package/dist/web/assets/SettingsPage-BMuASSzQ.js +0 -1
- package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
- package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +0 -1
- package/dist/web/assets/i18n-CWfd8Dtt.js +0 -5
- package/dist/web/assets/index-X2j29j8i.css +0 -1
- package/dist/web/assets/index-nP5p5OaY.js +0 -2
- package/dist/web/assets/vue-router-DN8x6iY1.js +0 -1
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
"""Persistent executor directory and policy-aware Agent routing.
|
|
2
|
+
|
|
3
|
+
This mixin is intentionally independent from the HTTP router and from the
|
|
4
|
+
worker/scheduler. ``GraphStore`` can compose it when its public endpoints are
|
|
5
|
+
ready. Every mutation is scoped through the existing space/project
|
|
6
|
+
authorizers, and every selection records the exact rule, candidate order,
|
|
7
|
+
fallback, or blocking reason that was used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import HTTPException
|
|
17
|
+
|
|
18
|
+
from .personal_project_access import authorize_personal_project
|
|
19
|
+
from .project_agent_executor_models import (
|
|
20
|
+
ProjectAgentExecutorActualReport,
|
|
21
|
+
ProjectAgentExecutorAuthorization,
|
|
22
|
+
ProjectAgentExecutorHealthReport,
|
|
23
|
+
ProjectAgentExecutorModelRecord,
|
|
24
|
+
ProjectAgentExecutorPermissionStatus,
|
|
25
|
+
ProjectAgentExecutorPreflightReport,
|
|
26
|
+
ProjectAgentExecutorRecord,
|
|
27
|
+
ProjectAgentExecutorRegistration,
|
|
28
|
+
ProjectAgentExecutorRegistrationStatus,
|
|
29
|
+
ProjectAgentExecutorRoutingRuleCreate,
|
|
30
|
+
ProjectAgentExecutorRoutingRuleRecord,
|
|
31
|
+
ProjectAgentExecutorRoutingRuleUpdate,
|
|
32
|
+
ProjectAgentExecutorScope,
|
|
33
|
+
project_agent_model_strategy_key,
|
|
34
|
+
)
|
|
35
|
+
from .project_agent_models import ProjectAgentExecutorPolicy, ProjectAgentModelStrategy
|
|
36
|
+
from .provider_values import native_datetime, now_utc, stable_uuid
|
|
37
|
+
from .store_project_agent_executor_learning import StoreProjectAgentExecutorLearning
|
|
38
|
+
from .store_project_agent_executor_routing import StoreProjectAgentExecutorRouting
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StoreProjectAgentExecutors(
|
|
42
|
+
StoreProjectAgentExecutorRouting,
|
|
43
|
+
StoreProjectAgentExecutorLearning,
|
|
44
|
+
):
|
|
45
|
+
"""Durable executor directory, routing rules, and outcome evidence."""
|
|
46
|
+
|
|
47
|
+
async def register_project_agent_executor(
|
|
48
|
+
self,
|
|
49
|
+
actor: dict,
|
|
50
|
+
request: ProjectAgentExecutorRegistration,
|
|
51
|
+
) -> ProjectAgentExecutorRecord:
|
|
52
|
+
self._require_personal()
|
|
53
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
54
|
+
executor_id = stable_uuid(
|
|
55
|
+
self.settings.provider_id,
|
|
56
|
+
'project-agent-executor',
|
|
57
|
+
request.personal_space_id,
|
|
58
|
+
request.executor_id,
|
|
59
|
+
)
|
|
60
|
+
payload_hash = self._payload_hash(request)
|
|
61
|
+
timestamp = now_utc()
|
|
62
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
63
|
+
'''
|
|
64
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
|
|
65
|
+
MERGE (executor:FuliProjectAgentExecutor {id: $executor_id})
|
|
66
|
+
ON CREATE SET executor.executor_id = $public_executor_id,
|
|
67
|
+
executor.personal_space_id = $personal_space_id,
|
|
68
|
+
executor.payload_hash = $payload_hash,
|
|
69
|
+
executor.display_name = $display_name,
|
|
70
|
+
executor.executor_kind = $executor_kind,
|
|
71
|
+
executor.capabilities = $capabilities,
|
|
72
|
+
executor.advertised_models_json = $models_json,
|
|
73
|
+
executor.global_priority = $global_priority,
|
|
74
|
+
executor.health_required = $health_required,
|
|
75
|
+
executor.registration_status = 'registered',
|
|
76
|
+
executor.permission_status = 'pending',
|
|
77
|
+
executor.preflight_status = 'not_run',
|
|
78
|
+
executor.health_status = 'unknown',
|
|
79
|
+
executor.revision = 0,
|
|
80
|
+
executor.test_source = $test_source,
|
|
81
|
+
executor.cleanup_eligible = $cleanup_eligible,
|
|
82
|
+
executor.registered_at = $timestamp,
|
|
83
|
+
executor.updated_at = $timestamp
|
|
84
|
+
MERGE (space)-[permission:HAS_EXECUTOR_PERMISSION]->(executor)
|
|
85
|
+
ON CREATE SET permission.status = 'pending',
|
|
86
|
+
permission.revision = 0,
|
|
87
|
+
permission.updated_at = $timestamp
|
|
88
|
+
RETURN executor, permission
|
|
89
|
+
''',
|
|
90
|
+
personal_space_id=request.personal_space_id,
|
|
91
|
+
executor_id=executor_id,
|
|
92
|
+
public_executor_id=request.executor_id,
|
|
93
|
+
payload_hash=payload_hash,
|
|
94
|
+
display_name=request.display_name,
|
|
95
|
+
executor_kind=request.executor_kind,
|
|
96
|
+
capabilities=request.capabilities,
|
|
97
|
+
models_json=json.dumps(
|
|
98
|
+
[item.model_dump(mode='json') for item in request.advertised_models],
|
|
99
|
+
sort_keys=True,
|
|
100
|
+
),
|
|
101
|
+
global_priority=request.global_priority,
|
|
102
|
+
health_required=request.health_required,
|
|
103
|
+
test_source=request.test_source,
|
|
104
|
+
cleanup_eligible=request.cleanup_eligible,
|
|
105
|
+
timestamp=timestamp,
|
|
106
|
+
)
|
|
107
|
+
if not records:
|
|
108
|
+
raise HTTPException(status_code=404, detail='personal space not found')
|
|
109
|
+
row = records[0]
|
|
110
|
+
raw = dict(row.get('executor') or {})
|
|
111
|
+
if raw.get('payload_hash') not in {None, payload_hash}:
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=409,
|
|
114
|
+
detail='executor ID was registered with different input',
|
|
115
|
+
)
|
|
116
|
+
return self._executor_from_row(row, request.personal_space_id)
|
|
117
|
+
|
|
118
|
+
async def authorize_project_agent_executor(
|
|
119
|
+
self,
|
|
120
|
+
actor: dict,
|
|
121
|
+
request: ProjectAgentExecutorAuthorization,
|
|
122
|
+
) -> ProjectAgentExecutorRecord:
|
|
123
|
+
self._require_personal()
|
|
124
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
125
|
+
timestamp = now_utc()
|
|
126
|
+
payload_hash = self._payload_hash(request)
|
|
127
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
128
|
+
'''
|
|
129
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
130
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
131
|
+
(executor:FuliProjectAgentExecutor {
|
|
132
|
+
executor_id: $executor_id
|
|
133
|
+
})
|
|
134
|
+
WITH executor, permission,
|
|
135
|
+
coalesce(
|
|
136
|
+
permission.authorization_idempotency_key = $idempotency_key,
|
|
137
|
+
false
|
|
138
|
+
) AS same_key
|
|
139
|
+
WHERE (same_key
|
|
140
|
+
AND permission.authorization_payload_hash = $payload_hash)
|
|
141
|
+
OR (NOT same_key AND (
|
|
142
|
+
$expected_revision IS NULL
|
|
143
|
+
OR coalesce(permission.revision, 0) = $expected_revision))
|
|
144
|
+
FOREACH (_ IN CASE WHEN same_key THEN [] ELSE [1] END |
|
|
145
|
+
SET permission.status = $permission_status,
|
|
146
|
+
permission.reason = $reason,
|
|
147
|
+
permission.authorization_idempotency_key = $idempotency_key,
|
|
148
|
+
permission.authorization_payload_hash = $payload_hash,
|
|
149
|
+
permission.revision = coalesce(permission.revision, 0) + 1,
|
|
150
|
+
permission.updated_at = $timestamp,
|
|
151
|
+
executor.permission_status = $permission_status,
|
|
152
|
+
executor.updated_at = $timestamp,
|
|
153
|
+
executor.revision = coalesce(executor.revision, 0) + 1
|
|
154
|
+
)
|
|
155
|
+
RETURN executor, permission
|
|
156
|
+
''',
|
|
157
|
+
personal_space_id=request.personal_space_id,
|
|
158
|
+
executor_id=request.executor_id,
|
|
159
|
+
permission_status=request.status,
|
|
160
|
+
reason=request.reason,
|
|
161
|
+
expected_revision=request.expected_revision,
|
|
162
|
+
idempotency_key=request.idempotency_key,
|
|
163
|
+
payload_hash=payload_hash,
|
|
164
|
+
timestamp=timestamp,
|
|
165
|
+
)
|
|
166
|
+
if not records:
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=409,
|
|
169
|
+
detail='executor permission is missing or revision is stale',
|
|
170
|
+
)
|
|
171
|
+
row = records[0]
|
|
172
|
+
return self._executor_from_row(row, request.personal_space_id)
|
|
173
|
+
|
|
174
|
+
async def upsert_project_agent_executor(
|
|
175
|
+
self,
|
|
176
|
+
actor: dict,
|
|
177
|
+
request,
|
|
178
|
+
) -> ProjectAgentExecutorRecord:
|
|
179
|
+
"""Register or revision-guarded edit of a directory entry.
|
|
180
|
+
|
|
181
|
+
``register`` is intentionally create/idempotent. Callers that expose
|
|
182
|
+
an editable upsert must supply ``expected_revision`` so capability or
|
|
183
|
+
model changes reset preflight and remain auditable.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
if request.expected_revision is None:
|
|
187
|
+
return await self.register_project_agent_executor(actor, request)
|
|
188
|
+
self._require_personal()
|
|
189
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
190
|
+
timestamp = now_utc()
|
|
191
|
+
payload_hash = self._payload_hash(request)
|
|
192
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
193
|
+
'''
|
|
194
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
195
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
196
|
+
(executor:FuliProjectAgentExecutor {
|
|
197
|
+
executor_id: $executor_id
|
|
198
|
+
})
|
|
199
|
+
WHERE coalesce(executor.revision, 0) = $expected_revision
|
|
200
|
+
SET executor.display_name = $display_name,
|
|
201
|
+
executor.executor_kind = $executor_kind,
|
|
202
|
+
executor.capabilities = $capabilities,
|
|
203
|
+
executor.advertised_models_json = $models_json,
|
|
204
|
+
executor.global_priority = $global_priority,
|
|
205
|
+
executor.health_required = $health_required,
|
|
206
|
+
executor.registration_status = 'registered',
|
|
207
|
+
executor.preflight_status = 'not_run',
|
|
208
|
+
executor.preflight_passed = false,
|
|
209
|
+
executor.preflight_reason = 'directory entry changed; preflight required',
|
|
210
|
+
executor.payload_hash = $payload_hash,
|
|
211
|
+
executor.test_source = $test_source,
|
|
212
|
+
executor.cleanup_eligible = $cleanup_eligible,
|
|
213
|
+
executor.updated_at = $timestamp,
|
|
214
|
+
executor.revision = coalesce(executor.revision, 0) + 1
|
|
215
|
+
RETURN executor, permission
|
|
216
|
+
''',
|
|
217
|
+
personal_space_id=request.personal_space_id,
|
|
218
|
+
executor_id=request.executor_id,
|
|
219
|
+
expected_revision=request.expected_revision,
|
|
220
|
+
display_name=request.display_name,
|
|
221
|
+
executor_kind=request.executor_kind,
|
|
222
|
+
capabilities=request.capabilities,
|
|
223
|
+
models_json=json.dumps(
|
|
224
|
+
[item.model_dump(mode='json') for item in request.advertised_models],
|
|
225
|
+
sort_keys=True,
|
|
226
|
+
),
|
|
227
|
+
global_priority=request.global_priority,
|
|
228
|
+
health_required=request.health_required,
|
|
229
|
+
payload_hash=self._payload_hash(request),
|
|
230
|
+
test_source=request.test_source,
|
|
231
|
+
cleanup_eligible=request.cleanup_eligible,
|
|
232
|
+
timestamp=timestamp,
|
|
233
|
+
)
|
|
234
|
+
if not records:
|
|
235
|
+
raise HTTPException(status_code=409, detail='executor revision is stale or missing')
|
|
236
|
+
return self._executor_from_row(records[0], request.personal_space_id)
|
|
237
|
+
|
|
238
|
+
async def record_project_agent_executor_preflight(
|
|
239
|
+
self,
|
|
240
|
+
actor: dict,
|
|
241
|
+
request: ProjectAgentExecutorPreflightReport,
|
|
242
|
+
) -> ProjectAgentExecutorRecord:
|
|
243
|
+
self._require_personal()
|
|
244
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
245
|
+
timestamp = now_utc()
|
|
246
|
+
payload_hash = self._payload_hash(request)
|
|
247
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
248
|
+
'''
|
|
249
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
250
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
251
|
+
(executor:FuliProjectAgentExecutor {
|
|
252
|
+
executor_id: $executor_id
|
|
253
|
+
})
|
|
254
|
+
WITH executor, permission,
|
|
255
|
+
coalesce(
|
|
256
|
+
executor.preflight_idempotency_key = $idempotency_key,
|
|
257
|
+
false
|
|
258
|
+
) AS same_key
|
|
259
|
+
WHERE (same_key AND executor.preflight_payload_hash = $payload_hash)
|
|
260
|
+
OR NOT same_key
|
|
261
|
+
FOREACH (_ IN CASE WHEN same_key THEN [] ELSE [1] END |
|
|
262
|
+
SET executor.preflight_status = $preflight_status,
|
|
263
|
+
executor.preflight_passed = $preflight_passed,
|
|
264
|
+
executor.preflight_reason = $reason,
|
|
265
|
+
executor.preflight_at = $checked_at,
|
|
266
|
+
executor.capabilities = $capabilities,
|
|
267
|
+
executor.available_models_json = $models_json,
|
|
268
|
+
executor.workspace_permission = $workspace_permission,
|
|
269
|
+
executor.preflight_idempotency_key = $idempotency_key,
|
|
270
|
+
executor.preflight_payload_hash = $payload_hash,
|
|
271
|
+
executor.updated_at = $timestamp,
|
|
272
|
+
executor.revision = coalesce(executor.revision, 0) + 1
|
|
273
|
+
)
|
|
274
|
+
RETURN executor, permission
|
|
275
|
+
''',
|
|
276
|
+
personal_space_id=request.personal_space_id,
|
|
277
|
+
executor_id=request.executor_id,
|
|
278
|
+
preflight_status=request.status,
|
|
279
|
+
preflight_passed=request.status == 'passed',
|
|
280
|
+
reason=request.reason,
|
|
281
|
+
checked_at=request.checked_at,
|
|
282
|
+
capabilities=request.capabilities,
|
|
283
|
+
models_json=json.dumps(
|
|
284
|
+
[item.model_dump(mode='json') for item in request.available_models],
|
|
285
|
+
sort_keys=True,
|
|
286
|
+
),
|
|
287
|
+
workspace_permission=request.workspace_permission,
|
|
288
|
+
idempotency_key=request.idempotency_key,
|
|
289
|
+
payload_hash=payload_hash,
|
|
290
|
+
timestamp=timestamp,
|
|
291
|
+
)
|
|
292
|
+
if not records:
|
|
293
|
+
raise HTTPException(status_code=404, detail='registered executor not found')
|
|
294
|
+
return self._executor_from_row(records[0], request.personal_space_id)
|
|
295
|
+
|
|
296
|
+
async def record_project_agent_executor_health(
|
|
297
|
+
self,
|
|
298
|
+
actor: dict,
|
|
299
|
+
request: ProjectAgentExecutorHealthReport,
|
|
300
|
+
) -> ProjectAgentExecutorRecord:
|
|
301
|
+
self._require_personal()
|
|
302
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
303
|
+
timestamp = now_utc()
|
|
304
|
+
payload_hash = self._payload_hash(request)
|
|
305
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
306
|
+
'''
|
|
307
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
308
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
309
|
+
(executor:FuliProjectAgentExecutor {
|
|
310
|
+
executor_id: $executor_id
|
|
311
|
+
})
|
|
312
|
+
WITH executor, permission,
|
|
313
|
+
coalesce(
|
|
314
|
+
executor.health_idempotency_key = $idempotency_key,
|
|
315
|
+
false
|
|
316
|
+
) AS same_key
|
|
317
|
+
WHERE (same_key AND executor.health_payload_hash = $payload_hash)
|
|
318
|
+
OR NOT same_key
|
|
319
|
+
FOREACH (_ IN CASE WHEN same_key THEN [] ELSE [1] END |
|
|
320
|
+
SET executor.health_status = $health_status,
|
|
321
|
+
executor.health_reason = $reason,
|
|
322
|
+
executor.health_checked_at = $checked_at,
|
|
323
|
+
executor.health_idempotency_key = $idempotency_key,
|
|
324
|
+
executor.health_payload_hash = $payload_hash,
|
|
325
|
+
executor.updated_at = $timestamp,
|
|
326
|
+
executor.revision = coalesce(executor.revision, 0) + 1
|
|
327
|
+
)
|
|
328
|
+
RETURN executor, permission
|
|
329
|
+
''',
|
|
330
|
+
personal_space_id=request.personal_space_id,
|
|
331
|
+
executor_id=request.executor_id,
|
|
332
|
+
health_status=request.status,
|
|
333
|
+
reason=request.reason,
|
|
334
|
+
checked_at=request.checked_at,
|
|
335
|
+
idempotency_key=request.idempotency_key,
|
|
336
|
+
payload_hash=payload_hash,
|
|
337
|
+
timestamp=timestamp,
|
|
338
|
+
)
|
|
339
|
+
if not records:
|
|
340
|
+
raise HTTPException(status_code=404, detail='registered executor not found')
|
|
341
|
+
return self._executor_from_row(records[0], request.personal_space_id)
|
|
342
|
+
|
|
343
|
+
async def list_project_agent_executors(
|
|
344
|
+
self,
|
|
345
|
+
actor: dict,
|
|
346
|
+
personal_space_id: str,
|
|
347
|
+
*,
|
|
348
|
+
capability: str | None = None,
|
|
349
|
+
available_only: bool = False,
|
|
350
|
+
) -> list[ProjectAgentExecutorRecord]:
|
|
351
|
+
self._require_personal()
|
|
352
|
+
await self.authorize(actor, personal_space_id, 'reader')
|
|
353
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
354
|
+
'''
|
|
355
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
356
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
357
|
+
(executor:FuliProjectAgentExecutor)
|
|
358
|
+
WHERE ($capability IS NULL OR any(item IN coalesce(executor.capabilities, [])
|
|
359
|
+
WHERE toLower(item) CONTAINS toLower($capability)))
|
|
360
|
+
AND ($available_only = false OR (
|
|
361
|
+
executor.registration_status = 'registered'
|
|
362
|
+
AND permission.status = 'authorized'
|
|
363
|
+
AND executor.preflight_status = 'passed'
|
|
364
|
+
AND coalesce(executor.workspace_permission, false) = true
|
|
365
|
+
AND executor.health_status <> 'unhealthy'
|
|
366
|
+
))
|
|
367
|
+
RETURN executor, permission
|
|
368
|
+
ORDER BY coalesce(executor.global_priority, 100), executor.executor_id
|
|
369
|
+
''',
|
|
370
|
+
personal_space_id=personal_space_id,
|
|
371
|
+
capability=capability,
|
|
372
|
+
available_only=available_only,
|
|
373
|
+
routing_='r',
|
|
374
|
+
)
|
|
375
|
+
return [
|
|
376
|
+
self._executor_from_row(row, personal_space_id)
|
|
377
|
+
for row in records
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
async def get_project_agent_executor(
|
|
381
|
+
self,
|
|
382
|
+
actor: dict,
|
|
383
|
+
personal_space_id: str,
|
|
384
|
+
executor_id: str,
|
|
385
|
+
) -> ProjectAgentExecutorRecord:
|
|
386
|
+
records = await self.list_project_agent_executors(actor, personal_space_id)
|
|
387
|
+
for record in records:
|
|
388
|
+
if record.executor_id == executor_id:
|
|
389
|
+
return record
|
|
390
|
+
raise HTTPException(status_code=404, detail='executor not found')
|
|
391
|
+
|
|
392
|
+
async def archive_project_agent_executor(
|
|
393
|
+
self,
|
|
394
|
+
actor: dict,
|
|
395
|
+
personal_space_id: str,
|
|
396
|
+
executor_id: str,
|
|
397
|
+
*,
|
|
398
|
+
reason: str,
|
|
399
|
+
) -> ProjectAgentExecutorRecord:
|
|
400
|
+
"""Disable one scoped executor without erasing its audit history."""
|
|
401
|
+
|
|
402
|
+
self._require_personal()
|
|
403
|
+
await self.authorize(actor, personal_space_id, 'maintainer')
|
|
404
|
+
reason = reason.strip()
|
|
405
|
+
if not reason:
|
|
406
|
+
raise HTTPException(status_code=422, detail='executor archive reason is required')
|
|
407
|
+
timestamp = now_utc()
|
|
408
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
409
|
+
'''
|
|
410
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
411
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
412
|
+
(executor:FuliProjectAgentExecutor {
|
|
413
|
+
executor_id: $executor_id
|
|
414
|
+
})
|
|
415
|
+
SET executor.registration_status = 'disabled',
|
|
416
|
+
executor.permission_status = 'revoked',
|
|
417
|
+
executor.archive_reason = $reason,
|
|
418
|
+
executor.archived_at = $timestamp,
|
|
419
|
+
executor.updated_at = $timestamp,
|
|
420
|
+
executor.revision = coalesce(executor.revision, 0) + 1,
|
|
421
|
+
permission.status = 'revoked',
|
|
422
|
+
permission.reason = $reason,
|
|
423
|
+
permission.updated_at = $timestamp,
|
|
424
|
+
permission.revision = coalesce(permission.revision, 0) + 1
|
|
425
|
+
RETURN executor, permission
|
|
426
|
+
''',
|
|
427
|
+
personal_space_id=personal_space_id,
|
|
428
|
+
executor_id=executor_id,
|
|
429
|
+
reason=reason,
|
|
430
|
+
timestamp=timestamp,
|
|
431
|
+
)
|
|
432
|
+
if not records:
|
|
433
|
+
raise HTTPException(status_code=404, detail='executor not found')
|
|
434
|
+
return self._executor_from_row(records[0], personal_space_id)
|
|
435
|
+
|
|
436
|
+
async def update_project_agent_executor_priority(
|
|
437
|
+
self,
|
|
438
|
+
actor: dict,
|
|
439
|
+
request,
|
|
440
|
+
) -> ProjectAgentExecutorRecord:
|
|
441
|
+
self._require_personal()
|
|
442
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
443
|
+
timestamp = now_utc()
|
|
444
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
445
|
+
'''
|
|
446
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
447
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
448
|
+
(executor:FuliProjectAgentExecutor {
|
|
449
|
+
executor_id: $executor_id
|
|
450
|
+
})
|
|
451
|
+
WHERE coalesce(executor.revision, 0) = $expected_revision
|
|
452
|
+
SET executor.global_priority = $global_priority,
|
|
453
|
+
executor.priority_reason = $reason,
|
|
454
|
+
executor.priority_updated_at = $timestamp,
|
|
455
|
+
executor.updated_at = $timestamp,
|
|
456
|
+
executor.revision = coalesce(executor.revision, 0) + 1
|
|
457
|
+
RETURN executor, permission
|
|
458
|
+
''',
|
|
459
|
+
personal_space_id=request.personal_space_id,
|
|
460
|
+
executor_id=request.executor_id,
|
|
461
|
+
expected_revision=request.expected_revision,
|
|
462
|
+
global_priority=request.global_priority,
|
|
463
|
+
reason=request.reason,
|
|
464
|
+
timestamp=timestamp,
|
|
465
|
+
)
|
|
466
|
+
if not records:
|
|
467
|
+
raise HTTPException(status_code=409, detail='executor priority revision is stale')
|
|
468
|
+
return self._executor_from_row(records[0], request.personal_space_id)
|
|
469
|
+
|
|
470
|
+
async def create_project_agent_executor_routing_rule(
|
|
471
|
+
self,
|
|
472
|
+
actor: dict,
|
|
473
|
+
request: ProjectAgentExecutorRoutingRuleCreate,
|
|
474
|
+
) -> ProjectAgentExecutorRoutingRuleRecord:
|
|
475
|
+
self._require_personal()
|
|
476
|
+
await self._authorize_rule_scope(actor, request)
|
|
477
|
+
rule_id = stable_uuid(
|
|
478
|
+
self.settings.provider_id,
|
|
479
|
+
'project-agent-executor-rule',
|
|
480
|
+
request.scope,
|
|
481
|
+
request.personal_space_id or '',
|
|
482
|
+
request.personal_project_id or '',
|
|
483
|
+
request.task_id or '',
|
|
484
|
+
request.idempotency_key,
|
|
485
|
+
)
|
|
486
|
+
payload_hash = self._payload_hash(request)
|
|
487
|
+
timestamp = now_utc()
|
|
488
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
489
|
+
'''
|
|
490
|
+
MERGE (rule:FuliProjectAgentExecutorRoutingRule {id: $rule_id})
|
|
491
|
+
ON CREATE SET rule.rule_id = $rule_id,
|
|
492
|
+
rule.payload_hash = $payload_hash,
|
|
493
|
+
rule.scope = $scope,
|
|
494
|
+
rule.personal_space_id = $personal_space_id,
|
|
495
|
+
rule.personal_project_id = $personal_project_id,
|
|
496
|
+
rule.task_id = $task_id,
|
|
497
|
+
rule.work_kind = $work_kind,
|
|
498
|
+
rule.required_capabilities = $required_capabilities,
|
|
499
|
+
rule.executor_ids = $executor_ids,
|
|
500
|
+
rule.model_strategy_json = $model_strategy_json,
|
|
501
|
+
rule.priority = $priority,
|
|
502
|
+
rule.reason = $reason,
|
|
503
|
+
rule.status = 'active',
|
|
504
|
+
rule.revision = 0,
|
|
505
|
+
rule.created_at = $timestamp,
|
|
506
|
+
rule.updated_at = $timestamp
|
|
507
|
+
RETURN rule
|
|
508
|
+
''',
|
|
509
|
+
rule_id=rule_id,
|
|
510
|
+
payload_hash=payload_hash,
|
|
511
|
+
scope=request.scope,
|
|
512
|
+
personal_space_id=request.personal_space_id,
|
|
513
|
+
personal_project_id=request.personal_project_id,
|
|
514
|
+
task_id=request.task_id,
|
|
515
|
+
work_kind=request.work_kind,
|
|
516
|
+
required_capabilities=request.required_capabilities,
|
|
517
|
+
executor_ids=request.executor_ids,
|
|
518
|
+
model_strategy_json=(
|
|
519
|
+
request.model_strategy.model_dump_json()
|
|
520
|
+
if request.model_strategy else None
|
|
521
|
+
),
|
|
522
|
+
priority=request.priority,
|
|
523
|
+
reason=request.reason,
|
|
524
|
+
timestamp=timestamp,
|
|
525
|
+
)
|
|
526
|
+
if not records:
|
|
527
|
+
raise HTTPException(status_code=404, detail='routing rule was not stored')
|
|
528
|
+
raw = dict(records[0].get('rule') or {})
|
|
529
|
+
if raw.get('payload_hash') not in {None, payload_hash}:
|
|
530
|
+
raise HTTPException(
|
|
531
|
+
status_code=409,
|
|
532
|
+
detail='routing rule idempotency key was reused with different input',
|
|
533
|
+
)
|
|
534
|
+
return self._routing_rule_from_raw(raw)
|
|
535
|
+
|
|
536
|
+
async def list_project_agent_executor_routing_rules(
|
|
537
|
+
self,
|
|
538
|
+
actor: dict,
|
|
539
|
+
*,
|
|
540
|
+
personal_space_id: str | None = None,
|
|
541
|
+
personal_project_id: str | None = None,
|
|
542
|
+
task_id: str | None = None,
|
|
543
|
+
scope: ProjectAgentExecutorScope | None = None,
|
|
544
|
+
status: str | None = None,
|
|
545
|
+
) -> list[ProjectAgentExecutorRoutingRuleRecord]:
|
|
546
|
+
self._require_personal()
|
|
547
|
+
if personal_space_id:
|
|
548
|
+
await self.authorize(actor, personal_space_id, 'reader')
|
|
549
|
+
if personal_project_id:
|
|
550
|
+
space = await self.authorize(actor, personal_space_id, 'reader')
|
|
551
|
+
await authorize_personal_project(
|
|
552
|
+
self,
|
|
553
|
+
actor,
|
|
554
|
+
space,
|
|
555
|
+
personal_project_id,
|
|
556
|
+
)
|
|
557
|
+
elif not actor.get('provider_admin'):
|
|
558
|
+
raise HTTPException(status_code=403, detail='provider administrator required')
|
|
559
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
560
|
+
'''
|
|
561
|
+
MATCH (rule:FuliProjectAgentExecutorRoutingRule)
|
|
562
|
+
WHERE ($personal_space_id IS NULL
|
|
563
|
+
OR rule.personal_space_id = $personal_space_id
|
|
564
|
+
OR rule.scope = 'global')
|
|
565
|
+
AND ($personal_project_id IS NULL
|
|
566
|
+
OR rule.personal_project_id = $personal_project_id)
|
|
567
|
+
AND ($task_id IS NULL OR rule.task_id = $task_id)
|
|
568
|
+
AND ($scope IS NULL OR rule.scope = $scope)
|
|
569
|
+
AND ($status IS NULL OR rule.status = $status)
|
|
570
|
+
RETURN rule
|
|
571
|
+
ORDER BY CASE rule.scope
|
|
572
|
+
WHEN 'task' THEN 3
|
|
573
|
+
WHEN 'project' THEN 2
|
|
574
|
+
WHEN 'space' THEN 1
|
|
575
|
+
ELSE 0 END DESC,
|
|
576
|
+
coalesce(rule.priority, 100), rule.rule_id
|
|
577
|
+
''',
|
|
578
|
+
personal_space_id=personal_space_id,
|
|
579
|
+
personal_project_id=personal_project_id,
|
|
580
|
+
task_id=task_id,
|
|
581
|
+
scope=scope,
|
|
582
|
+
status=status,
|
|
583
|
+
routing_='r',
|
|
584
|
+
)
|
|
585
|
+
return [self._routing_rule_from_raw(dict(row['rule'])) for row in records]
|
|
586
|
+
|
|
587
|
+
async def update_project_agent_executor_routing_rule(
|
|
588
|
+
self,
|
|
589
|
+
actor: dict,
|
|
590
|
+
request: ProjectAgentExecutorRoutingRuleUpdate,
|
|
591
|
+
) -> ProjectAgentExecutorRoutingRuleRecord:
|
|
592
|
+
self._require_personal()
|
|
593
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
594
|
+
timestamp = now_utc()
|
|
595
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
596
|
+
'''
|
|
597
|
+
MATCH (rule:FuliProjectAgentExecutorRoutingRule {
|
|
598
|
+
rule_id: $rule_id,
|
|
599
|
+
personal_space_id: $personal_space_id
|
|
600
|
+
})
|
|
601
|
+
WHERE coalesce(rule.revision, 0) = $expected_revision
|
|
602
|
+
SET rule.status = $status,
|
|
603
|
+
rule.update_reason = $reason,
|
|
604
|
+
rule.updated_at = $timestamp,
|
|
605
|
+
rule.revision = coalesce(rule.revision, 0) + 1
|
|
606
|
+
RETURN rule
|
|
607
|
+
''',
|
|
608
|
+
personal_space_id=request.personal_space_id,
|
|
609
|
+
rule_id=request.rule_id,
|
|
610
|
+
expected_revision=request.expected_revision,
|
|
611
|
+
status=request.status,
|
|
612
|
+
reason=request.reason,
|
|
613
|
+
timestamp=timestamp,
|
|
614
|
+
)
|
|
615
|
+
if not records:
|
|
616
|
+
raise HTTPException(status_code=409, detail='routing rule revision is stale')
|
|
617
|
+
return self._routing_rule_from_raw(dict(records[0]['rule']))
|
|
618
|
+
|
|
619
|
+
async def get_project_agent_executor_routing_rule(
|
|
620
|
+
self,
|
|
621
|
+
actor: dict,
|
|
622
|
+
personal_space_id: str,
|
|
623
|
+
rule_id: str,
|
|
624
|
+
) -> ProjectAgentExecutorRoutingRuleRecord:
|
|
625
|
+
rules = await self.list_project_agent_executor_routing_rules(
|
|
626
|
+
actor,
|
|
627
|
+
personal_space_id=personal_space_id,
|
|
628
|
+
)
|
|
629
|
+
for rule in rules:
|
|
630
|
+
if rule.rule_id == rule_id:
|
|
631
|
+
return rule
|
|
632
|
+
raise HTTPException(status_code=404, detail='executor routing rule not found')
|
|
633
|
+
|
|
634
|
+
async def archive_project_agent_executor_routing_rule(
|
|
635
|
+
self,
|
|
636
|
+
actor: dict,
|
|
637
|
+
personal_space_id: str,
|
|
638
|
+
rule_id: str,
|
|
639
|
+
*,
|
|
640
|
+
reason: str,
|
|
641
|
+
) -> ProjectAgentExecutorRoutingRuleRecord:
|
|
642
|
+
"""End a scoped rule while retaining every past routing decision."""
|
|
643
|
+
|
|
644
|
+
self._require_personal()
|
|
645
|
+
await self.authorize(actor, personal_space_id, 'maintainer')
|
|
646
|
+
reason = reason.strip()
|
|
647
|
+
if not reason:
|
|
648
|
+
raise HTTPException(status_code=422, detail='routing rule archive reason is required')
|
|
649
|
+
timestamp = now_utc()
|
|
650
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
651
|
+
'''
|
|
652
|
+
MATCH (rule:FuliProjectAgentExecutorRoutingRule {
|
|
653
|
+
rule_id: $rule_id,
|
|
654
|
+
personal_space_id: $personal_space_id
|
|
655
|
+
})
|
|
656
|
+
SET rule.status = 'ended',
|
|
657
|
+
rule.update_reason = $reason,
|
|
658
|
+
rule.ended_at = $timestamp,
|
|
659
|
+
rule.updated_at = $timestamp,
|
|
660
|
+
rule.revision = coalesce(rule.revision, 0) + 1
|
|
661
|
+
RETURN rule
|
|
662
|
+
''',
|
|
663
|
+
personal_space_id=personal_space_id,
|
|
664
|
+
rule_id=rule_id,
|
|
665
|
+
reason=reason,
|
|
666
|
+
timestamp=timestamp,
|
|
667
|
+
)
|
|
668
|
+
if not records:
|
|
669
|
+
raise HTTPException(status_code=404, detail='executor routing rule not found')
|
|
670
|
+
return self._routing_rule_from_raw(dict(records[0]['rule']))
|
|
671
|
+
|
|
672
|
+
async def record_project_agent_executor_actual(
|
|
673
|
+
self,
|
|
674
|
+
actor: dict,
|
|
675
|
+
request: ProjectAgentExecutorActualReport,
|
|
676
|
+
) -> ProjectAgentExecutorActualReport:
|
|
677
|
+
"""Persist the executor/model actually observed by a Task Run."""
|
|
678
|
+
|
|
679
|
+
self._require_personal()
|
|
680
|
+
space = await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
681
|
+
await authorize_personal_project(
|
|
682
|
+
self,
|
|
683
|
+
actor,
|
|
684
|
+
space,
|
|
685
|
+
request.personal_project_id,
|
|
686
|
+
)
|
|
687
|
+
validation_records, _, _ = await self.runtime.driver.execute_query(
|
|
688
|
+
'''
|
|
689
|
+
MATCH (task:FuliProjectAgentTask {
|
|
690
|
+
personal_space_id: $personal_space_id,
|
|
691
|
+
personal_project_id: $personal_project_id,
|
|
692
|
+
task_id: $task_id
|
|
693
|
+
})
|
|
694
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
695
|
+
[permission:HAS_EXECUTOR_PERMISSION]->
|
|
696
|
+
(executor:FuliProjectAgentExecutor {
|
|
697
|
+
executor_id: $executor_id
|
|
698
|
+
})
|
|
699
|
+
MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
700
|
+
(agent:FuliProjectAgent {agent_id: $agent_id})
|
|
701
|
+
MATCH (task)-[:HAS_PARTICIPANT]->(agent)
|
|
702
|
+
WHERE permission.status = 'authorized'
|
|
703
|
+
AND executor.registration_status = 'registered'
|
|
704
|
+
AND executor.preflight_status = 'passed'
|
|
705
|
+
AND coalesce(executor.workspace_permission, false) = true
|
|
706
|
+
RETURN task, agent, executor, permission
|
|
707
|
+
''',
|
|
708
|
+
personal_space_id=request.personal_space_id,
|
|
709
|
+
personal_project_id=request.personal_project_id,
|
|
710
|
+
task_id=request.task_id,
|
|
711
|
+
executor_id=request.executor_id,
|
|
712
|
+
agent_id=request.agent_id,
|
|
713
|
+
routing_='r',
|
|
714
|
+
)
|
|
715
|
+
if not validation_records:
|
|
716
|
+
raise HTTPException(
|
|
717
|
+
status_code=409,
|
|
718
|
+
detail='actual executor is not registered, authorized, and preflighted',
|
|
719
|
+
)
|
|
720
|
+
validation_row = validation_records[0]
|
|
721
|
+
task_raw = dict(validation_row.get('task') or {})
|
|
722
|
+
task_strategy = ProjectAgentModelStrategy()
|
|
723
|
+
task_strategy_json = task_raw.get('effective_model_strategy_json')
|
|
724
|
+
if task_strategy_json:
|
|
725
|
+
try:
|
|
726
|
+
task_strategy = ProjectAgentModelStrategy.model_validate_json(
|
|
727
|
+
task_strategy_json
|
|
728
|
+
)
|
|
729
|
+
except Exception as exc:
|
|
730
|
+
raise HTTPException(
|
|
731
|
+
status_code=409,
|
|
732
|
+
detail='task model strategy is invalid and must be repaired',
|
|
733
|
+
) from exc
|
|
734
|
+
if project_agent_model_strategy_key(
|
|
735
|
+
request.model_strategy
|
|
736
|
+
) != project_agent_model_strategy_key(task_strategy):
|
|
737
|
+
raise HTTPException(
|
|
738
|
+
status_code=409,
|
|
739
|
+
detail='actual model strategy does not match the task strategy',
|
|
740
|
+
)
|
|
741
|
+
executor_record = self._executor_from_row(
|
|
742
|
+
validation_row,
|
|
743
|
+
request.personal_space_id,
|
|
744
|
+
)
|
|
745
|
+
reported_model = next(
|
|
746
|
+
(
|
|
747
|
+
item
|
|
748
|
+
for item in executor_record.available_models
|
|
749
|
+
if item.available
|
|
750
|
+
and item.provider == request.provider
|
|
751
|
+
and item.model == request.model
|
|
752
|
+
),
|
|
753
|
+
None,
|
|
754
|
+
)
|
|
755
|
+
if reported_model is None:
|
|
756
|
+
raise HTTPException(
|
|
757
|
+
status_code=409,
|
|
758
|
+
detail='actual model was not reported by the latest passed preflight',
|
|
759
|
+
)
|
|
760
|
+
if (
|
|
761
|
+
executor_record.health_status == 'unhealthy'
|
|
762
|
+
or (
|
|
763
|
+
executor_record.health_required
|
|
764
|
+
and executor_record.health_status != 'healthy'
|
|
765
|
+
)
|
|
766
|
+
):
|
|
767
|
+
raise HTTPException(
|
|
768
|
+
status_code=409,
|
|
769
|
+
detail='actual executor health does not permit execution',
|
|
770
|
+
)
|
|
771
|
+
agent_policy = self._policy_from_agent(
|
|
772
|
+
dict(validation_row.get('agent') or {})
|
|
773
|
+
)
|
|
774
|
+
effective_policy = agent_policy
|
|
775
|
+
task_policy_json = task_raw.get('executor_policy_json')
|
|
776
|
+
if task_policy_json:
|
|
777
|
+
try:
|
|
778
|
+
effective_policy = ProjectAgentExecutorPolicy.model_validate_json(
|
|
779
|
+
task_policy_json
|
|
780
|
+
)
|
|
781
|
+
except Exception as exc:
|
|
782
|
+
raise HTTPException(
|
|
783
|
+
status_code=409,
|
|
784
|
+
detail='task executor policy is invalid and must be repaired',
|
|
785
|
+
) from exc
|
|
786
|
+
# An Agent lock remains authoritative even if an older Task projection
|
|
787
|
+
# contains a different effective policy.
|
|
788
|
+
if agent_policy.mode == 'locked':
|
|
789
|
+
effective_policy = agent_policy
|
|
790
|
+
if (
|
|
791
|
+
effective_policy.mode == 'locked'
|
|
792
|
+
and request.executor_id not in effective_policy.locked_executor_ids
|
|
793
|
+
):
|
|
794
|
+
raise HTTPException(
|
|
795
|
+
status_code=409,
|
|
796
|
+
detail='actual executor is outside the effective locked allow-list',
|
|
797
|
+
)
|
|
798
|
+
selected_executor_id = task_raw.get('selected_executor_id')
|
|
799
|
+
if (
|
|
800
|
+
selected_executor_id
|
|
801
|
+
and selected_executor_id != request.executor_id
|
|
802
|
+
and not request.fallback_reason
|
|
803
|
+
):
|
|
804
|
+
raise HTTPException(
|
|
805
|
+
status_code=409,
|
|
806
|
+
detail='actual executor differs from the selection without a fallback reason',
|
|
807
|
+
)
|
|
808
|
+
observation_id = stable_uuid(
|
|
809
|
+
self.settings.provider_id,
|
|
810
|
+
request.personal_space_id,
|
|
811
|
+
'project-agent-executor-observation',
|
|
812
|
+
request.task_id,
|
|
813
|
+
request.run_id,
|
|
814
|
+
request.idempotency_key,
|
|
815
|
+
)
|
|
816
|
+
payload_hash = self._payload_hash(request)
|
|
817
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
818
|
+
'''
|
|
819
|
+
MATCH (task:FuliProjectAgentTask {
|
|
820
|
+
personal_space_id: $personal_space_id,
|
|
821
|
+
personal_project_id: $personal_project_id,
|
|
822
|
+
task_id: $task_id
|
|
823
|
+
})
|
|
824
|
+
MERGE (observation:FuliProjectAgentExecutorObservation {
|
|
825
|
+
id: $observation_id
|
|
826
|
+
})
|
|
827
|
+
ON CREATE SET observation.observation_id = $observation_id,
|
|
828
|
+
observation.payload_hash = $payload_hash,
|
|
829
|
+
observation.task_id = $task_id,
|
|
830
|
+
observation.run_id = $run_id,
|
|
831
|
+
observation.executor_id = $executor_id,
|
|
832
|
+
observation.provider = $provider,
|
|
833
|
+
observation.model = $model,
|
|
834
|
+
observation.agent_id = $agent_id,
|
|
835
|
+
observation.model_strategy_json = $model_strategy_json,
|
|
836
|
+
observation.model_strategy_source = $model_strategy_source,
|
|
837
|
+
observation.matched_rule_id = $matched_rule_id,
|
|
838
|
+
observation.fallback_reason = $fallback_reason,
|
|
839
|
+
observation.occurred_at = $occurred_at,
|
|
840
|
+
observation.source_application = $source_application,
|
|
841
|
+
observation.source_session_id = $source_session_id
|
|
842
|
+
WITH task, observation,
|
|
843
|
+
observation.payload_hash = $payload_hash AS payload_matches,
|
|
844
|
+
coalesce(observation.projection_applied, false) = false
|
|
845
|
+
AS projection_pending
|
|
846
|
+
FOREACH (_ IN CASE
|
|
847
|
+
WHEN payload_matches AND projection_pending THEN [1] ELSE [] END |
|
|
848
|
+
MERGE (task)-[:HAS_EXECUTOR_OBSERVATION]->(observation)
|
|
849
|
+
SET task.actual_executor_id = $executor_id,
|
|
850
|
+
task.actual_run_id = $run_id,
|
|
851
|
+
task.actual_model_provider = $provider,
|
|
852
|
+
task.actual_model = $model,
|
|
853
|
+
task.actual_model_strategy_json = $model_strategy_json,
|
|
854
|
+
task.actual_model_strategy_source = $model_strategy_source,
|
|
855
|
+
task.matched_executor_rule_id = $matched_rule_id,
|
|
856
|
+
task.executor_fallback_reason = $fallback_reason,
|
|
857
|
+
task.updated_at = $occurred_at,
|
|
858
|
+
observation.projection_applied = true
|
|
859
|
+
)
|
|
860
|
+
RETURN task, observation, payload_matches
|
|
861
|
+
''',
|
|
862
|
+
personal_space_id=request.personal_space_id,
|
|
863
|
+
personal_project_id=request.personal_project_id,
|
|
864
|
+
task_id=request.task_id,
|
|
865
|
+
observation_id=observation_id,
|
|
866
|
+
payload_hash=payload_hash,
|
|
867
|
+
run_id=request.run_id,
|
|
868
|
+
agent_id=request.agent_id,
|
|
869
|
+
executor_id=request.executor_id,
|
|
870
|
+
provider=request.provider,
|
|
871
|
+
model=request.model,
|
|
872
|
+
model_strategy_json=request.model_strategy.model_dump_json(),
|
|
873
|
+
model_strategy_source=request.model_strategy_source,
|
|
874
|
+
matched_rule_id=request.matched_rule_id,
|
|
875
|
+
fallback_reason=request.fallback_reason,
|
|
876
|
+
occurred_at=request.occurred_at,
|
|
877
|
+
source_application=request.source_application,
|
|
878
|
+
source_session_id=request.source_session_id,
|
|
879
|
+
)
|
|
880
|
+
if not records:
|
|
881
|
+
raise HTTPException(status_code=404, detail='project Agent task not found')
|
|
882
|
+
row = records[0]
|
|
883
|
+
observation = dict(row.get('observation') or {})
|
|
884
|
+
payload_matches = row.get(
|
|
885
|
+
'payload_matches',
|
|
886
|
+
observation.get('payload_hash') == payload_hash,
|
|
887
|
+
)
|
|
888
|
+
if payload_matches is not True:
|
|
889
|
+
raise HTTPException(
|
|
890
|
+
status_code=409,
|
|
891
|
+
detail='executor observation idempotency key was reused',
|
|
892
|
+
)
|
|
893
|
+
return request
|
|
894
|
+
|
|
895
|
+
async def archive_test_project_agents(
|
|
896
|
+
self,
|
|
897
|
+
actor: dict,
|
|
898
|
+
personal_space_id: str,
|
|
899
|
+
*,
|
|
900
|
+
test_source: str,
|
|
901
|
+
) -> int:
|
|
902
|
+
"""Archive only explicitly marked test identities; never delete them."""
|
|
903
|
+
|
|
904
|
+
self._require_personal()
|
|
905
|
+
await self.authorize(actor, personal_space_id, 'maintainer')
|
|
906
|
+
if not test_source.strip():
|
|
907
|
+
raise HTTPException(status_code=422, detail='test source is required')
|
|
908
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
909
|
+
'''
|
|
910
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
911
|
+
[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
912
|
+
(agent:FuliProjectAgent)
|
|
913
|
+
WHERE agent.test_source = $test_source
|
|
914
|
+
AND coalesce(agent.cleanup_eligible, false) = true
|
|
915
|
+
SET agent.status = 'archived',
|
|
916
|
+
agent.updated_at = $updated_at,
|
|
917
|
+
agent.archived_reason = 'test cleanup'
|
|
918
|
+
WITH space, agent
|
|
919
|
+
OPTIONAL MATCH (space)-[:CONTAINS_PROJECT]->
|
|
920
|
+
(:FuliPersonalProject)-
|
|
921
|
+
[:HAS_PROJECT_AGENT_ASSIGNMENT]->
|
|
922
|
+
(assignment:FuliProjectAgentAssignment)-
|
|
923
|
+
[:ASSIGNED_AGENT]->(agent)
|
|
924
|
+
WHERE assignment.status = 'active'
|
|
925
|
+
SET assignment.status = 'ended',
|
|
926
|
+
assignment.end_reason = 'test Agent archived',
|
|
927
|
+
assignment.ended_at = $updated_at,
|
|
928
|
+
assignment.updated_at = $updated_at,
|
|
929
|
+
assignment.revision = coalesce(assignment.revision, 0) + 1
|
|
930
|
+
RETURN count(DISTINCT agent) AS archived_count
|
|
931
|
+
''',
|
|
932
|
+
personal_space_id=personal_space_id,
|
|
933
|
+
test_source=test_source,
|
|
934
|
+
updated_at=now_utc(),
|
|
935
|
+
)
|
|
936
|
+
return int(records[0].get('archived_count', 0)) if records else 0
|
|
937
|
+
|
|
938
|
+
# Short aliases make the mixin convenient for provider/facade adapters and
|
|
939
|
+
# keep the public naming independent from the eventual HTTP route names.
|
|
940
|
+
register_executor = register_project_agent_executor
|
|
941
|
+
upsert_executor = upsert_project_agent_executor
|
|
942
|
+
authorize_executor = authorize_project_agent_executor
|
|
943
|
+
record_executor_preflight = record_project_agent_executor_preflight
|
|
944
|
+
record_executor_health = record_project_agent_executor_health
|
|
945
|
+
list_executors = list_project_agent_executors
|
|
946
|
+
get_executor = get_project_agent_executor
|
|
947
|
+
archive_executor = archive_project_agent_executor
|
|
948
|
+
update_executor_priority = update_project_agent_executor_priority
|
|
949
|
+
create_executor_routing_rule = create_project_agent_executor_routing_rule
|
|
950
|
+
list_executor_routing_rules = list_project_agent_executor_routing_rules
|
|
951
|
+
update_executor_routing_rule = update_project_agent_executor_routing_rule
|
|
952
|
+
get_executor_routing_rule = get_project_agent_executor_routing_rule
|
|
953
|
+
archive_executor_routing_rule = archive_project_agent_executor_routing_rule
|
|
954
|
+
record_task_executor_actual = record_project_agent_executor_actual
|
|
955
|
+
|
|
956
|
+
@staticmethod
|
|
957
|
+
def _payload_hash(value: Any) -> str:
|
|
958
|
+
if hasattr(value, 'model_dump'):
|
|
959
|
+
payload = value.model_dump(mode='json', exclude={'idempotency_key'})
|
|
960
|
+
else:
|
|
961
|
+
payload = value
|
|
962
|
+
return hashlib.sha256(
|
|
963
|
+
json.dumps(payload, sort_keys=True, separators=(',', ':'), default=str).encode()
|
|
964
|
+
).hexdigest()
|
|
965
|
+
|
|
966
|
+
async def _authorize_rule_scope(
|
|
967
|
+
self,
|
|
968
|
+
actor: dict,
|
|
969
|
+
request: ProjectAgentExecutorRoutingRuleCreate,
|
|
970
|
+
) -> None:
|
|
971
|
+
if request.scope == 'global':
|
|
972
|
+
if not actor.get('provider_admin'):
|
|
973
|
+
raise HTTPException(
|
|
974
|
+
status_code=403,
|
|
975
|
+
detail='provider administrator required for global executor rules',
|
|
976
|
+
)
|
|
977
|
+
return
|
|
978
|
+
if not request.personal_space_id:
|
|
979
|
+
raise HTTPException(status_code=422, detail='routing rule space is required')
|
|
980
|
+
space = await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
981
|
+
if request.personal_project_id:
|
|
982
|
+
await authorize_personal_project(
|
|
983
|
+
self,
|
|
984
|
+
actor,
|
|
985
|
+
space,
|
|
986
|
+
request.personal_project_id,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
@staticmethod
|
|
990
|
+
def _json_list(value: Any) -> list[Any]:
|
|
991
|
+
if value is None:
|
|
992
|
+
return []
|
|
993
|
+
if isinstance(value, str):
|
|
994
|
+
try:
|
|
995
|
+
value = json.loads(value)
|
|
996
|
+
except (TypeError, ValueError):
|
|
997
|
+
return []
|
|
998
|
+
return list(value) if isinstance(value, (list, tuple)) else []
|
|
999
|
+
|
|
1000
|
+
@classmethod
|
|
1001
|
+
def _models_from_raw(cls, value: Any) -> list[ProjectAgentExecutorModelRecord]:
|
|
1002
|
+
result = []
|
|
1003
|
+
for item in cls._json_list(value):
|
|
1004
|
+
try:
|
|
1005
|
+
result.append(ProjectAgentExecutorModelRecord.model_validate(item))
|
|
1006
|
+
except Exception:
|
|
1007
|
+
continue
|
|
1008
|
+
return result
|
|
1009
|
+
|
|
1010
|
+
@classmethod
|
|
1011
|
+
def _executor_from_row(
|
|
1012
|
+
cls,
|
|
1013
|
+
row: dict,
|
|
1014
|
+
personal_space_id: str,
|
|
1015
|
+
) -> ProjectAgentExecutorRecord:
|
|
1016
|
+
raw = dict(row.get('executor') or row)
|
|
1017
|
+
permission = dict(row.get('permission') or {})
|
|
1018
|
+
available_models = cls._models_from_raw(
|
|
1019
|
+
raw.get('available_models_json')
|
|
1020
|
+
or raw.get('advertised_models_json')
|
|
1021
|
+
or raw.get('available_models')
|
|
1022
|
+
)
|
|
1023
|
+
permission_status = (
|
|
1024
|
+
permission.get('status')
|
|
1025
|
+
or raw.get('permission_status')
|
|
1026
|
+
or 'pending'
|
|
1027
|
+
)
|
|
1028
|
+
workspace_permission = raw.get(
|
|
1029
|
+
'workspace_permission',
|
|
1030
|
+
permission_status == 'authorized',
|
|
1031
|
+
)
|
|
1032
|
+
registered_at = native_datetime(raw.get('registered_at')) or now_utc()
|
|
1033
|
+
updated_at = native_datetime(raw.get('updated_at')) or registered_at
|
|
1034
|
+
return ProjectAgentExecutorRecord(
|
|
1035
|
+
executor_id=raw.get('executor_id') or raw.get('public_executor_id') or raw.get('id'),
|
|
1036
|
+
display_name=raw.get('display_name') or raw.get('executor_id') or 'executor',
|
|
1037
|
+
executor_kind=raw.get('executor_kind') or 'external',
|
|
1038
|
+
registration_status=raw.get('registration_status') or 'registered',
|
|
1039
|
+
permission_status=permission_status,
|
|
1040
|
+
preflight_status=raw.get('preflight_status') or 'not_run',
|
|
1041
|
+
health_status=raw.get('health_status') or 'unknown',
|
|
1042
|
+
health_required=bool(raw.get('health_required', False)),
|
|
1043
|
+
workspace_permission=bool(workspace_permission),
|
|
1044
|
+
capabilities=list(raw.get('capabilities') or []),
|
|
1045
|
+
available_models=available_models,
|
|
1046
|
+
global_priority=int(raw.get('global_priority', 100)),
|
|
1047
|
+
revision=int(raw.get('revision', 0)),
|
|
1048
|
+
permission_revision=int(permission.get('revision', 0)),
|
|
1049
|
+
preflight_at=native_datetime(raw.get('preflight_at')),
|
|
1050
|
+
health_checked_at=native_datetime(raw.get('health_checked_at')),
|
|
1051
|
+
registered_at=registered_at,
|
|
1052
|
+
updated_at=updated_at,
|
|
1053
|
+
test_source=raw.get('test_source'),
|
|
1054
|
+
cleanup_eligible=bool(raw.get('cleanup_eligible', False)),
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
@staticmethod
|
|
1058
|
+
def _routing_rule_from_raw(raw: dict) -> ProjectAgentExecutorRoutingRuleRecord:
|
|
1059
|
+
model_strategy_json = raw.get('model_strategy_json')
|
|
1060
|
+
model_strategy = None
|
|
1061
|
+
if model_strategy_json:
|
|
1062
|
+
try:
|
|
1063
|
+
model_strategy = ProjectAgentModelStrategy.model_validate_json(
|
|
1064
|
+
model_strategy_json
|
|
1065
|
+
)
|
|
1066
|
+
except Exception:
|
|
1067
|
+
model_strategy = None
|
|
1068
|
+
return ProjectAgentExecutorRoutingRuleRecord(
|
|
1069
|
+
rule_id=raw.get('rule_id') or raw.get('id'),
|
|
1070
|
+
scope=raw.get('scope') or 'global',
|
|
1071
|
+
personal_space_id=raw.get('personal_space_id'),
|
|
1072
|
+
personal_project_id=raw.get('personal_project_id'),
|
|
1073
|
+
task_id=raw.get('task_id'),
|
|
1074
|
+
work_kind=raw['work_kind'],
|
|
1075
|
+
required_capabilities=list(raw.get('required_capabilities') or []),
|
|
1076
|
+
executor_ids=list(raw.get('executor_ids') or []),
|
|
1077
|
+
model_strategy=model_strategy,
|
|
1078
|
+
priority=int(raw.get('priority', 100)),
|
|
1079
|
+
reason=raw.get('reason') or 'routing rule',
|
|
1080
|
+
idempotency_key=raw.get('idempotency_key') or 'stored-rule-key',
|
|
1081
|
+
status=raw.get('status') or 'active',
|
|
1082
|
+
revision=int(raw.get('revision', 0)),
|
|
1083
|
+
created_at=native_datetime(raw.get('created_at')) or now_utc(),
|
|
1084
|
+
updated_at=native_datetime(raw.get('updated_at'))
|
|
1085
|
+
or native_datetime(raw.get('created_at'))
|
|
1086
|
+
or now_utc(),
|
|
1087
|
+
)
|