fuli-context 0.7.4-beta.3 → 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.
Files changed (128) hide show
  1. package/README.md +77 -6
  2. package/README.zh-CN.md +69 -6
  3. package/compose.graphiti.yml +6 -6
  4. package/dist/web/assets/AboutPage-ByCUor-j.js +1 -0
  5. package/dist/web/assets/ConnectionsPage-DkdkZ40Q.js +1 -0
  6. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +1 -0
  7. package/dist/web/assets/KnowledgeInspector-CYAjAb9J.js +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CV4F54R_.js +1 -0
  9. package/dist/web/assets/KnowledgePage-vYYXipOi.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-z4u5mXOG.js +4 -0
  11. package/dist/web/assets/OverviewPage-BWOfeS6k.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-c3w4-h7a.js +1 -0
  13. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +1 -0
  14. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +1 -0
  15. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +11 -0
  16. package/dist/web/assets/PublicProjectsPage-DdH0oGB3.js +1 -0
  17. package/dist/web/assets/ReviewPage-BYIpcIHG.js +1 -0
  18. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +1 -0
  19. package/dist/web/assets/SettingsPage-2VkbXgZi.js +1 -0
  20. package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
  21. package/dist/web/assets/WritingTastePage-BOPQZuva.js +1 -0
  22. package/dist/web/assets/{console-D_2zC4lm.js → console-BlAhujsH.js} +1 -1
  23. package/dist/web/assets/{conversation-launcher-settings-DQmMH2j7.js → conversation-launcher-settings-DTYxiLJt.js} +1 -1
  24. package/dist/web/assets/i18n-Ci3heuiL.js +9 -0
  25. package/dist/web/assets/{identity-DmGcY3Y6.js → identity-4_-v6cB7.js} +1 -1
  26. package/dist/web/assets/index-BnH5mcZx.css +1 -0
  27. package/dist/web/assets/index-C67Q0JY2.js +2 -0
  28. package/dist/web/assets/{model-CVoOdRsL.js → model-yG0YqUyZ.js} +1 -1
  29. package/dist/web/assets/{useApi-CROJJdhE-CBNg4CdH.js → useApi-CROJJdhE-twhdZArA.js} +1 -1
  30. package/dist/web/assets/vue-router-C-wot_Di.js +1 -0
  31. package/dist/web/index.html +6 -6
  32. package/graph-provider/fuli_graph/app.py +8 -0
  33. package/graph-provider/fuli_graph/collaboration_context.py +69 -6
  34. package/graph-provider/fuli_graph/knowledge_search.py +84 -5
  35. package/graph-provider/fuli_graph/model_validation.py +130 -0
  36. package/graph-provider/fuli_graph/models.py +24 -5
  37. package/graph-provider/fuli_graph/project_agent_access.py +54 -0
  38. package/graph-provider/fuli_graph/project_agent_executor_models.py +635 -0
  39. package/graph-provider/fuli_graph/project_agent_models.py +278 -0
  40. package/graph-provider/fuli_graph/project_agent_routes.py +597 -0
  41. package/graph-provider/fuli_graph/project_agent_task_models.py +542 -0
  42. package/graph-provider/fuli_graph/runtime.py +259 -0
  43. package/graph-provider/fuli_graph/search_projection.py +3 -0
  44. package/graph-provider/fuli_graph/store.py +6 -0
  45. package/graph-provider/fuli_graph/store_knowledge.py +62 -9
  46. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
  47. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
  48. package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
  49. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
  50. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +699 -0
  51. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +409 -0
  52. package/graph-provider/fuli_graph/store_project_agent_tasks.py +1144 -0
  53. package/graph-provider/fuli_graph/store_project_agents.py +965 -0
  54. package/graph-provider/pyproject.toml +2 -1
  55. package/npm-shrinkwrap.json +2 -2
  56. package/package.json +1 -1
  57. package/skills/capturing-session-knowledge/SKILL.md +39 -0
  58. package/src/adaptive-runtime/executor-pool.js +127 -0
  59. package/src/adaptive-runtime/graph-services.js +166 -0
  60. package/src/adaptive-runtime/lease-client.js +335 -0
  61. package/src/adaptive-runtime/runtime-broker.js +318 -0
  62. package/src/adaptive-runtime/settings.js +111 -0
  63. package/src/adaptive-runtime/state.js +74 -0
  64. package/src/agent-tools/graph-definitions.js +396 -0
  65. package/src/agent-tools/graph-handlers.js +61 -0
  66. package/src/agent-tools/project-agent-definitions.js +307 -0
  67. package/src/backup/graph-backup.js +289 -0
  68. package/src/backup/identity-reconciliation.js +208 -0
  69. package/src/backup/runtime-adapters.js +269 -0
  70. package/src/cli/command-registry.js +6 -2
  71. package/src/cli/graph-data-command.js +112 -0
  72. package/src/cli/local-runtime-command.js +16 -2
  73. package/src/cli/setup-command.js +19 -3
  74. package/src/cli/update-command.js +4 -0
  75. package/src/cli.js +6 -0
  76. package/src/graphiti/collaboration-preference-projection.js +29 -1
  77. package/src/graphiti/collaboration-preference-workflow.js +121 -0
  78. package/src/graphiti/federated-application.js +53 -90
  79. package/src/graphiti/project-agent-control-plane.js +263 -0
  80. package/src/graphiti/project-agent-mapping.js +1003 -0
  81. package/src/graphiti/project-agent-workflows.js +557 -0
  82. package/src/graphiti/provider-client.js +270 -1
  83. package/src/http/api-router.js +63 -9
  84. package/src/http/graph-api-router.js +278 -0
  85. package/src/local-runtime/lifecycle.js +46 -10
  86. package/src/mcp/context-resources.js +15 -6
  87. package/src/mcp/create-mcp-server.js +51 -16
  88. package/src/mcp/instructions.js +7 -5
  89. package/src/mcp/runtime.js +17 -5
  90. package/src/mcp/session-id.js +38 -1
  91. package/src/mcp/tool-annotations.js +69 -0
  92. package/src/mcp-server.js +23 -6
  93. package/src/native-runtime/runtime.js +717 -0
  94. package/src/server.js +6 -2
  95. package/src/setup/agent-installation-status.js +7 -1
  96. package/src/setup/agents.js +3 -1
  97. package/src/setup/claude-code-config.js +3 -1
  98. package/src/setup/codex-bootstrap.js +11 -10
  99. package/src/setup/codex-config.js +7 -1
  100. package/src/setup/cursor-config.js +3 -1
  101. package/src/setup/graph-runtime.js +158 -13
  102. package/src/setup/neo4j-memory-profile.js +43 -0
  103. package/src/setup/options.js +38 -2
  104. package/src/setup/paths.js +14 -0
  105. package/src/setup/setup.js +37 -1
  106. package/src/system/resource-monitor.js +181 -16
  107. package/src/system/runtime-settings.js +10 -1
  108. package/src/system/system-service.js +27 -2
  109. package/dist/web/assets/AboutPage-Dpf5fRWv.js +0 -1
  110. package/dist/web/assets/ConnectionsPage-D21aUDCP.js +0 -1
  111. package/dist/web/assets/KnowledgeEditDialog-u7lpfN3j.js +0 -1
  112. package/dist/web/assets/KnowledgeInspector-k4xnHtXd.js +0 -1
  113. package/dist/web/assets/KnowledgeOrganizerPage-Bdj7Rn84.js +0 -1
  114. package/dist/web/assets/KnowledgePage-UHVAbC1W.js +0 -1
  115. package/dist/web/assets/KnowledgeWorkspace-Lq3d-MeC.js +0 -4
  116. package/dist/web/assets/OverviewPage-CycAgA1V.js +0 -1
  117. package/dist/web/assets/PersonalProfilePage-U9oIV5FT.js +0 -1
  118. package/dist/web/assets/PersonalProjectsPage-C5RpXKcA.js +0 -1
  119. package/dist/web/assets/PublicProjectsPage-DiWxm35E.js +0 -1
  120. package/dist/web/assets/ReviewPage-jxRfGSJK.js +0 -1
  121. package/dist/web/assets/SearchableSelect-B3-5IPup.js +0 -1
  122. package/dist/web/assets/SettingsPage-BqfGDOho.js +0 -1
  123. package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
  124. package/dist/web/assets/WritingTastePage-BJxhMqJR.js +0 -1
  125. package/dist/web/assets/i18n-CwqXDI1C.js +0 -5
  126. package/dist/web/assets/index-IUGLt4lG.js +0 -2
  127. package/dist/web/assets/index-X2j29j8i.css +0 -1
  128. package/dist/web/assets/vue-router-CWSsUUio.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
+ )