fuli-context 0.7.5 → 0.7.7
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 +69 -13
- package/README.zh-CN.md +60 -14
- package/dist/web/assets/{AboutPage-ByCUor-j.js → AboutPage-BcZeFcHe.js} +1 -1
- package/dist/web/assets/{ConnectionsPage-DkdkZ40Q.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
- package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
- package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
- package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
- package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
- package/dist/web/assets/{KnowledgeInspector-CYAjAb9J.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-CV4F54R_.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
- package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-z4u5mXOG.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
- package/dist/web/assets/{OverviewPage-BWOfeS6k.js → OverviewPage-Xyfm5xkZ.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-c3w4-h7a.js → PersonalProfilePage-DB9mByB3.js} +1 -1
- package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
- package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
- package/dist/web/assets/{PublicProjectsPage-DdH0oGB3.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
- package/dist/web/assets/{ReviewPage-BYIpcIHG.js → ReviewPage-CQYGwFBH.js} +1 -1
- package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
- package/dist/web/assets/{SettingsPage-2VkbXgZi.js → SettingsPage-BlEZ03Yi.js} +1 -1
- package/dist/web/assets/{WritingTastePage-BOPQZuva.js → WritingTastePage-DvRt_nmP.js} +1 -1
- package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
- package/dist/web/assets/{console-BlAhujsH.js → console-tOxOl8Vq.js} +1 -1
- package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
- package/dist/web/assets/{i18n-Ci3heuiL.js → i18n-CgBHZthI.js} +3 -3
- package/dist/web/assets/{identity-4_-v6cB7.js → identity-CFo_Uw1H.js} +1 -1
- package/dist/web/assets/index-CJc5ppXH.css +1 -0
- package/dist/web/assets/index-yCi3cz2l.js +2 -0
- package/dist/web/assets/{model-yG0YqUyZ.js → model-BtnVr7fX.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-twhdZArA.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
- package/dist/web/assets/{vue-router-C-wot_Di.js → vue-router-CzHm-y-x.js} +1 -1
- package/dist/web/index.html +7 -6
- package/docs/employee-agents.md +99 -0
- package/docs/project-agent-memory.md +479 -0
- package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
- package/graph-provider/fuli_graph/model_base.py +5 -0
- package/graph-provider/fuli_graph/model_validation.py +32 -0
- package/graph-provider/fuli_graph/models.py +26 -170
- package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
- package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -0
- package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
- package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
- package/graph-provider/fuli_graph/project_agent_models.py +9 -0
- package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
- package/graph-provider/fuli_graph/project_agent_routes.py +50 -1
- package/graph-provider/fuli_graph/project_agent_task_models.py +220 -29
- package/graph-provider/fuli_graph/runtime.py +37 -1
- package/graph-provider/fuli_graph/store.py +6 -0
- package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
- package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +122 -0
- package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
- package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
- package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
- package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
- package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
- package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
- package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +756 -164
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +396 -7
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +265 -465
- package/graph-provider/fuli_graph/store_project_agents.py +214 -46
- package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
- package/graph-provider/fuli_graph/store_transactions.py +40 -0
- package/graph-provider/fuli_graph/task_context_models.py +50 -0
- package/graph-provider/fuli_graph/task_context_routes.py +31 -0
- package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +8 -8
- package/package.json +5 -2
- package/skills/capturing-session-knowledge/SKILL.md +18 -6
- package/src/adaptive-runtime/executor-pool.js +46 -5
- package/src/adaptive-runtime/graph-services.js +10 -3
- package/src/adaptive-runtime/lease-client.js +66 -14
- package/src/adaptive-runtime/runtime-broker.js +24 -9
- package/src/agent-tools/employee-definitions.js +43 -0
- package/src/agent-tools/graph-definitions.js +48 -6
- package/src/agent-tools/graph-handlers.js +15 -0
- package/src/agent-tools/project-agent-definitions.js +35 -4
- package/src/agent-tools/project-agent-memory-definitions.js +36 -0
- package/src/agent-tools.js +6 -2
- package/src/agents/codex/lifecycle-hook.js +107 -0
- package/src/agents/codex/lifecycle-hooks.js +231 -0
- package/src/agents/cursor/lifecycle-hook.js +133 -0
- package/src/agents/cursor/lifecycle-hooks.js +50 -0
- package/src/agents/lifecycle-message.js +19 -0
- package/src/app/agent-request-context.js +23 -0
- package/src/cli/command-registry.js +2 -0
- package/src/cli/employee-command.js +17 -0
- package/src/cli/remote-mcp-command.js +282 -0
- package/src/cli/setup-command.js +5 -0
- package/src/cli.js +10 -0
- package/src/employees/catalog/jefa.json +21 -0
- package/src/employees/install-package.js +77 -0
- package/src/employees/management-policy.js +88 -0
- package/src/employees/manifest.js +53 -0
- package/src/employees/package-registry.js +107 -0
- package/src/employees/project-selection.js +50 -0
- package/src/employees/recruitment.js +199 -0
- package/src/employees/service.js +83 -0
- package/src/employees/task-entry.js +39 -0
- package/src/graphiti/agent-knowledge-workflows.js +135 -25
- package/src/graphiti/federated-application.js +78 -35
- package/src/graphiti/project-agent-control-plane.js +32 -3
- package/src/graphiti/project-agent-mapping.js +175 -4
- package/src/graphiti/project-agent-memory.js +115 -0
- package/src/graphiti/project-agent-task-entry.js +126 -0
- package/src/graphiti/project-agent-workflows.js +49 -14
- package/src/graphiti/provider-client.js +78 -4
- package/src/http/api-router.js +25 -5
- package/src/http/employee-api-router.js +64 -0
- package/src/http/error-mapping.js +4 -0
- package/src/http/graph-api-router.js +14 -0
- package/src/http/request-policy.js +6 -3
- package/src/http/static-handler.js +4 -3
- package/src/mcp/create-mcp-server.js +98 -37
- package/src/mcp/direct-call.js +38 -0
- package/src/mcp/instructions.js +8 -7
- package/src/mcp/provider-task-context-registry.js +74 -0
- package/src/mcp/remote-http-server.js +761 -0
- package/src/mcp/session-id.js +29 -3
- package/src/mcp/task-context-registry.js +88 -29
- package/src/mcp/tool-annotations.js +12 -2
- package/src/mcp/tool-result.js +8 -3
- package/src/mcp-server.js +20 -8
- package/src/native-runtime/runtime.js +58 -8
- package/src/server.js +76 -28
- package/src/setup/agent-installation-status.js +38 -6
- package/src/setup/agents.js +20 -8
- package/src/setup/claude-code-config.js +55 -13
- package/src/setup/codex-bootstrap.js +11 -10
- package/src/setup/codex-config.js +74 -7
- package/src/setup/config-backup.js +18 -2
- package/src/setup/cursor-config.js +22 -5
- package/src/setup/setup.js +30 -22
- package/src/storage/json-file.js +11 -3
- package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +0 -1
- package/dist/web/assets/KnowledgePage-vYYXipOi.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +0 -1
- package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +0 -11
- package/dist/web/assets/SearchableSelect-Dz13ClQb.js +0 -1
- package/dist/web/assets/conversation-launcher-settings-DTYxiLJt.js +0 -1
- package/dist/web/assets/index-BnH5mcZx.css +0 -1
- package/dist/web/assets/index-C67Q0JY2.js +0 -2
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from uuid import uuid4
|
|
6
8
|
|
|
7
9
|
from fastapi import HTTPException
|
|
8
10
|
|
|
@@ -13,6 +15,7 @@ from .project_agent_models import (
|
|
|
13
15
|
ProjectAgentProfile,
|
|
14
16
|
ProjectAgentUpsert,
|
|
15
17
|
)
|
|
18
|
+
from .project_agent_recruitment_values import project_agent_recruitment_record
|
|
16
19
|
from .project_agent_task_models import (
|
|
17
20
|
ProjectAgentRecruitmentDecision,
|
|
18
21
|
ProjectAgentRecruitmentPolicyRecord,
|
|
@@ -21,6 +24,10 @@ from .project_agent_task_models import (
|
|
|
21
24
|
ProjectAgentRoutingDecisionRecord,
|
|
22
25
|
)
|
|
23
26
|
from .provider_values import native_datetime, now_utc, stable_uuid
|
|
27
|
+
from .store_transactions import query_store_transaction
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
RECRUITMENT_CLAIM_TTL = timedelta(minutes=2)
|
|
24
31
|
|
|
25
32
|
|
|
26
33
|
class StoreProjectAgentTaskRecruitment:
|
|
@@ -97,22 +104,90 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
97
104
|
space,
|
|
98
105
|
request.personal_project_id,
|
|
99
106
|
)
|
|
107
|
+
updated_at = now_utc()
|
|
108
|
+
decision_status = (
|
|
109
|
+
'cancelled' if request.decision == 'cancel' else 'requested'
|
|
110
|
+
)
|
|
111
|
+
provisioning_claim_id = (
|
|
112
|
+
str(uuid4()) if decision_status == 'requested' else None
|
|
113
|
+
)
|
|
114
|
+
cancel_event_id = stable_uuid(
|
|
115
|
+
self.settings.provider_id,
|
|
116
|
+
request.personal_space_id,
|
|
117
|
+
'project-agent-task-event',
|
|
118
|
+
'recruitment-cancelled',
|
|
119
|
+
request.recruitment_id,
|
|
120
|
+
)
|
|
100
121
|
records, _, _ = await self.runtime.driver.execute_query(
|
|
101
122
|
'''
|
|
102
|
-
MATCH (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
MATCH (space:FuliSpace {
|
|
124
|
+
id: $personal_space_id, kind: 'personal'
|
|
125
|
+
})-[:HAS_PROJECT_AGENT_RECRUITMENT]->
|
|
126
|
+
(recruitment:FuliProjectAgentRecruitment {
|
|
127
|
+
recruitment_id: $recruitment_id,
|
|
128
|
+
personal_project_id: $personal_project_id
|
|
129
|
+
})
|
|
130
|
+
MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
|
|
131
|
+
(task:FuliProjectAgentTask)
|
|
132
|
+
OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
133
|
+
(hr:FuliProjectAgent {agent_type: 'hr', status: 'active'})
|
|
134
|
+
WHERE hr.agent_id = recruitment.hr_agent_id
|
|
135
|
+
WITH recruitment, task, hr
|
|
107
136
|
WHERE recruitment.status = 'awaiting_confirmation'
|
|
108
|
-
AND recruitment.revision = $expected_revision
|
|
137
|
+
AND coalesce(recruitment.revision, 0) = $expected_revision
|
|
138
|
+
AND task.task_id = recruitment.task_id
|
|
139
|
+
AND task.status = 'awaiting_recruitment'
|
|
140
|
+
AND (
|
|
141
|
+
task.recruitment_provisioning_claimed_at IS NULL
|
|
142
|
+
OR task.recruitment_provisioning_claimed_at <=
|
|
143
|
+
$claim_expired_before
|
|
144
|
+
)
|
|
145
|
+
AND (
|
|
146
|
+
$decision_status = 'cancelled'
|
|
147
|
+
OR hr IS NOT NULL
|
|
148
|
+
)
|
|
149
|
+
SET recruitment.status = $decision_status,
|
|
150
|
+
recruitment.decision_reason = $reason,
|
|
151
|
+
recruitment.provisioning_claim_id = $provisioning_claim_id,
|
|
152
|
+
recruitment.provisioning_claimed_at = $provisioning_claimed_at,
|
|
153
|
+
recruitment.revision = coalesce(recruitment.revision, 0) + 1,
|
|
154
|
+
recruitment.updated_at = $updated_at,
|
|
155
|
+
task.revision = coalesce(task.revision, 0) + 1,
|
|
156
|
+
task.updated_at = $updated_at,
|
|
157
|
+
task.recruitment_provisioning_claim_id =
|
|
158
|
+
$provisioning_claim_id,
|
|
159
|
+
task.recruitment_provisioning_claimed_at =
|
|
160
|
+
$provisioning_claimed_at
|
|
161
|
+
FOREACH (_ IN CASE
|
|
162
|
+
WHEN $decision_status = 'cancelled' THEN [1] ELSE [] END |
|
|
163
|
+
SET task.status = 'blocked',
|
|
164
|
+
task.routing_outcome = 'blocked',
|
|
165
|
+
task.executor_blocked_reason = $task_blocked_reason
|
|
166
|
+
MERGE (event:FuliProjectAgentTaskEvent {id: $cancel_event_id})
|
|
167
|
+
ON CREATE SET event.event_id = $cancel_event_id,
|
|
168
|
+
event.task_id = task.task_id,
|
|
169
|
+
event.status = 'blocked',
|
|
170
|
+
event.actor_kind = 'hr',
|
|
171
|
+
event.summary = $task_blocked_reason,
|
|
172
|
+
event.created_at = $updated_at
|
|
173
|
+
MERGE (task)-[:HAS_TASK_EVENT]->(event)
|
|
174
|
+
)
|
|
109
175
|
RETURN recruitment
|
|
110
176
|
''',
|
|
111
177
|
personal_space_id=request.personal_space_id,
|
|
112
178
|
personal_project_id=request.personal_project_id,
|
|
113
179
|
recruitment_id=request.recruitment_id,
|
|
114
180
|
expected_revision=request.expected_revision,
|
|
115
|
-
|
|
181
|
+
decision_status=decision_status,
|
|
182
|
+
reason=request.reason,
|
|
183
|
+
provisioning_claim_id=provisioning_claim_id,
|
|
184
|
+
provisioning_claimed_at=(
|
|
185
|
+
updated_at if provisioning_claim_id else None
|
|
186
|
+
),
|
|
187
|
+
claim_expired_before=updated_at - RECRUITMENT_CLAIM_TTL,
|
|
188
|
+
task_blocked_reason=f'recruitment cancelled: {request.reason}',
|
|
189
|
+
cancel_event_id=cancel_event_id,
|
|
190
|
+
updated_at=updated_at,
|
|
116
191
|
)
|
|
117
192
|
if not records:
|
|
118
193
|
raise HTTPException(
|
|
@@ -120,40 +195,14 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
120
195
|
detail='recruitment is not awaiting confirmation or revision is stale',
|
|
121
196
|
)
|
|
122
197
|
raw = dict(records[0]['recruitment'])
|
|
123
|
-
updated_at = now_utc()
|
|
124
198
|
if request.decision == 'cancel':
|
|
125
|
-
await self.runtime.driver.execute_query(
|
|
126
|
-
'''
|
|
127
|
-
MATCH (recruitment:FuliProjectAgentRecruitment {
|
|
128
|
-
recruitment_id: $recruitment_id,
|
|
129
|
-
personal_space_id: $personal_space_id
|
|
130
|
-
})
|
|
131
|
-
SET recruitment.status = 'cancelled',
|
|
132
|
-
recruitment.decision_reason = $reason,
|
|
133
|
-
recruitment.revision = recruitment.revision + 1,
|
|
134
|
-
recruitment.updated_at = $updated_at
|
|
135
|
-
''',
|
|
136
|
-
recruitment_id=request.recruitment_id,
|
|
137
|
-
personal_space_id=request.personal_space_id,
|
|
138
|
-
reason=request.reason,
|
|
139
|
-
updated_at=updated_at,
|
|
140
|
-
)
|
|
141
|
-
await self._mark_recruitment_task_blocked(
|
|
142
|
-
raw,
|
|
143
|
-
request.reason,
|
|
144
|
-
updated_at,
|
|
145
|
-
)
|
|
146
|
-
raw.update({
|
|
147
|
-
'status': 'cancelled',
|
|
148
|
-
'revision': int(raw.get('revision') or 0) + 1,
|
|
149
|
-
'updated_at': updated_at,
|
|
150
|
-
})
|
|
151
199
|
return self._recruitment(raw)
|
|
152
200
|
if not raw.get('hr_agent_id'):
|
|
153
201
|
raise HTTPException(
|
|
154
202
|
status_code=409,
|
|
155
203
|
detail='recruitment cannot proceed without an active HR Agent',
|
|
156
204
|
)
|
|
205
|
+
raw['_guarded_task_id'] = raw['task_id']
|
|
157
206
|
selected = await self._provision_recruitment(actor, raw)
|
|
158
207
|
await self._activate_approved_recruitment(
|
|
159
208
|
actor,
|
|
@@ -169,52 +218,14 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
169
218
|
raise HTTPException(status_code=404, detail='recruitment not found')
|
|
170
219
|
return record
|
|
171
220
|
|
|
172
|
-
async def _mark_recruitment_task_blocked(self, recruitment, reason, updated_at):
|
|
173
|
-
event_id = stable_uuid(
|
|
174
|
-
self.settings.provider_id,
|
|
175
|
-
recruitment['personal_space_id'],
|
|
176
|
-
'project-agent-task-event',
|
|
177
|
-
recruitment['task_id'],
|
|
178
|
-
'recruitment-cancelled',
|
|
179
|
-
)
|
|
180
|
-
await self.runtime.driver.execute_query(
|
|
181
|
-
'''
|
|
182
|
-
MATCH (space:FuliSpace {
|
|
183
|
-
id: $personal_space_id, kind: 'personal'
|
|
184
|
-
})-[:HAS_PROJECT_AGENT_TASK]->
|
|
185
|
-
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
186
|
-
MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
|
|
187
|
-
(recruitment:FuliProjectAgentRecruitment {
|
|
188
|
-
recruitment_id: $recruitment_id
|
|
189
|
-
})
|
|
190
|
-
SET task.status = 'blocked',
|
|
191
|
-
task.routing_outcome = 'blocked',
|
|
192
|
-
task.executor_blocked_reason = $reason,
|
|
193
|
-
task.updated_at = $updated_at,
|
|
194
|
-
task.revision = coalesce(task.revision, 0) + 1
|
|
195
|
-
MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
|
|
196
|
-
ON CREATE SET event.event_id = $event_id,
|
|
197
|
-
event.task_id = $task_id,
|
|
198
|
-
event.status = 'blocked',
|
|
199
|
-
event.actor_kind = 'hr',
|
|
200
|
-
event.summary = $reason,
|
|
201
|
-
event.created_at = $updated_at
|
|
202
|
-
MERGE (task)-[:HAS_TASK_EVENT]->(event)
|
|
203
|
-
''',
|
|
204
|
-
task_id=recruitment['task_id'],
|
|
205
|
-
personal_space_id=recruitment['personal_space_id'],
|
|
206
|
-
recruitment_id=recruitment['recruitment_id'],
|
|
207
|
-
event_id=event_id,
|
|
208
|
-
reason=f'recruitment cancelled: {reason}',
|
|
209
|
-
updated_at=updated_at,
|
|
210
|
-
)
|
|
211
|
-
|
|
212
221
|
async def _activate_approved_recruitment(
|
|
213
222
|
self,
|
|
214
223
|
actor,
|
|
215
224
|
recruitment,
|
|
216
225
|
selected,
|
|
217
226
|
approval_reason,
|
|
227
|
+
*,
|
|
228
|
+
_attempt=0,
|
|
218
229
|
):
|
|
219
230
|
row = await self._find_task_row(
|
|
220
231
|
recruitment['personal_space_id'],
|
|
@@ -223,6 +234,33 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
223
234
|
if not row:
|
|
224
235
|
raise HTTPException(status_code=404, detail='recruitment task not found')
|
|
225
236
|
raw_task = dict(row['task'])
|
|
237
|
+
linked = await self._link_recruited_task_participant(
|
|
238
|
+
recruitment,
|
|
239
|
+
selected,
|
|
240
|
+
)
|
|
241
|
+
if linked is False:
|
|
242
|
+
raise HTTPException(
|
|
243
|
+
status_code=409,
|
|
244
|
+
detail='recruited project Agent is not active',
|
|
245
|
+
)
|
|
246
|
+
task_recruitments = await self._task_recruitments(
|
|
247
|
+
recruitment['personal_space_id'],
|
|
248
|
+
recruitment['task_id'],
|
|
249
|
+
)
|
|
250
|
+
if task_recruitments and any(
|
|
251
|
+
item.status != 'fulfilled' for item in task_recruitments
|
|
252
|
+
):
|
|
253
|
+
return
|
|
254
|
+
if raw_task.get('status') != 'awaiting_recruitment':
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
routing_selected = await self._recruitment_routing_lead(
|
|
258
|
+
recruitment,
|
|
259
|
+
selected,
|
|
260
|
+
raw_task,
|
|
261
|
+
task_recruitments,
|
|
262
|
+
)
|
|
263
|
+
expected_task_revision = int(raw_task.get('revision') or 0)
|
|
226
264
|
task_model_override = raw_task.get('task_model_strategy_override_json')
|
|
227
265
|
if task_model_override:
|
|
228
266
|
model_strategy = ProjectAgentModelStrategy.model_validate_json(
|
|
@@ -230,7 +268,7 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
230
268
|
)
|
|
231
269
|
model_source = 'task'
|
|
232
270
|
else:
|
|
233
|
-
model_strategy =
|
|
271
|
+
model_strategy = routing_selected['profile'].default_model_strategy
|
|
234
272
|
model_source = 'agent'
|
|
235
273
|
task_executor_override = raw_task.get(
|
|
236
274
|
'task_executor_policy_override_json'
|
|
@@ -243,17 +281,17 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
243
281
|
personal_space_id=recruitment['personal_space_id'],
|
|
244
282
|
personal_project_id=recruitment['personal_project_id'],
|
|
245
283
|
task_id=recruitment['task_id'],
|
|
246
|
-
agent_id=
|
|
284
|
+
agent_id=routing_selected['agent_id'],
|
|
247
285
|
work_kind=raw_task['work_kind'],
|
|
248
286
|
required_capabilities=list(
|
|
249
|
-
raw_task.get('
|
|
287
|
+
raw_task.get('executor_capability_hints') or []
|
|
250
288
|
),
|
|
251
289
|
model_strategy=model_strategy,
|
|
252
290
|
task_override=(
|
|
253
291
|
json.loads(task_executor_override)
|
|
254
292
|
if task_executor_override else None
|
|
255
293
|
),
|
|
256
|
-
assignment_id=
|
|
294
|
+
assignment_id=routing_selected.get('assignment_id'),
|
|
257
295
|
model_strategy_source=model_source,
|
|
258
296
|
idempotency_key=f"recruit:{recruitment['recruitment_id']}",
|
|
259
297
|
)
|
|
@@ -282,7 +320,7 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
282
320
|
outcome=outcome,
|
|
283
321
|
reason=recruitment['reason_code'],
|
|
284
322
|
match_basis=['HR recruitment approved and Agent assigned'],
|
|
285
|
-
candidate_agent_ids=[
|
|
323
|
+
candidate_agent_ids=[routing_selected['agent_id']],
|
|
286
324
|
created_at=now,
|
|
287
325
|
**self._decision_executor_fields(executor_decision),
|
|
288
326
|
)
|
|
@@ -303,14 +341,30 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
303
341
|
recruitment['task_id'],
|
|
304
342
|
'recruitment-approved',
|
|
305
343
|
)
|
|
306
|
-
await self.runtime.driver.execute_query(
|
|
344
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
307
345
|
'''
|
|
308
346
|
MATCH (space:FuliSpace {
|
|
309
347
|
id: $personal_space_id, kind: 'personal'
|
|
310
348
|
})-[:HAS_PROJECT_AGENT_TASK]->
|
|
311
349
|
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
350
|
+
SET task._task_lifecycle_lock = true
|
|
351
|
+
REMOVE task._task_lifecycle_lock
|
|
352
|
+
WITH space, task
|
|
312
353
|
MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
313
354
|
(agent:FuliProjectAgent {agent_id: $agent_id})
|
|
355
|
+
MATCH (task)-[:HAS_PARTICIPANT]->
|
|
356
|
+
(lifecycle_agent:FuliProjectAgent)
|
|
357
|
+
WITH space, task, agent, lifecycle_agent
|
|
358
|
+
ORDER BY lifecycle_agent.agent_id
|
|
359
|
+
SET lifecycle_agent._task_lifecycle_lock = true
|
|
360
|
+
REMOVE lifecycle_agent._task_lifecycle_lock
|
|
361
|
+
WITH space, task, agent,
|
|
362
|
+
collect(lifecycle_agent) AS locked_agents
|
|
363
|
+
WHERE agent IN locked_agents
|
|
364
|
+
AND all(locked_agent IN locked_agents
|
|
365
|
+
WHERE locked_agent.status = 'active')
|
|
366
|
+
AND coalesce(task.revision, 0) = $expected_task_revision
|
|
367
|
+
AND task.status = 'awaiting_recruitment'
|
|
314
368
|
OPTIONAL MATCH (task)-[old_route:ROUTED_BY]->()
|
|
315
369
|
DELETE old_route
|
|
316
370
|
SET task.status = $status,
|
|
@@ -335,6 +389,13 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
335
389
|
participant.assignment_summary = $assignment_summary,
|
|
336
390
|
participant.joined_at = coalesce(participant.joined_at, $updated_at),
|
|
337
391
|
participant.updated_at = $updated_at
|
|
392
|
+
WITH task, agent, locked_agents
|
|
393
|
+
MATCH (task)-[task_participant:HAS_PARTICIPANT]->
|
|
394
|
+
(participant_agent:FuliProjectAgent)
|
|
395
|
+
WHERE participant_agent IN locked_agents
|
|
396
|
+
SET task_participant.status = $status,
|
|
397
|
+
task_participant.updated_at = $updated_at
|
|
398
|
+
WITH DISTINCT task, agent
|
|
338
399
|
MERGE (decision_node:FuliProjectAgentRoutingDecision {id: $decision_id})
|
|
339
400
|
ON CREATE SET decision_node.decision_id = $decision_id,
|
|
340
401
|
decision_node.task_id = $task_id,
|
|
@@ -351,10 +412,11 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
351
412
|
event.created_at = $updated_at
|
|
352
413
|
MERGE (task)-[:HAS_TASK_EVENT]->(event)
|
|
353
414
|
MERGE (event)-[:EVENT_AGENT]->(agent)
|
|
415
|
+
RETURN task
|
|
354
416
|
''',
|
|
355
417
|
task_id=recruitment['task_id'],
|
|
356
418
|
personal_space_id=recruitment['personal_space_id'],
|
|
357
|
-
agent_id=
|
|
419
|
+
agent_id=routing_selected['agent_id'],
|
|
358
420
|
status=status,
|
|
359
421
|
routing_outcome=outcome,
|
|
360
422
|
routing_explanation=explanation,
|
|
@@ -377,13 +439,214 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
377
439
|
fallback_outcome=(executor_raw or {}).get('fallback_outcome'),
|
|
378
440
|
fallback_reason=(executor_raw or {}).get('fallback_reason'),
|
|
379
441
|
blocked_reason=(executor_raw or {}).get('blocked_reason'),
|
|
380
|
-
assignment_summary=
|
|
442
|
+
assignment_summary=routing_selected.get('responsibility'),
|
|
381
443
|
decision_id=decision_id,
|
|
382
444
|
decision_json=decision.model_dump_json(),
|
|
383
445
|
event_id=event_id,
|
|
384
446
|
event_summary=f'HR recruitment approved: {approval_reason}',
|
|
447
|
+
expected_task_revision=expected_task_revision,
|
|
385
448
|
updated_at=now,
|
|
386
449
|
)
|
|
450
|
+
if not records:
|
|
451
|
+
latest = await self._find_task_row(
|
|
452
|
+
recruitment['personal_space_id'],
|
|
453
|
+
recruitment['task_id'],
|
|
454
|
+
)
|
|
455
|
+
if latest and dict(latest['task']).get('status') != 'awaiting_recruitment':
|
|
456
|
+
return
|
|
457
|
+
if _attempt < 2:
|
|
458
|
+
return await self._activate_approved_recruitment(
|
|
459
|
+
actor,
|
|
460
|
+
recruitment,
|
|
461
|
+
selected,
|
|
462
|
+
approval_reason,
|
|
463
|
+
_attempt=_attempt + 1,
|
|
464
|
+
)
|
|
465
|
+
raise HTTPException(
|
|
466
|
+
status_code=409,
|
|
467
|
+
detail='recruitment task changed before activation',
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def _link_recruited_task_participant(self, recruitment, selected):
|
|
471
|
+
"""Repair durable links without changing task lifecycle state."""
|
|
472
|
+
|
|
473
|
+
updated_at = now_utc()
|
|
474
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
475
|
+
'''
|
|
476
|
+
MATCH (space:FuliSpace {
|
|
477
|
+
id: $personal_space_id, kind: 'personal'
|
|
478
|
+
})-[:HAS_PROJECT_AGENT_TASK]->
|
|
479
|
+
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
480
|
+
SET task._task_lifecycle_lock = true
|
|
481
|
+
REMOVE task._task_lifecycle_lock
|
|
482
|
+
WITH space, task
|
|
483
|
+
MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
|
|
484
|
+
(recruitment:FuliProjectAgentRecruitment {
|
|
485
|
+
recruitment_id: $recruitment_id,
|
|
486
|
+
status: 'fulfilled'
|
|
487
|
+
})
|
|
488
|
+
MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
489
|
+
(agent:FuliProjectAgent {agent_id: $agent_id})
|
|
490
|
+
SET agent._task_lifecycle_lock = true
|
|
491
|
+
REMOVE agent._task_lifecycle_lock
|
|
492
|
+
WITH task, recruitment, agent
|
|
493
|
+
WHERE agent.status = 'active'
|
|
494
|
+
MERGE (task)-[participant:HAS_PARTICIPANT]->(agent)
|
|
495
|
+
ON CREATE SET participant.role =
|
|
496
|
+
coalesce(recruitment.participant_role, 'lead'),
|
|
497
|
+
participant.status = task.status,
|
|
498
|
+
participant.assignment_summary = $assignment_summary,
|
|
499
|
+
participant.joined_at = $updated_at,
|
|
500
|
+
participant.updated_at = $updated_at
|
|
501
|
+
SET task.recruitment_id = coalesce(
|
|
502
|
+
task.recruitment_id, recruitment.recruitment_id),
|
|
503
|
+
task.hr_agent_id = coalesce(
|
|
504
|
+
task.hr_agent_id, recruitment.hr_agent_id)
|
|
505
|
+
MERGE (task)-[:TRIGGERED_RECRUITMENT]->(recruitment)
|
|
506
|
+
RETURN count(task) AS linked_count
|
|
507
|
+
''',
|
|
508
|
+
personal_space_id=recruitment['personal_space_id'],
|
|
509
|
+
task_id=recruitment['task_id'],
|
|
510
|
+
recruitment_id=recruitment['recruitment_id'],
|
|
511
|
+
agent_id=selected['agent_id'],
|
|
512
|
+
assignment_summary=selected.get('responsibility'),
|
|
513
|
+
updated_at=updated_at,
|
|
514
|
+
)
|
|
515
|
+
return bool(records and records[0].get('linked_count') == 1)
|
|
516
|
+
|
|
517
|
+
async def _recruitment_routing_lead(
|
|
518
|
+
self,
|
|
519
|
+
recruitment,
|
|
520
|
+
selected,
|
|
521
|
+
raw_task,
|
|
522
|
+
task_recruitments,
|
|
523
|
+
):
|
|
524
|
+
if (recruitment.get('participant_role') or 'lead') == 'lead':
|
|
525
|
+
return selected
|
|
526
|
+
lead_agent_id = raw_task.get('lead_agent_id')
|
|
527
|
+
if not lead_agent_id:
|
|
528
|
+
lead_recruitment = next(
|
|
529
|
+
(
|
|
530
|
+
item
|
|
531
|
+
for item in task_recruitments
|
|
532
|
+
if item.participant_role == 'lead'
|
|
533
|
+
),
|
|
534
|
+
None,
|
|
535
|
+
)
|
|
536
|
+
if lead_recruitment:
|
|
537
|
+
lead_agent_id = (
|
|
538
|
+
lead_recruitment.recruited_agent_id
|
|
539
|
+
or lead_recruitment.proposed_agent_id
|
|
540
|
+
)
|
|
541
|
+
candidates = await self._assignment_candidates(
|
|
542
|
+
recruitment['personal_space_id'],
|
|
543
|
+
recruitment['personal_project_id'],
|
|
544
|
+
include_temporary=True,
|
|
545
|
+
)
|
|
546
|
+
lead = next(
|
|
547
|
+
(
|
|
548
|
+
item for item in candidates
|
|
549
|
+
if item['agent_id'] == lead_agent_id
|
|
550
|
+
),
|
|
551
|
+
None,
|
|
552
|
+
)
|
|
553
|
+
if not lead:
|
|
554
|
+
raise HTTPException(
|
|
555
|
+
status_code=409,
|
|
556
|
+
detail='recruited task lead assignment is unavailable',
|
|
557
|
+
)
|
|
558
|
+
return lead
|
|
559
|
+
|
|
560
|
+
async def _recover_recruitment_lifecycle(
|
|
561
|
+
self,
|
|
562
|
+
actor,
|
|
563
|
+
request,
|
|
564
|
+
task,
|
|
565
|
+
recruitments,
|
|
566
|
+
):
|
|
567
|
+
recovered_requested = False
|
|
568
|
+
if task.status == 'awaiting_recruitment':
|
|
569
|
+
expected_task_revision = int(getattr(task, 'revision', 0) or 0)
|
|
570
|
+
for record in recruitments:
|
|
571
|
+
if record.status != 'requested':
|
|
572
|
+
continue
|
|
573
|
+
recovery_guard = {
|
|
574
|
+
'task_id': task.task_id,
|
|
575
|
+
'expected_task_revision': expected_task_revision,
|
|
576
|
+
}
|
|
577
|
+
if await self._finalize_ready_recruitment(
|
|
578
|
+
request,
|
|
579
|
+
record.recruitment_id,
|
|
580
|
+
**recovery_guard,
|
|
581
|
+
):
|
|
582
|
+
recovered_requested = True
|
|
583
|
+
expected_task_revision += 1
|
|
584
|
+
continue
|
|
585
|
+
claimed = await self._claim_requested_recruitment(
|
|
586
|
+
request,
|
|
587
|
+
record.recruitment_id,
|
|
588
|
+
**recovery_guard,
|
|
589
|
+
)
|
|
590
|
+
if not claimed:
|
|
591
|
+
continue
|
|
592
|
+
await self._provision_recruitment(actor, claimed)
|
|
593
|
+
recovered_requested = True
|
|
594
|
+
# The guarded claim and fulfillment each advance the Task CAS.
|
|
595
|
+
expected_task_revision += 2
|
|
596
|
+
if recovered_requested:
|
|
597
|
+
recruitments = await self._task_recruitments(
|
|
598
|
+
request.personal_space_id,
|
|
599
|
+
task.task_id,
|
|
600
|
+
)
|
|
601
|
+
fulfilled = [
|
|
602
|
+
item for item in recruitments if item.status == 'fulfilled'
|
|
603
|
+
]
|
|
604
|
+
if not fulfilled:
|
|
605
|
+
return recovered_requested
|
|
606
|
+
candidates = await self._assignment_candidates(
|
|
607
|
+
request.personal_space_id,
|
|
608
|
+
request.personal_project_id,
|
|
609
|
+
include_temporary=True,
|
|
610
|
+
)
|
|
611
|
+
by_agent_id = {item['agent_id']: item for item in candidates}
|
|
612
|
+
recovered = []
|
|
613
|
+
for record in fulfilled:
|
|
614
|
+
raw = await self._get_recruitment_raw(
|
|
615
|
+
request.personal_space_id,
|
|
616
|
+
record.recruitment_id,
|
|
617
|
+
)
|
|
618
|
+
if not raw:
|
|
619
|
+
continue
|
|
620
|
+
agent_id = record.recruited_agent_id or record.proposed_agent_id
|
|
621
|
+
selected = by_agent_id.get(agent_id) or {
|
|
622
|
+
'agent_id': agent_id,
|
|
623
|
+
'responsibility': record.proposed_profile.responsibility,
|
|
624
|
+
'profile': record.proposed_profile,
|
|
625
|
+
}
|
|
626
|
+
linked = await self._link_recruited_task_participant(raw, selected)
|
|
627
|
+
if linked is False:
|
|
628
|
+
continue
|
|
629
|
+
recovered.append((raw, selected))
|
|
630
|
+
if (
|
|
631
|
+
not recovered
|
|
632
|
+
or task.status != 'awaiting_recruitment'
|
|
633
|
+
or len(fulfilled) != len(recruitments)
|
|
634
|
+
):
|
|
635
|
+
return bool(recovered) or recovered_requested
|
|
636
|
+
raw, selected = next(
|
|
637
|
+
(
|
|
638
|
+
item for item in recovered
|
|
639
|
+
if (item[0].get('participant_role') or 'lead') == 'lead'
|
|
640
|
+
),
|
|
641
|
+
recovered[0],
|
|
642
|
+
)
|
|
643
|
+
await self._activate_approved_recruitment(
|
|
644
|
+
actor,
|
|
645
|
+
raw,
|
|
646
|
+
selected,
|
|
647
|
+
'Recovered fulfilled recruitment activation during task replay.',
|
|
648
|
+
)
|
|
649
|
+
return True
|
|
387
650
|
|
|
388
651
|
async def list_project_agent_recruitments(
|
|
389
652
|
self,
|
|
@@ -431,7 +694,51 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
431
694
|
task_id,
|
|
432
695
|
coordinator_agent_id,
|
|
433
696
|
reason_code,
|
|
697
|
+
*,
|
|
698
|
+
participant_role='lead',
|
|
699
|
+
recruitment_slot=None,
|
|
434
700
|
):
|
|
701
|
+
if participant_role not in {'lead', 'collaborator'}:
|
|
702
|
+
raise ValueError('recruitment participant role is invalid')
|
|
703
|
+
slot = recruitment_slot or 'lead'
|
|
704
|
+
if not slot or len(slot) > 128:
|
|
705
|
+
raise ValueError('recruitment slot must contain 1 to 128 characters')
|
|
706
|
+
|
|
707
|
+
position_kind, profile = self._normalized_recruitment_profile(request)
|
|
708
|
+
self._require_agent_client_allowed(
|
|
709
|
+
profile,
|
|
710
|
+
request.source_application,
|
|
711
|
+
)
|
|
712
|
+
is_legacy_lead_slot = participant_role == 'lead' and slot == 'lead'
|
|
713
|
+
recruitment_id_parts = [task_id]
|
|
714
|
+
proposed_agent_id_parts = [task_id, position_kind]
|
|
715
|
+
if not is_legacy_lead_slot:
|
|
716
|
+
recruitment_id_parts.append(slot)
|
|
717
|
+
proposed_agent_id_parts.append(slot)
|
|
718
|
+
recruitment_id = stable_uuid(
|
|
719
|
+
self.settings.provider_id,
|
|
720
|
+
request.personal_space_id,
|
|
721
|
+
'project-agent-recruitment',
|
|
722
|
+
*recruitment_id_parts,
|
|
723
|
+
)
|
|
724
|
+
proposed_agent_id = stable_uuid(
|
|
725
|
+
self.settings.provider_id,
|
|
726
|
+
request.personal_space_id,
|
|
727
|
+
'recruited-project-agent',
|
|
728
|
+
*proposed_agent_id_parts,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
existing = await self._get_recruitment(
|
|
732
|
+
request.personal_space_id,
|
|
733
|
+
recruitment_id,
|
|
734
|
+
)
|
|
735
|
+
if existing:
|
|
736
|
+
return await self._existing_recruitment_result(
|
|
737
|
+
actor,
|
|
738
|
+
request,
|
|
739
|
+
existing,
|
|
740
|
+
)
|
|
741
|
+
|
|
435
742
|
hr_records, _, _ = await self.runtime.driver.execute_query(
|
|
436
743
|
'''
|
|
437
744
|
MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
@@ -445,45 +752,19 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
445
752
|
hr_agent_id = (
|
|
446
753
|
dict(hr_records[0]['hr'])['agent_id'] if hr_records else None
|
|
447
754
|
)
|
|
448
|
-
|
|
755
|
+
coordination_policy = await self.get_project_agent_coordination_policy(
|
|
449
756
|
actor,
|
|
450
757
|
request.personal_space_id,
|
|
758
|
+
request.personal_project_id,
|
|
451
759
|
)
|
|
452
|
-
|
|
453
|
-
'
|
|
454
|
-
if
|
|
455
|
-
else '
|
|
456
|
-
)
|
|
457
|
-
profile = request.recruitment_profile or ProjectAgentProfile(
|
|
458
|
-
name=f'{request.work_kind} Agent',
|
|
459
|
-
responsibility=request.objective,
|
|
460
|
-
agent_type=position_kind,
|
|
461
|
-
work_kinds=[request.work_kind],
|
|
462
|
-
capabilities=request.required_capabilities,
|
|
463
|
-
status='active',
|
|
464
|
-
)
|
|
465
|
-
if profile.agent_type != position_kind:
|
|
466
|
-
profile = profile.model_copy(update={'agent_type': position_kind})
|
|
467
|
-
self._require_agent_client_allowed(
|
|
468
|
-
profile,
|
|
469
|
-
request.source_application,
|
|
470
|
-
)
|
|
471
|
-
recruitment_id = stable_uuid(
|
|
472
|
-
self.settings.provider_id,
|
|
473
|
-
request.personal_space_id,
|
|
474
|
-
'project-agent-recruitment',
|
|
475
|
-
task_id,
|
|
476
|
-
)
|
|
477
|
-
proposed_agent_id = stable_uuid(
|
|
478
|
-
self.settings.provider_id,
|
|
479
|
-
request.personal_space_id,
|
|
480
|
-
'recruited-project-agent',
|
|
481
|
-
task_id,
|
|
482
|
-
position_kind,
|
|
760
|
+
confirmation_mode = (
|
|
761
|
+
'require_confirmation'
|
|
762
|
+
if coordination_policy.ask_before_recruitment
|
|
763
|
+
else 'automatic'
|
|
483
764
|
)
|
|
484
765
|
if not hr_agent_id:
|
|
485
766
|
status = 'no_hr'
|
|
486
|
-
elif
|
|
767
|
+
elif confirmation_mode == 'require_confirmation':
|
|
487
768
|
status = 'awaiting_confirmation'
|
|
488
769
|
else:
|
|
489
770
|
status = 'requested'
|
|
@@ -502,9 +783,11 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
502
783
|
'reason_code': reason_code,
|
|
503
784
|
'reason': request.routing_reason,
|
|
504
785
|
'status': status,
|
|
505
|
-
'confirmation_mode':
|
|
786
|
+
'confirmation_mode': confirmation_mode,
|
|
506
787
|
'proposed_agent_id': proposed_agent_id,
|
|
507
788
|
'proposed_profile_json': profile.model_dump_json(),
|
|
789
|
+
'participant_role': participant_role,
|
|
790
|
+
'recruitment_slot': slot,
|
|
508
791
|
'occupation_emoji': profile.occupation_emoji,
|
|
509
792
|
'trigger_source_application': request.source_application,
|
|
510
793
|
'trigger_source_session_id': request.source_session_id,
|
|
@@ -513,8 +796,29 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
513
796
|
'updated_at': now,
|
|
514
797
|
'test_source': profile.test_source,
|
|
515
798
|
'cleanup_eligible': profile.cleanup_eligible,
|
|
799
|
+
'provisioning_claim_id': (
|
|
800
|
+
str(uuid4()) if status == 'requested' else None
|
|
801
|
+
),
|
|
802
|
+
'provisioning_claimed_at': (
|
|
803
|
+
now if status == 'requested' else None
|
|
804
|
+
),
|
|
516
805
|
}
|
|
517
|
-
await self._persist_recruitment(raw)
|
|
806
|
+
claimed = await self._persist_recruitment(raw)
|
|
807
|
+
if status == 'requested' and claimed is False:
|
|
808
|
+
existing = await self._get_recruitment(
|
|
809
|
+
request.personal_space_id,
|
|
810
|
+
recruitment_id,
|
|
811
|
+
)
|
|
812
|
+
if not existing:
|
|
813
|
+
raise HTTPException(
|
|
814
|
+
status_code=409,
|
|
815
|
+
detail='recruitment creation raced; retry task submission',
|
|
816
|
+
)
|
|
817
|
+
return await self._existing_recruitment_result(
|
|
818
|
+
actor,
|
|
819
|
+
request,
|
|
820
|
+
existing,
|
|
821
|
+
)
|
|
518
822
|
record = self._recruitment(raw)
|
|
519
823
|
if status != 'requested' or not hr_agent_id:
|
|
520
824
|
return record, None
|
|
@@ -524,8 +828,225 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
524
828
|
recruitment_id,
|
|
525
829
|
), recruited
|
|
526
830
|
|
|
831
|
+
async def _existing_recruitment_result(self, actor, request, existing):
|
|
832
|
+
if existing.status == 'requested':
|
|
833
|
+
ready = await self._finalize_ready_recruitment(
|
|
834
|
+
request,
|
|
835
|
+
existing.recruitment_id,
|
|
836
|
+
)
|
|
837
|
+
if ready:
|
|
838
|
+
record = self._recruitment(ready)
|
|
839
|
+
return await self._fulfilled_recruitment_result(
|
|
840
|
+
request,
|
|
841
|
+
record,
|
|
842
|
+
)
|
|
843
|
+
raw = await self._claim_requested_recruitment(
|
|
844
|
+
request,
|
|
845
|
+
existing.recruitment_id,
|
|
846
|
+
)
|
|
847
|
+
if not raw:
|
|
848
|
+
raise HTTPException(
|
|
849
|
+
status_code=409,
|
|
850
|
+
detail=(
|
|
851
|
+
'automatic recruitment is already provisioning; '
|
|
852
|
+
'retry task submission'
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
recruited = await self._provision_recruitment(actor, raw)
|
|
856
|
+
record = await self._get_recruitment(
|
|
857
|
+
request.personal_space_id,
|
|
858
|
+
existing.recruitment_id,
|
|
859
|
+
)
|
|
860
|
+
if not record:
|
|
861
|
+
raise HTTPException(status_code=404, detail='recruitment not found')
|
|
862
|
+
return record, recruited
|
|
863
|
+
if existing.status != 'fulfilled':
|
|
864
|
+
return existing, None
|
|
865
|
+
return await self._fulfilled_recruitment_result(request, existing)
|
|
866
|
+
|
|
867
|
+
async def _fulfilled_recruitment_result(self, request, existing):
|
|
868
|
+
assignment_rows = await self._assignment_candidates(
|
|
869
|
+
request.personal_space_id,
|
|
870
|
+
request.personal_project_id,
|
|
871
|
+
include_temporary=True,
|
|
872
|
+
)
|
|
873
|
+
recruited_agent_id = (
|
|
874
|
+
existing.recruited_agent_id or existing.proposed_agent_id
|
|
875
|
+
)
|
|
876
|
+
recruited = next(
|
|
877
|
+
(
|
|
878
|
+
item
|
|
879
|
+
for item in assignment_rows
|
|
880
|
+
if item['agent_id'] == recruited_agent_id
|
|
881
|
+
),
|
|
882
|
+
None,
|
|
883
|
+
)
|
|
884
|
+
return existing, recruited
|
|
885
|
+
|
|
886
|
+
async def _finalize_ready_recruitment(
|
|
887
|
+
self,
|
|
888
|
+
request,
|
|
889
|
+
recruitment_id,
|
|
890
|
+
*,
|
|
891
|
+
task_id=None,
|
|
892
|
+
expected_task_revision=None,
|
|
893
|
+
):
|
|
894
|
+
updated_at = now_utc()
|
|
895
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
896
|
+
'''
|
|
897
|
+
MATCH (space:FuliSpace {
|
|
898
|
+
id: $personal_space_id, kind: 'personal'
|
|
899
|
+
})-[:HAS_PROJECT_AGENT_RECRUITMENT]->
|
|
900
|
+
(recruitment:FuliProjectAgentRecruitment {
|
|
901
|
+
recruitment_id: $recruitment_id,
|
|
902
|
+
status: 'requested'
|
|
903
|
+
})
|
|
904
|
+
OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
|
|
905
|
+
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
906
|
+
FOREACH (_ IN CASE WHEN task IS NULL THEN [] ELSE [1] END |
|
|
907
|
+
SET task._task_lifecycle_lock = true
|
|
908
|
+
)
|
|
909
|
+
FOREACH (_ IN CASE WHEN task IS NULL THEN [] ELSE [1] END |
|
|
910
|
+
REMOVE task._task_lifecycle_lock
|
|
911
|
+
)
|
|
912
|
+
WITH space, recruitment, task
|
|
913
|
+
MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
914
|
+
(agent:FuliProjectAgent)
|
|
915
|
+
WHERE agent.agent_id = recruitment.proposed_agent_id
|
|
916
|
+
SET agent._task_lifecycle_lock = true
|
|
917
|
+
REMOVE agent._task_lifecycle_lock
|
|
918
|
+
WITH space, recruitment, agent, task
|
|
919
|
+
WHERE agent.status = 'active'
|
|
920
|
+
MATCH (space)-[:CONTAINS_PROJECT]->
|
|
921
|
+
(:FuliPersonalProject {project_id: $personal_project_id})-
|
|
922
|
+
[:HAS_PROJECT_AGENT_ASSIGNMENT]->
|
|
923
|
+
(:FuliProjectAgentAssignment {status: 'active'})-
|
|
924
|
+
[:ASSIGNED_AGENT]->(agent)
|
|
925
|
+
WITH recruitment, agent, task
|
|
926
|
+
WHERE $task_id IS NULL
|
|
927
|
+
OR (
|
|
928
|
+
recruitment.task_id = $task_id
|
|
929
|
+
AND task.status = 'awaiting_recruitment'
|
|
930
|
+
AND coalesce(task.revision, 0) = $expected_task_revision
|
|
931
|
+
)
|
|
932
|
+
SET recruitment.status = 'fulfilled',
|
|
933
|
+
recruitment.recruited_agent_id = agent.agent_id,
|
|
934
|
+
recruitment.fulfilled_at = $updated_at,
|
|
935
|
+
recruitment.updated_at = $updated_at,
|
|
936
|
+
recruitment.revision = coalesce(recruitment.revision, 0) + 1,
|
|
937
|
+
agent.recruitment_id = recruitment.recruitment_id,
|
|
938
|
+
agent.recruited_at = coalesce(agent.recruited_at, $updated_at),
|
|
939
|
+
agent.recruitment_reason = recruitment.reason,
|
|
940
|
+
agent.recruitment_source_application =
|
|
941
|
+
recruitment.trigger_source_application,
|
|
942
|
+
agent.temporary_task_id = CASE recruitment.position_kind
|
|
943
|
+
WHEN 'temporary' THEN recruitment.task_id
|
|
944
|
+
ELSE agent.temporary_task_id END,
|
|
945
|
+
agent.test_source = recruitment.test_source,
|
|
946
|
+
agent.cleanup_eligible =
|
|
947
|
+
coalesce(recruitment.cleanup_eligible, false)
|
|
948
|
+
FOREACH (_ IN CASE WHEN $task_id IS NULL THEN [] ELSE [1] END |
|
|
949
|
+
SET task.recruitment_provisioning_claim_id = null,
|
|
950
|
+
task.recruitment_provisioning_claimed_at = null,
|
|
951
|
+
task.revision = coalesce(task.revision, 0) + 1,
|
|
952
|
+
task.updated_at = $updated_at
|
|
953
|
+
)
|
|
954
|
+
MERGE (recruitment)-[:CREATED_AGENT]->(agent)
|
|
955
|
+
RETURN recruitment
|
|
956
|
+
''',
|
|
957
|
+
personal_space_id=request.personal_space_id,
|
|
958
|
+
personal_project_id=request.personal_project_id,
|
|
959
|
+
recruitment_id=recruitment_id,
|
|
960
|
+
task_id=task_id,
|
|
961
|
+
expected_task_revision=expected_task_revision,
|
|
962
|
+
updated_at=updated_at,
|
|
963
|
+
)
|
|
964
|
+
return dict(records[0]['recruitment']) if records else None
|
|
965
|
+
|
|
966
|
+
async def _claim_requested_recruitment(
|
|
967
|
+
self,
|
|
968
|
+
request,
|
|
969
|
+
recruitment_id,
|
|
970
|
+
*,
|
|
971
|
+
task_id=None,
|
|
972
|
+
expected_task_revision=None,
|
|
973
|
+
):
|
|
974
|
+
claimed_at = now_utc()
|
|
975
|
+
claim_id = str(uuid4())
|
|
976
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
977
|
+
'''
|
|
978
|
+
MATCH (space:FuliSpace {
|
|
979
|
+
id: $personal_space_id, kind: 'personal'
|
|
980
|
+
})-[:HAS_PROJECT_AGENT_RECRUITMENT]->
|
|
981
|
+
(recruitment:FuliProjectAgentRecruitment {
|
|
982
|
+
recruitment_id: $recruitment_id
|
|
983
|
+
})
|
|
984
|
+
OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
|
|
985
|
+
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
986
|
+
WITH recruitment, task
|
|
987
|
+
WHERE (
|
|
988
|
+
$task_id IS NULL
|
|
989
|
+
OR (
|
|
990
|
+
recruitment.task_id = $task_id
|
|
991
|
+
AND task.status = 'awaiting_recruitment'
|
|
992
|
+
AND coalesce(task.revision, 0) = $expected_task_revision
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
AND recruitment.status = 'requested'
|
|
996
|
+
AND (
|
|
997
|
+
recruitment.provisioning_claimed_at IS NULL
|
|
998
|
+
OR recruitment.provisioning_claimed_at <= $claim_expired_before
|
|
999
|
+
)
|
|
1000
|
+
SET recruitment.provisioning_claim_id = $provisioning_claim_id,
|
|
1001
|
+
recruitment.provisioning_claimed_at = $provisioning_claimed_at,
|
|
1002
|
+
recruitment.updated_at = $provisioning_claimed_at
|
|
1003
|
+
FOREACH (_ IN CASE WHEN $task_id IS NULL THEN [] ELSE [1] END |
|
|
1004
|
+
SET task.recruitment_provisioning_claim_id =
|
|
1005
|
+
$provisioning_claim_id,
|
|
1006
|
+
task.recruitment_provisioning_claimed_at =
|
|
1007
|
+
$provisioning_claimed_at,
|
|
1008
|
+
task.revision = coalesce(task.revision, 0) + 1,
|
|
1009
|
+
task.updated_at = $provisioning_claimed_at
|
|
1010
|
+
)
|
|
1011
|
+
RETURN recruitment
|
|
1012
|
+
''',
|
|
1013
|
+
personal_space_id=request.personal_space_id,
|
|
1014
|
+
personal_project_id=request.personal_project_id,
|
|
1015
|
+
recruitment_id=recruitment_id,
|
|
1016
|
+
task_id=task_id,
|
|
1017
|
+
expected_task_revision=expected_task_revision,
|
|
1018
|
+
provisioning_claim_id=claim_id,
|
|
1019
|
+
provisioning_claimed_at=claimed_at,
|
|
1020
|
+
claim_expired_before=claimed_at - RECRUITMENT_CLAIM_TTL,
|
|
1021
|
+
)
|
|
1022
|
+
if not records:
|
|
1023
|
+
return None
|
|
1024
|
+
raw = dict(records[0]['recruitment'])
|
|
1025
|
+
if task_id:
|
|
1026
|
+
raw['_guarded_task_id'] = task_id
|
|
1027
|
+
return raw
|
|
1028
|
+
|
|
1029
|
+
@staticmethod
|
|
1030
|
+
def _normalized_recruitment_profile(request):
|
|
1031
|
+
position_kind = (
|
|
1032
|
+
'temporary'
|
|
1033
|
+
if request.staffing_intent == 'temporary'
|
|
1034
|
+
else 'durable'
|
|
1035
|
+
)
|
|
1036
|
+
profile = request.recruitment_profile or ProjectAgentProfile(
|
|
1037
|
+
name=f'{request.work_kind} Agent',
|
|
1038
|
+
responsibility=request.objective,
|
|
1039
|
+
agent_type=position_kind,
|
|
1040
|
+
work_kinds=[request.work_kind],
|
|
1041
|
+
capabilities=request.required_capabilities,
|
|
1042
|
+
status='active',
|
|
1043
|
+
)
|
|
1044
|
+
if profile.agent_type != position_kind:
|
|
1045
|
+
profile = profile.model_copy(update={'agent_type': position_kind})
|
|
1046
|
+
return position_kind, profile
|
|
1047
|
+
|
|
527
1048
|
async def _persist_recruitment(self, raw):
|
|
528
|
-
await self.runtime.driver.execute_query(
|
|
1049
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
529
1050
|
'''
|
|
530
1051
|
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
|
|
531
1052
|
MATCH (space)-[:CONTAINS_PROJECT]->
|
|
@@ -540,15 +1061,71 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
540
1061
|
FOREACH (_ IN CASE WHEN hr IS NULL THEN [] ELSE [1] END |
|
|
541
1062
|
MERGE (recruitment)-[:RECRUITED_BY]->(hr)
|
|
542
1063
|
)
|
|
1064
|
+
RETURN recruitment.provisioning_claim_id =
|
|
1065
|
+
$provisioning_claim_id AS provisioning_claimed
|
|
543
1066
|
''',
|
|
544
1067
|
personal_space_id=raw['personal_space_id'],
|
|
545
1068
|
personal_project_id=raw['personal_project_id'],
|
|
546
1069
|
id=raw['id'],
|
|
547
1070
|
properties=raw,
|
|
548
1071
|
hr_agent_id=raw.get('hr_agent_id'),
|
|
1072
|
+
provisioning_claim_id=raw.get('provisioning_claim_id'),
|
|
1073
|
+
)
|
|
1074
|
+
return bool(
|
|
1075
|
+
records
|
|
1076
|
+
and dict(records[0]).get('provisioning_claimed')
|
|
549
1077
|
)
|
|
550
1078
|
|
|
551
1079
|
async def _provision_recruitment(self, actor, raw):
|
|
1080
|
+
# Identity, assignment and fulfillment either all commit or all roll back.
|
|
1081
|
+
# The audit/claim intentionally remains outside for recoverable retries.
|
|
1082
|
+
async with query_store_transaction(self) as scoped:
|
|
1083
|
+
return await scoped._provision_recruitment_transaction(actor, raw)
|
|
1084
|
+
|
|
1085
|
+
async def _provision_recruitment_transaction(self, actor, raw):
|
|
1086
|
+
guarded_task_id = raw.get('_guarded_task_id')
|
|
1087
|
+
if guarded_task_id:
|
|
1088
|
+
active, _, _ = await self.runtime.driver.execute_query(
|
|
1089
|
+
'''
|
|
1090
|
+
MATCH (:FuliSpace {
|
|
1091
|
+
id: $personal_space_id, kind: 'personal'
|
|
1092
|
+
})-[:HAS_PROJECT_AGENT_TASK]->
|
|
1093
|
+
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
1094
|
+
SET task._recruitment_write_lock = true
|
|
1095
|
+
REMOVE task._recruitment_write_lock
|
|
1096
|
+
WITH task
|
|
1097
|
+
WHERE task.status = 'awaiting_recruitment'
|
|
1098
|
+
AND task.recruitment_provisioning_claim_id =
|
|
1099
|
+
$provisioning_claim_id
|
|
1100
|
+
RETURN task
|
|
1101
|
+
''',
|
|
1102
|
+
personal_space_id=raw['personal_space_id'],
|
|
1103
|
+
task_id=guarded_task_id,
|
|
1104
|
+
provisioning_claim_id=raw.get('provisioning_claim_id'),
|
|
1105
|
+
routing_='r',
|
|
1106
|
+
)
|
|
1107
|
+
if not active:
|
|
1108
|
+
raise HTTPException(
|
|
1109
|
+
status_code=409,
|
|
1110
|
+
detail='recruitment task is no longer awaiting provisioning',
|
|
1111
|
+
)
|
|
1112
|
+
claimed, _, _ = await self.runtime.driver.execute_query(
|
|
1113
|
+
'''
|
|
1114
|
+
MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
|
|
1115
|
+
-[:HAS_PROJECT_AGENT_RECRUITMENT]->
|
|
1116
|
+
(recruitment:FuliProjectAgentRecruitment {recruitment_id: $recruitment_id})
|
|
1117
|
+
SET recruitment._provision_write_lock = true
|
|
1118
|
+
REMOVE recruitment._provision_write_lock
|
|
1119
|
+
WITH recruitment
|
|
1120
|
+
WHERE recruitment.status = 'requested'
|
|
1121
|
+
AND recruitment.provisioning_claim_id = $provisioning_claim_id
|
|
1122
|
+
RETURN recruitment
|
|
1123
|
+
''',
|
|
1124
|
+
**{key: raw.get(key) for key in (
|
|
1125
|
+
'personal_space_id', 'recruitment_id', 'provisioning_claim_id')},
|
|
1126
|
+
)
|
|
1127
|
+
if not claimed:
|
|
1128
|
+
raise HTTPException(status_code=409, detail='recruitment provisioning claim is stale')
|
|
552
1129
|
profile = ProjectAgentProfile.model_validate_json(
|
|
553
1130
|
raw['proposed_profile_json']
|
|
554
1131
|
)
|
|
@@ -585,7 +1162,7 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
585
1162
|
),
|
|
586
1163
|
)
|
|
587
1164
|
updated_at = now_utc()
|
|
588
|
-
await self.runtime.driver.execute_query(
|
|
1165
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
589
1166
|
'''
|
|
590
1167
|
MATCH (space:FuliSpace {
|
|
591
1168
|
id: $personal_space_id, kind: 'personal'
|
|
@@ -595,11 +1172,24 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
595
1172
|
})
|
|
596
1173
|
MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
597
1174
|
(agent:FuliProjectAgent {agent_id: $agent_id})
|
|
1175
|
+
OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
|
|
1176
|
+
(task:FuliProjectAgentTask {task_id: $task_id})
|
|
1177
|
+
WITH recruitment, agent, task
|
|
1178
|
+
WHERE recruitment.status = 'requested'
|
|
1179
|
+
AND recruitment.provisioning_claim_id = $provisioning_claim_id
|
|
1180
|
+
AND (
|
|
1181
|
+
NOT $task_guarded
|
|
1182
|
+
OR (
|
|
1183
|
+
task.status = 'awaiting_recruitment'
|
|
1184
|
+
AND task.recruitment_provisioning_claim_id =
|
|
1185
|
+
$provisioning_claim_id
|
|
1186
|
+
)
|
|
1187
|
+
)
|
|
598
1188
|
SET recruitment.status = 'fulfilled',
|
|
599
1189
|
recruitment.recruited_agent_id = $agent_id,
|
|
600
1190
|
recruitment.fulfilled_at = $updated_at,
|
|
601
1191
|
recruitment.updated_at = $updated_at,
|
|
602
|
-
recruitment.revision = recruitment.revision + 1,
|
|
1192
|
+
recruitment.revision = coalesce(recruitment.revision, 0) + 1,
|
|
603
1193
|
agent.recruitment_id = $recruitment_id,
|
|
604
1194
|
agent.recruited_at = $updated_at,
|
|
605
1195
|
agent.recruitment_reason = recruitment.reason,
|
|
@@ -610,13 +1200,33 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
610
1200
|
agent.test_source = recruitment.test_source,
|
|
611
1201
|
agent.cleanup_eligible =
|
|
612
1202
|
coalesce(recruitment.cleanup_eligible, false)
|
|
1203
|
+
FOREACH (_ IN CASE WHEN $task_guarded THEN [1] ELSE [] END |
|
|
1204
|
+
SET task.recruitment_provisioning_claim_id = null,
|
|
1205
|
+
task.recruitment_provisioning_claimed_at = null,
|
|
1206
|
+
task.revision = coalesce(task.revision, 0) + 1,
|
|
1207
|
+
task.updated_at = $updated_at
|
|
1208
|
+
)
|
|
613
1209
|
MERGE (recruitment)-[:CREATED_AGENT]->(agent)
|
|
1210
|
+
RETURN recruitment
|
|
614
1211
|
''',
|
|
615
1212
|
recruitment_id=raw['recruitment_id'],
|
|
616
1213
|
personal_space_id=raw['personal_space_id'],
|
|
617
1214
|
agent_id=agent_id,
|
|
1215
|
+
task_id=guarded_task_id,
|
|
1216
|
+
task_guarded=bool(guarded_task_id),
|
|
1217
|
+
provisioning_claim_id=raw.get('provisioning_claim_id'),
|
|
618
1218
|
updated_at=updated_at,
|
|
619
1219
|
)
|
|
1220
|
+
if not records:
|
|
1221
|
+
existing = await self._get_recruitment(
|
|
1222
|
+
raw['personal_space_id'],
|
|
1223
|
+
raw['recruitment_id'],
|
|
1224
|
+
)
|
|
1225
|
+
if not existing or existing.status != 'fulfilled':
|
|
1226
|
+
raise HTTPException(
|
|
1227
|
+
status_code=409,
|
|
1228
|
+
detail='recruitment provisioning claim is no longer active',
|
|
1229
|
+
)
|
|
620
1230
|
assignment_rows = await self._assignment_candidates(
|
|
621
1231
|
raw['personal_space_id'],
|
|
622
1232
|
raw['personal_project_id'],
|
|
@@ -626,6 +1236,13 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
626
1236
|
return selected
|
|
627
1237
|
|
|
628
1238
|
async def _get_recruitment(self, personal_space_id, recruitment_id):
|
|
1239
|
+
raw = await self._get_recruitment_raw(
|
|
1240
|
+
personal_space_id,
|
|
1241
|
+
recruitment_id,
|
|
1242
|
+
)
|
|
1243
|
+
return self._recruitment(raw) if raw else None
|
|
1244
|
+
|
|
1245
|
+
async def _get_recruitment_raw(self, personal_space_id, recruitment_id):
|
|
629
1246
|
records, _, _ = await self.runtime.driver.execute_query(
|
|
630
1247
|
'''
|
|
631
1248
|
MATCH (recruitment:FuliProjectAgentRecruitment {
|
|
@@ -638,62 +1255,37 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
638
1255
|
recruitment_id=recruitment_id,
|
|
639
1256
|
routing_='r',
|
|
640
1257
|
)
|
|
641
|
-
return (
|
|
642
|
-
self._recruitment(dict(records[0]['recruitment']))
|
|
643
|
-
if records else None
|
|
644
|
-
)
|
|
1258
|
+
return dict(records[0]['recruitment']) if records else None
|
|
645
1259
|
|
|
646
1260
|
async def _task_recruitment(self, personal_space_id, task_id):
|
|
1261
|
+
recruitments = await self._task_recruitments(
|
|
1262
|
+
personal_space_id,
|
|
1263
|
+
task_id,
|
|
1264
|
+
)
|
|
1265
|
+
return recruitments[0] if recruitments else None
|
|
1266
|
+
|
|
1267
|
+
async def _task_recruitments(self, personal_space_id, task_id):
|
|
647
1268
|
records, _, _ = await self.runtime.driver.execute_query(
|
|
648
1269
|
'''
|
|
649
1270
|
MATCH (recruitment:FuliProjectAgentRecruitment {
|
|
650
1271
|
personal_space_id: $personal_space_id,
|
|
651
1272
|
task_id: $task_id
|
|
652
1273
|
})
|
|
653
|
-
RETURN recruitment
|
|
1274
|
+
RETURN recruitment
|
|
1275
|
+
ORDER BY CASE
|
|
1276
|
+
WHEN coalesce(recruitment.participant_role, 'lead') = 'lead'
|
|
1277
|
+
THEN 0 ELSE 1 END,
|
|
1278
|
+
recruitment.created_at,
|
|
1279
|
+
recruitment.recruitment_id
|
|
654
1280
|
''',
|
|
655
1281
|
personal_space_id=personal_space_id,
|
|
656
1282
|
task_id=task_id,
|
|
657
1283
|
routing_='r',
|
|
658
1284
|
)
|
|
659
|
-
return
|
|
1285
|
+
return [
|
|
1286
|
+
self._recruitment(dict(row['recruitment']))
|
|
1287
|
+
for row in records
|
|
1288
|
+
]
|
|
660
1289
|
|
|
661
1290
|
def _recruitment(self, raw):
|
|
662
|
-
|
|
663
|
-
raw['proposed_profile_json']
|
|
664
|
-
)
|
|
665
|
-
occupation_emoji = raw.get('occupation_emoji') or raw.get(
|
|
666
|
-
'occupationEmoji'
|
|
667
|
-
)
|
|
668
|
-
if not proposed_profile.occupation_emoji and occupation_emoji:
|
|
669
|
-
proposed_profile = proposed_profile.model_copy(
|
|
670
|
-
update={'occupation_emoji': occupation_emoji}
|
|
671
|
-
)
|
|
672
|
-
fields = {
|
|
673
|
-
'recruitment_id': raw.get('recruitment_id') or raw['id'],
|
|
674
|
-
'personal_space_id': raw['personal_space_id'],
|
|
675
|
-
'personal_project_id': raw['personal_project_id'],
|
|
676
|
-
'task_id': raw['task_id'],
|
|
677
|
-
'coordinator_agent_id': raw['coordinator_agent_id'],
|
|
678
|
-
'hr_agent_id': raw.get('hr_agent_id'),
|
|
679
|
-
'position_kind': raw['position_kind'],
|
|
680
|
-
'work_kind': raw['work_kind'],
|
|
681
|
-
'required_capabilities': list(raw.get('required_capabilities') or []),
|
|
682
|
-
'reason_code': raw['reason_code'],
|
|
683
|
-
'reason': raw['reason'],
|
|
684
|
-
'status': raw['status'],
|
|
685
|
-
'confirmation_mode': raw['confirmation_mode'],
|
|
686
|
-
'proposed_agent_id': raw['proposed_agent_id'],
|
|
687
|
-
'proposed_profile': proposed_profile,
|
|
688
|
-
'trigger_source_application': raw.get('trigger_source_application'),
|
|
689
|
-
'trigger_source_session_id': raw.get('trigger_source_session_id'),
|
|
690
|
-
'revision': int(raw.get('revision') or 0),
|
|
691
|
-
'recruited_agent_id': raw.get('recruited_agent_id'),
|
|
692
|
-
'created_at': native_datetime(raw['created_at']),
|
|
693
|
-
'updated_at': native_datetime(raw['updated_at']),
|
|
694
|
-
'fulfilled_at': native_datetime(raw.get('fulfilled_at')),
|
|
695
|
-
}
|
|
696
|
-
for name in ('test_source', 'cleanup_eligible'):
|
|
697
|
-
if name in ProjectAgentRecruitmentRecord.model_fields:
|
|
698
|
-
fields[name] = raw.get(name)
|
|
699
|
-
return ProjectAgentRecruitmentRecord(**fields)
|
|
1291
|
+
return project_agent_recruitment_record(raw)
|