fuli-context 0.7.6 → 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-sLV9WChi.js → AboutPage-BcZeFcHe.js} +1 -1
- package/dist/web/assets/{ConnectionsPage-BWn4ywGJ.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-CRQWOaos.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-Dfj3vIpU.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
- package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-ChEB5Bwk.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
- package/dist/web/assets/{OverviewPage-Cd66M0RT.js → OverviewPage-Xyfm5xkZ.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-RCFHL0Wz.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-mstPEjHe.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
- package/dist/web/assets/{ReviewPage-6Y1SmfZy.js → ReviewPage-CQYGwFBH.js} +1 -1
- package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
- package/dist/web/assets/{SettingsPage--LdTMUm7.js → SettingsPage-BlEZ03Yi.js} +1 -1
- package/dist/web/assets/{WritingTastePage-CIpKswvs.js → WritingTastePage-DvRt_nmP.js} +1 -1
- package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
- package/dist/web/assets/{console-BT1P2Db4.js → console-tOxOl8Vq.js} +1 -1
- package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
- package/dist/web/assets/{i18n-js6gfc7j.js → i18n-CgBHZthI.js} +3 -3
- package/dist/web/assets/{identity-CfDfanpa.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-DJkLAJsg.js → model-BtnVr7fX.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-BdSti7Y2.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
- package/dist/web/assets/{vue-router-Kk67EaCP.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_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 +21 -1
- package/graph-provider/fuli_graph/project_agent_task_models.py +217 -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_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 +748 -162
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +230 -18
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +261 -468
- 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 +25 -6
- package/src/agent-tools/graph-handlers.js +11 -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 +20 -3
- package/src/graphiti/project-agent-mapping.js +155 -5
- 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 +30 -14
- package/src/graphiti/provider-client.js +66 -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 +2 -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 +10 -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-CZ5ziPrD.js +0 -1
- package/dist/web/assets/KnowledgePage-BRYC9S5Y.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-DEh8XWby.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-Crh7PrXa.css +0 -1
- package/dist/web/assets/ProjectAgentsPage-DPnaH-TR.js +0 -11
- package/dist/web/assets/SearchableSelect-D3HM8YF8.js +0 -1
- package/dist/web/assets/conversation-launcher-settings-DrJEotdf.js +0 -1
- package/dist/web/assets/index-BnH5mcZx.css +0 -1
- package/dist/web/assets/index-GqJf1xqg.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'})-
|
|
@@ -455,38 +762,6 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
455
762
|
if coordination_policy.ask_before_recruitment
|
|
456
763
|
else 'automatic'
|
|
457
764
|
)
|
|
458
|
-
position_kind = (
|
|
459
|
-
'temporary'
|
|
460
|
-
if request.staffing_intent == 'temporary'
|
|
461
|
-
else 'durable'
|
|
462
|
-
)
|
|
463
|
-
profile = request.recruitment_profile or ProjectAgentProfile(
|
|
464
|
-
name=f'{request.work_kind} Agent',
|
|
465
|
-
responsibility=request.objective,
|
|
466
|
-
agent_type=position_kind,
|
|
467
|
-
work_kinds=[request.work_kind],
|
|
468
|
-
capabilities=request.required_capabilities,
|
|
469
|
-
status='active',
|
|
470
|
-
)
|
|
471
|
-
if profile.agent_type != position_kind:
|
|
472
|
-
profile = profile.model_copy(update={'agent_type': position_kind})
|
|
473
|
-
self._require_agent_client_allowed(
|
|
474
|
-
profile,
|
|
475
|
-
request.source_application,
|
|
476
|
-
)
|
|
477
|
-
recruitment_id = stable_uuid(
|
|
478
|
-
self.settings.provider_id,
|
|
479
|
-
request.personal_space_id,
|
|
480
|
-
'project-agent-recruitment',
|
|
481
|
-
task_id,
|
|
482
|
-
)
|
|
483
|
-
proposed_agent_id = stable_uuid(
|
|
484
|
-
self.settings.provider_id,
|
|
485
|
-
request.personal_space_id,
|
|
486
|
-
'recruited-project-agent',
|
|
487
|
-
task_id,
|
|
488
|
-
position_kind,
|
|
489
|
-
)
|
|
490
765
|
if not hr_agent_id:
|
|
491
766
|
status = 'no_hr'
|
|
492
767
|
elif confirmation_mode == 'require_confirmation':
|
|
@@ -511,6 +786,8 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
511
786
|
'confirmation_mode': confirmation_mode,
|
|
512
787
|
'proposed_agent_id': proposed_agent_id,
|
|
513
788
|
'proposed_profile_json': profile.model_dump_json(),
|
|
789
|
+
'participant_role': participant_role,
|
|
790
|
+
'recruitment_slot': slot,
|
|
514
791
|
'occupation_emoji': profile.occupation_emoji,
|
|
515
792
|
'trigger_source_application': request.source_application,
|
|
516
793
|
'trigger_source_session_id': request.source_session_id,
|
|
@@ -519,8 +796,29 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
519
796
|
'updated_at': now,
|
|
520
797
|
'test_source': profile.test_source,
|
|
521
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
|
+
),
|
|
522
805
|
}
|
|
523
|
-
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
|
+
)
|
|
524
822
|
record = self._recruitment(raw)
|
|
525
823
|
if status != 'requested' or not hr_agent_id:
|
|
526
824
|
return record, None
|
|
@@ -530,8 +828,225 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
530
828
|
recruitment_id,
|
|
531
829
|
), recruited
|
|
532
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
|
+
|
|
533
1048
|
async def _persist_recruitment(self, raw):
|
|
534
|
-
await self.runtime.driver.execute_query(
|
|
1049
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
535
1050
|
'''
|
|
536
1051
|
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
|
|
537
1052
|
MATCH (space)-[:CONTAINS_PROJECT]->
|
|
@@ -546,15 +1061,71 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
546
1061
|
FOREACH (_ IN CASE WHEN hr IS NULL THEN [] ELSE [1] END |
|
|
547
1062
|
MERGE (recruitment)-[:RECRUITED_BY]->(hr)
|
|
548
1063
|
)
|
|
1064
|
+
RETURN recruitment.provisioning_claim_id =
|
|
1065
|
+
$provisioning_claim_id AS provisioning_claimed
|
|
549
1066
|
''',
|
|
550
1067
|
personal_space_id=raw['personal_space_id'],
|
|
551
1068
|
personal_project_id=raw['personal_project_id'],
|
|
552
1069
|
id=raw['id'],
|
|
553
1070
|
properties=raw,
|
|
554
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')
|
|
555
1077
|
)
|
|
556
1078
|
|
|
557
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')
|
|
558
1129
|
profile = ProjectAgentProfile.model_validate_json(
|
|
559
1130
|
raw['proposed_profile_json']
|
|
560
1131
|
)
|
|
@@ -591,7 +1162,7 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
591
1162
|
),
|
|
592
1163
|
)
|
|
593
1164
|
updated_at = now_utc()
|
|
594
|
-
await self.runtime.driver.execute_query(
|
|
1165
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
595
1166
|
'''
|
|
596
1167
|
MATCH (space:FuliSpace {
|
|
597
1168
|
id: $personal_space_id, kind: 'personal'
|
|
@@ -601,11 +1172,24 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
601
1172
|
})
|
|
602
1173
|
MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
|
|
603
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
|
+
)
|
|
604
1188
|
SET recruitment.status = 'fulfilled',
|
|
605
1189
|
recruitment.recruited_agent_id = $agent_id,
|
|
606
1190
|
recruitment.fulfilled_at = $updated_at,
|
|
607
1191
|
recruitment.updated_at = $updated_at,
|
|
608
|
-
recruitment.revision = recruitment.revision + 1,
|
|
1192
|
+
recruitment.revision = coalesce(recruitment.revision, 0) + 1,
|
|
609
1193
|
agent.recruitment_id = $recruitment_id,
|
|
610
1194
|
agent.recruited_at = $updated_at,
|
|
611
1195
|
agent.recruitment_reason = recruitment.reason,
|
|
@@ -616,13 +1200,33 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
616
1200
|
agent.test_source = recruitment.test_source,
|
|
617
1201
|
agent.cleanup_eligible =
|
|
618
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
|
+
)
|
|
619
1209
|
MERGE (recruitment)-[:CREATED_AGENT]->(agent)
|
|
1210
|
+
RETURN recruitment
|
|
620
1211
|
''',
|
|
621
1212
|
recruitment_id=raw['recruitment_id'],
|
|
622
1213
|
personal_space_id=raw['personal_space_id'],
|
|
623
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'),
|
|
624
1218
|
updated_at=updated_at,
|
|
625
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
|
+
)
|
|
626
1230
|
assignment_rows = await self._assignment_candidates(
|
|
627
1231
|
raw['personal_space_id'],
|
|
628
1232
|
raw['personal_project_id'],
|
|
@@ -632,6 +1236,13 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
632
1236
|
return selected
|
|
633
1237
|
|
|
634
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):
|
|
635
1246
|
records, _, _ = await self.runtime.driver.execute_query(
|
|
636
1247
|
'''
|
|
637
1248
|
MATCH (recruitment:FuliProjectAgentRecruitment {
|
|
@@ -644,62 +1255,37 @@ class StoreProjectAgentTaskRecruitment:
|
|
|
644
1255
|
recruitment_id=recruitment_id,
|
|
645
1256
|
routing_='r',
|
|
646
1257
|
)
|
|
647
|
-
return (
|
|
648
|
-
self._recruitment(dict(records[0]['recruitment']))
|
|
649
|
-
if records else None
|
|
650
|
-
)
|
|
1258
|
+
return dict(records[0]['recruitment']) if records else None
|
|
651
1259
|
|
|
652
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):
|
|
653
1268
|
records, _, _ = await self.runtime.driver.execute_query(
|
|
654
1269
|
'''
|
|
655
1270
|
MATCH (recruitment:FuliProjectAgentRecruitment {
|
|
656
1271
|
personal_space_id: $personal_space_id,
|
|
657
1272
|
task_id: $task_id
|
|
658
1273
|
})
|
|
659
|
-
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
|
|
660
1280
|
''',
|
|
661
1281
|
personal_space_id=personal_space_id,
|
|
662
1282
|
task_id=task_id,
|
|
663
1283
|
routing_='r',
|
|
664
1284
|
)
|
|
665
|
-
return
|
|
1285
|
+
return [
|
|
1286
|
+
self._recruitment(dict(row['recruitment']))
|
|
1287
|
+
for row in records
|
|
1288
|
+
]
|
|
666
1289
|
|
|
667
1290
|
def _recruitment(self, raw):
|
|
668
|
-
|
|
669
|
-
raw['proposed_profile_json']
|
|
670
|
-
)
|
|
671
|
-
occupation_emoji = raw.get('occupation_emoji') or raw.get(
|
|
672
|
-
'occupationEmoji'
|
|
673
|
-
)
|
|
674
|
-
if not proposed_profile.occupation_emoji and occupation_emoji:
|
|
675
|
-
proposed_profile = proposed_profile.model_copy(
|
|
676
|
-
update={'occupation_emoji': occupation_emoji}
|
|
677
|
-
)
|
|
678
|
-
fields = {
|
|
679
|
-
'recruitment_id': raw.get('recruitment_id') or raw['id'],
|
|
680
|
-
'personal_space_id': raw['personal_space_id'],
|
|
681
|
-
'personal_project_id': raw['personal_project_id'],
|
|
682
|
-
'task_id': raw['task_id'],
|
|
683
|
-
'coordinator_agent_id': raw['coordinator_agent_id'],
|
|
684
|
-
'hr_agent_id': raw.get('hr_agent_id'),
|
|
685
|
-
'position_kind': raw['position_kind'],
|
|
686
|
-
'work_kind': raw['work_kind'],
|
|
687
|
-
'required_capabilities': list(raw.get('required_capabilities') or []),
|
|
688
|
-
'reason_code': raw['reason_code'],
|
|
689
|
-
'reason': raw['reason'],
|
|
690
|
-
'status': raw['status'],
|
|
691
|
-
'confirmation_mode': raw['confirmation_mode'],
|
|
692
|
-
'proposed_agent_id': raw['proposed_agent_id'],
|
|
693
|
-
'proposed_profile': proposed_profile,
|
|
694
|
-
'trigger_source_application': raw.get('trigger_source_application'),
|
|
695
|
-
'trigger_source_session_id': raw.get('trigger_source_session_id'),
|
|
696
|
-
'revision': int(raw.get('revision') or 0),
|
|
697
|
-
'recruited_agent_id': raw.get('recruited_agent_id'),
|
|
698
|
-
'created_at': native_datetime(raw['created_at']),
|
|
699
|
-
'updated_at': native_datetime(raw['updated_at']),
|
|
700
|
-
'fulfilled_at': native_datetime(raw.get('fulfilled_at')),
|
|
701
|
-
}
|
|
702
|
-
for name in ('test_source', 'cleanup_eligible'):
|
|
703
|
-
if name in ProjectAgentRecruitmentRecord.model_fields:
|
|
704
|
-
fields[name] = raw.get(name)
|
|
705
|
-
return ProjectAgentRecruitmentRecord(**fields)
|
|
1291
|
+
return project_agent_recruitment_record(raw)
|