fuli-context 0.7.3 → 0.7.4-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +170 -18
  2. package/README.zh-CN.md +146 -12
  3. package/compose.graphiti.yml +2 -0
  4. package/dist/web/assets/{AboutPage-qeO_vm0T.js → AboutPage-CftRexSY.js} +1 -1
  5. package/dist/web/assets/{ConnectionsPage-DO0GR9Xg.js → ConnectionsPage-D5J91x6d.js} +1 -1
  6. package/dist/web/assets/{useMinimumLoadingDisplay-BpCyBeko.css → KnowledgeEditDialog-DWUV_NPp.css} +1 -1
  7. package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +1 -0
  8. package/dist/web/assets/{KnowledgeInspector-Bjhdu2mf.css → KnowledgeInspector--1NujtXW.css} +1 -1
  9. package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +1 -0
  10. package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +1 -0
  11. package/dist/web/assets/KnowledgePage-OUT07aKH.js +1 -0
  12. package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +4 -0
  13. package/dist/web/assets/{OverviewPage-CmCcDCAs.js → OverviewPage-CHE6XhdS.js} +1 -1
  14. package/dist/web/assets/PersonalProfilePage-B7T09bow.css +1 -0
  15. package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +1 -0
  16. package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +1 -0
  17. package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +1 -0
  18. package/dist/web/assets/{ReviewPage-B3vnZ6gn.js → ReviewPage-DUeyu7E8.js} +1 -1
  19. package/dist/web/assets/{SearchableSelect-ih69qbcS.js → SearchableSelect-C5RiB5F8.js} +1 -1
  20. package/dist/web/assets/SettingsPage-BMuASSzQ.js +1 -0
  21. package/dist/web/assets/SettingsPage-CAfz4qiV.css +1 -0
  22. package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +1 -0
  23. package/dist/web/assets/WritingTastePage-DSgvTH6x.css +1 -0
  24. package/dist/web/assets/{console-DE40hXGC.js → console-DMvHxKf9.js} +1 -1
  25. package/dist/web/assets/conversation-launcher-settings-Dnlcw7Rj.js +1 -0
  26. package/dist/web/assets/{i18n-CDxBs1sz.js → i18n-CWfd8Dtt.js} +1 -1
  27. package/dist/web/assets/{identity-BCxb1mcy.js → identity-C9mLdR8L.js} +1 -1
  28. package/dist/web/assets/index-F5e2sQ7p.js +2 -0
  29. package/dist/web/assets/{index-CCOGfZzE.css → index-X2j29j8i.css} +1 -1
  30. package/dist/web/assets/{model-CCMdt10Q.js → model-CsYAR8O3.js} +1 -1
  31. package/dist/web/assets/paths-CDREouhi.js +1 -0
  32. package/dist/web/assets/useApi-CROJJdhE-BLQiPVw0.js +1 -0
  33. package/dist/web/assets/vue-router-DN8x6iY1.js +1 -0
  34. package/dist/web/index.html +7 -6
  35. package/graph-provider/fuli_graph/app.py +238 -2
  36. package/graph-provider/fuli_graph/auth.py +18 -0
  37. package/graph-provider/fuli_graph/collaboration_context.py +253 -17
  38. package/graph-provider/fuli_graph/common_knowledge.py +6 -2
  39. package/graph-provider/fuli_graph/config.py +5 -0
  40. package/graph-provider/fuli_graph/graph_models.py +6 -0
  41. package/graph-provider/fuli_graph/graph_projection.py +14 -0
  42. package/graph-provider/fuli_graph/graph_query.py +27 -3
  43. package/graph-provider/fuli_graph/knowledge_management.py +19 -1
  44. package/graph-provider/fuli_graph/knowledge_search.py +11 -0
  45. package/graph-provider/fuli_graph/models.py +15 -2
  46. package/graph-provider/fuli_graph/personal_global_preference_models.py +234 -0
  47. package/graph-provider/fuli_graph/personal_global_preferences.py +1247 -0
  48. package/graph-provider/fuli_graph/project_action_models.py +22 -0
  49. package/graph-provider/fuli_graph/project_knowledge.py +121 -2
  50. package/graph-provider/fuli_graph/provider_values.py +24 -0
  51. package/graph-provider/fuli_graph/runtime.py +78 -0
  52. package/graph-provider/fuli_graph/search_projection.py +14 -1
  53. package/graph-provider/fuli_graph/store_knowledge.py +116 -2
  54. package/graph-provider/fuli_graph/workflow_candidate_models.py +168 -0
  55. package/graph-provider/fuli_graph/workflow_candidates.py +996 -0
  56. package/graph-provider/fuli_graph/workflow_observation_models.py +30 -0
  57. package/graph-provider/fuli_graph/workflow_observations.py +84 -0
  58. package/graph-provider/pyproject.toml +1 -1
  59. package/npm-shrinkwrap.json +18 -18
  60. package/package.json +4 -4
  61. package/skills/capturing-session-knowledge/SKILL.md +32 -5
  62. package/skills/flreview/SKILL.md +4 -3
  63. package/src/agent-tools/graph-definitions.js +136 -20
  64. package/src/agent-tools/graph-handlers.js +10 -4
  65. package/src/cli/command-registry.js +1 -1
  66. package/src/cli/local-runtime-command.js +69 -2
  67. package/src/graphiti/agent-knowledge-workflows.js +497 -16
  68. package/src/graphiti/collaboration-preference-projection.js +162 -0
  69. package/src/graphiti/federated-application.js +155 -161
  70. package/src/graphiti/federated-search-support.js +102 -0
  71. package/src/graphiti/knowledge-provider-mapping.js +15 -0
  72. package/src/graphiti/provider-client.js +162 -10
  73. package/src/graphiti/related-project-suggestions.js +109 -0
  74. package/src/graphiti/runtime-config.js +14 -0
  75. package/src/graphiti/source-marker.js +2 -2
  76. package/src/graphiti/user-taste-skill.js +241 -0
  77. package/src/graphiti/workflow-candidate-mapping.js +87 -0
  78. package/src/graphiti/workflow-observation.js +82 -0
  79. package/src/graphiti/writing-taste-profile-workflow.js +25 -0
  80. package/src/graphiti/writing-taste-profile.js +490 -0
  81. package/src/http/graph-api-router.js +15 -2
  82. package/src/mcp/context-resources.js +158 -0
  83. package/src/mcp/create-mcp-server.js +39 -5
  84. package/src/mcp/instructions.js +5 -7
  85. package/src/mcp/session-id.js +60 -0
  86. package/src/mcp/task-context-registry.js +15 -3
  87. package/src/mcp/tool-annotations.js +8 -1
  88. package/src/mcp/tool-result.js +28 -4
  89. package/src/server.js +0 -1
  90. package/src/setup/claude-code-config.js +17 -6
  91. package/src/setup/graph-runtime.js +20 -2
  92. package/src/system/runtime-settings.js +121 -2
  93. package/src/system/system-service.js +5 -1
  94. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +0 -1
  95. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +0 -1
  96. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +0 -1
  97. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +0 -4
  98. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +0 -1
  99. package/dist/web/assets/PersonalProfilePage-CdmEzyvZ.css +0 -1
  100. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +0 -1
  101. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +0 -1
  102. package/dist/web/assets/SettingsPage-C6ouU0sR.css +0 -1
  103. package/dist/web/assets/SettingsPage-oLE8mNs5.js +0 -1
  104. package/dist/web/assets/index-BzhNfzU_.js +0 -2
  105. package/dist/web/assets/paths-DwHeiGMT.js +0 -1
  106. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +0 -1
  107. package/dist/web/assets/vue-router-Drfl1F8i.js +0 -1
@@ -0,0 +1,996 @@
1
+ import hashlib
2
+ import json
3
+ import secrets
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from fastapi import HTTPException
7
+
8
+ from .provider_values import native_datetime, now_utc, stable_uuid
9
+ from .workflow_candidate_models import (
10
+ WorkflowAuthorization,
11
+ WorkflowCandidate,
12
+ WorkflowCandidatePage,
13
+ WorkflowCandidateReview,
14
+ WorkflowCandidateReviewIntent,
15
+ WorkflowCandidateReviewPreview,
16
+ WorkflowCandidateSearch,
17
+ WorkflowRecommendation,
18
+ WorkflowRecommendationPage,
19
+ WorkflowRecommendationPolicy,
20
+ WorkflowRecommendationWeights,
21
+ WorkflowRecency,
22
+ )
23
+
24
+ MINIMUM_OCCURRENCES = 3
25
+ MINIMUM_DISTINCT_SESSIONS = 3
26
+ RECOMMENDATION_THRESHOLD = 0.7
27
+ RECOMMENDATION_WEIGHTS = WorkflowRecommendationWeights(
28
+ occurrences=0.45,
29
+ distinct_sessions=0.25,
30
+ recency=0.2,
31
+ confirmation_authority=0.1,
32
+ )
33
+ DECLINE_PENALTY = 0.25
34
+ NEGATIVE_EVIDENCE_PENALTY = 0.1
35
+ HIGH_RISK_ACTION_CATEGORIES = [
36
+ 'send', 'delete', 'publish', 'payment', 'external_write'
37
+ ]
38
+ REVIEW_PREVIEW_TTL = timedelta(minutes=10)
39
+ RECOMMENDATION_POLICY = WorkflowRecommendationPolicy(
40
+ minimum_occurrences=MINIMUM_OCCURRENCES,
41
+ minimum_distinct_sessions=MINIMUM_DISTINCT_SESSIONS,
42
+ recommendation_threshold=RECOMMENDATION_THRESHOLD,
43
+ weights=RECOMMENDATION_WEIGHTS,
44
+ decline_penalty=DECLINE_PENALTY,
45
+ negative_evidence_penalty=NEGATIVE_EVIDENCE_PENALTY,
46
+ )
47
+
48
+
49
+ async def recommend_workflow_candidates(
50
+ store,
51
+ actor: dict,
52
+ request: WorkflowCandidateSearch,
53
+ *,
54
+ evaluated_at: datetime | None = None,
55
+ ) -> WorkflowRecommendationPage:
56
+ candidates = await _read_workflow_candidates(
57
+ store, actor, request, evaluated_at=evaluated_at
58
+ )
59
+ return WorkflowRecommendationPage(
60
+ policy=RECOMMENDATION_POLICY,
61
+ candidates=[
62
+ candidate
63
+ for candidate in candidates
64
+ if candidate.recommendation.recommended
65
+ or candidate.execution_authorized
66
+ ],
67
+ )
68
+
69
+
70
+ async def search_workflow_candidates(
71
+ store,
72
+ actor: dict,
73
+ request: WorkflowCandidateSearch,
74
+ *,
75
+ evaluated_at: datetime | None = None,
76
+ ) -> WorkflowCandidatePage:
77
+ return WorkflowCandidatePage(
78
+ policy=RECOMMENDATION_POLICY,
79
+ candidates=await _read_workflow_candidates(
80
+ store, actor, request, evaluated_at=evaluated_at
81
+ ),
82
+ )
83
+
84
+
85
+ async def preview_workflow_candidate_review(
86
+ store,
87
+ actor: dict,
88
+ candidate_id: str,
89
+ request: WorkflowCandidateReviewIntent,
90
+ *,
91
+ created_at: datetime | None = None,
92
+ token_factory=secrets.token_urlsafe,
93
+ ) -> WorkflowCandidateReviewPreview:
94
+ space = await _personal_space(
95
+ store, actor, request.personal_space_id, 'maintainer'
96
+ )
97
+ target_rows, _, _ = await store.runtime.driver.execute_query(
98
+ _WORKFLOW_CANDIDATE_REVIEW_TARGET_QUERY,
99
+ space_id=space['id'],
100
+ candidate_id=candidate_id,
101
+ routing_='r',
102
+ )
103
+ if not target_rows:
104
+ raise HTTPException(status_code=404, detail='workflow candidate not found')
105
+ current_version = int(target_rows[0]['candidate_version'])
106
+ current_evidence_revision = int(target_rows[0]['evidence_revision'])
107
+ current_decision_revision = int(target_rows[0]['decision_revision'])
108
+ if (
109
+ current_version != request.candidate_version
110
+ or current_evidence_revision != request.evidence_revision
111
+ or current_decision_revision != request.decision_revision
112
+ ):
113
+ raise HTTPException(
114
+ status_code=409,
115
+ detail='workflow candidate changed; refresh before reviewing',
116
+ )
117
+ issued_at = created_at or now_utc()
118
+ expires_at = issued_at + REVIEW_PREVIEW_TTL
119
+ approval_token = token_factory(32)
120
+ token_hash = _sha256(approval_token)
121
+ payload_fingerprint = _review_payload_fingerprint(candidate_id, request)
122
+ preview_id = stable_uuid(
123
+ space['id'], 'workflow-review-preview', token_hash
124
+ )
125
+ records, _, _ = await store.runtime.driver.execute_query(
126
+ _CREATE_WORKFLOW_REVIEW_PREVIEW_QUERY,
127
+ space_id=space['id'],
128
+ candidate_id=candidate_id,
129
+ candidate_version=request.candidate_version,
130
+ evidence_revision=request.evidence_revision,
131
+ decision_revision=request.decision_revision,
132
+ rule_fingerprint=(
133
+ target_rows[0].get('rule_fingerprint') or candidate_id
134
+ ),
135
+ preview_id=preview_id,
136
+ token_hash=token_hash,
137
+ payload_fingerprint=payload_fingerprint,
138
+ issued_to_actor_id=actor['id'],
139
+ issued_channel='provider_http_human_review',
140
+ created_at=issued_at,
141
+ expires_at=expires_at,
142
+ )
143
+ if not records:
144
+ raise HTTPException(
145
+ status_code=409,
146
+ detail='workflow candidate changed before review preview was created',
147
+ )
148
+ return WorkflowCandidateReviewPreview(
149
+ preview_id=preview_id,
150
+ candidate_id=candidate_id,
151
+ candidate_version=request.candidate_version,
152
+ evidence_revision=request.evidence_revision,
153
+ decision_revision=request.decision_revision,
154
+ payload_fingerprint=payload_fingerprint,
155
+ approval_token=approval_token,
156
+ expires_at=expires_at,
157
+ )
158
+
159
+
160
+ async def review_workflow_candidate(
161
+ store,
162
+ actor: dict,
163
+ candidate_id: str,
164
+ request: WorkflowCandidateReview,
165
+ *,
166
+ reviewed_at: datetime | None = None,
167
+ ) -> WorkflowCandidate:
168
+ space = await _personal_space(
169
+ store, actor, request.personal_space_id, 'maintainer'
170
+ )
171
+ changed_at = reviewed_at or now_utc()
172
+ payload_fingerprint = _review_payload_fingerprint(candidate_id, request)
173
+ event_id = stable_uuid(
174
+ space['id'],
175
+ 'workflow-review-event',
176
+ candidate_id,
177
+ str(request.candidate_version),
178
+ request.idempotency_key,
179
+ )
180
+ existing_rows, _, _ = await store.runtime.driver.execute_query(
181
+ _WORKFLOW_REVIEW_EVENT_QUERY,
182
+ space_id=space['id'],
183
+ event_id=event_id,
184
+ routing_='r',
185
+ )
186
+ if existing_rows:
187
+ if existing_rows[0].get('payload_fingerprint') != payload_fingerprint:
188
+ raise HTTPException(
189
+ status_code=409,
190
+ detail='workflow review idempotency key was used for another payload',
191
+ )
192
+ return await _read_workflow_candidate(
193
+ store, actor, space['id'], candidate_id, changed_at
194
+ )
195
+ token_hash = _sha256(request.approval_token)
196
+ if request.decision == 'reject':
197
+ records, _, _ = await store.runtime.driver.execute_query(
198
+ _REJECT_WORKFLOW_CANDIDATE_QUERY,
199
+ space_id=space['id'],
200
+ candidate_id=candidate_id,
201
+ candidate_version=request.candidate_version,
202
+ evidence_revision=request.evidence_revision,
203
+ decision_revision=request.decision_revision,
204
+ token_hash=token_hash,
205
+ payload_fingerprint=payload_fingerprint,
206
+ event_id=event_id,
207
+ actor_id=actor['id'],
208
+ reason=request.reason,
209
+ authority=request.authority.kind,
210
+ authority_label=request.authority.label,
211
+ decision_source=request.decision_source,
212
+ reviewed_at=changed_at,
213
+ )
214
+ else:
215
+ rule_id = stable_uuid(
216
+ space['id'],
217
+ 'workflow-rule',
218
+ candidate_id,
219
+ str(request.candidate_version),
220
+ )
221
+ authorization_id = stable_uuid(
222
+ space['id'],
223
+ 'workflow-authorization',
224
+ event_id,
225
+ )
226
+ records, _, _ = await store.runtime.driver.execute_query(
227
+ _APPROVE_WORKFLOW_CANDIDATE_QUERY,
228
+ space_id=space['id'],
229
+ candidate_id=candidate_id,
230
+ candidate_version=request.candidate_version,
231
+ evidence_revision=request.evidence_revision,
232
+ decision_revision=request.decision_revision,
233
+ token_hash=token_hash,
234
+ payload_fingerprint=payload_fingerprint,
235
+ rule_id=rule_id,
236
+ authorization_id=authorization_id,
237
+ event_id=event_id,
238
+ actor_id=actor['id'],
239
+ reason=request.reason,
240
+ authority=request.authority.kind,
241
+ authority_label=request.authority.label,
242
+ decision_source=request.decision_source,
243
+ high_risk_action_categories=HIGH_RISK_ACTION_CATEGORIES,
244
+ reviewed_at=changed_at,
245
+ )
246
+ if not records:
247
+ raise HTTPException(
248
+ status_code=409,
249
+ detail=(
250
+ 'workflow review token is invalid, expired, used, or bound '
251
+ 'to another candidate version'
252
+ ),
253
+ )
254
+ return await _read_workflow_candidate(
255
+ store, actor, space['id'], candidate_id, changed_at
256
+ )
257
+
258
+
259
+ async def _read_workflow_candidate(
260
+ store,
261
+ actor: dict,
262
+ space_id: str,
263
+ candidate_id: str,
264
+ evaluated_at: datetime,
265
+ ) -> WorkflowCandidate:
266
+ candidates = await _read_workflow_candidates(
267
+ store,
268
+ actor,
269
+ WorkflowCandidateSearch(
270
+ personal_space_id=space_id,
271
+ limit=1,
272
+ ),
273
+ evaluated_at=evaluated_at,
274
+ candidate_id=candidate_id,
275
+ )
276
+ if not candidates:
277
+ raise HTTPException(status_code=404, detail='workflow candidate not found')
278
+ return candidates[0]
279
+
280
+
281
+ async def _read_workflow_candidates(
282
+ store,
283
+ actor: dict,
284
+ request: WorkflowCandidateSearch,
285
+ *,
286
+ evaluated_at: datetime | None,
287
+ candidate_id: str | None = None,
288
+ ) -> list[WorkflowCandidate]:
289
+ space = await _personal_space(store, actor, request.personal_space_id, 'reader')
290
+ rows, _, _ = await store.runtime.driver.execute_query(
291
+ _WORKFLOW_CANDIDATE_QUERY,
292
+ space_id=space['id'],
293
+ personal_project_id=request.personal_project_id,
294
+ after_step_key=request.after_step_key,
295
+ candidate_id=candidate_id,
296
+ minimum_occurrences=MINIMUM_OCCURRENCES,
297
+ limit=request.limit,
298
+ routing_='r',
299
+ )
300
+ return [
301
+ _candidate(dict(row), evaluated_at=evaluated_at)
302
+ for row in rows
303
+ ]
304
+
305
+
306
+ async def _personal_space(store, actor: dict, space_id: str, role: str) -> dict:
307
+ space = await store.authorize(actor, space_id, role)
308
+ if space['kind'] != 'personal':
309
+ raise HTTPException(status_code=422, detail='workflow candidates are personal-only')
310
+ return space
311
+
312
+
313
+ async def materialize_workflow_candidates(
314
+ store,
315
+ space: dict,
316
+ *,
317
+ personal_project_id: str | None,
318
+ pairs: list[dict],
319
+ ) -> list[str]:
320
+ if not pairs:
321
+ return []
322
+ rows, _, _ = await store.runtime.driver.execute_query(
323
+ _WORKFLOW_CANDIDATE_EVIDENCE_QUERY,
324
+ group_id=space['group_id'],
325
+ personal_project_id=personal_project_id,
326
+ pairs=pairs,
327
+ routing_='r',
328
+ )
329
+ changed_at = now_utc()
330
+ candidates = []
331
+ for value in (dict(row) for row in rows):
332
+ occurrence_count = int(value.get('occurrence_count') or 0)
333
+ if occurrence_count < MINIMUM_OCCURRENCES:
334
+ continue
335
+ condition_json = value.get('condition_json') or '{}'
336
+ candidate_id = stable_uuid(
337
+ space['id'],
338
+ 'workflow-candidate',
339
+ personal_project_id or 'personal-global',
340
+ value['workflow_key'],
341
+ value['source_step_id'],
342
+ value['target_step_id'],
343
+ )
344
+ evidence_ids = sorted(value.get('evidence_ids') or [])
345
+ evidence_fingerprint = stable_uuid(
346
+ 'workflow-evidence',
347
+ json.dumps(
348
+ {
349
+ 'occurrence_count': occurrence_count,
350
+ 'distinct_session_count': int(
351
+ value.get('distinct_session_count') or 0
352
+ ),
353
+ 'first_observed_at': native_datetime(
354
+ value['first_observed_at']
355
+ ).isoformat(),
356
+ 'last_observed_at': native_datetime(
357
+ value['last_observed_at']
358
+ ).isoformat(),
359
+ 'confirmation_authority': (
360
+ value.get('confirmation_authority') or 'none'
361
+ ),
362
+ 'negative_evidence_count': int(
363
+ value.get('negative_evidence_count') or 0
364
+ ),
365
+ 'evidence_ids': evidence_ids,
366
+ },
367
+ ensure_ascii=False,
368
+ separators=(',', ':'),
369
+ sort_keys=True,
370
+ ),
371
+ )
372
+ candidates.append({
373
+ 'candidate_id': candidate_id,
374
+ 'personal_space_id': space['id'],
375
+ 'personal_project_id': personal_project_id,
376
+ 'workflow_key': value['workflow_key'],
377
+ 'source_step_id': value['source_step_id'],
378
+ 'source_step_key': value['source_step_key'],
379
+ 'source_step_name': value['source_step_name'],
380
+ 'target_step_id': value['target_step_id'],
381
+ 'target_step_key': value['target_step_key'],
382
+ 'target_step_name': value['target_step_name'],
383
+ 'condition_json': condition_json,
384
+ 'rule_fingerprint': stable_uuid(
385
+ 'workflow-rule',
386
+ value['workflow_key'],
387
+ value['source_step_id'],
388
+ value['target_step_id'],
389
+ condition_json,
390
+ ),
391
+ 'evidence_fingerprint': evidence_fingerprint,
392
+ 'occurrence_count': occurrence_count,
393
+ 'distinct_session_count': int(
394
+ value.get('distinct_session_count') or 0
395
+ ),
396
+ 'first_observed_at': value['first_observed_at'],
397
+ 'last_observed_at': value['last_observed_at'],
398
+ 'confirmation_authority': (
399
+ value.get('confirmation_authority') or 'none'
400
+ ),
401
+ 'negative_evidence_count': int(
402
+ value.get('negative_evidence_count') or 0
403
+ ),
404
+ 'evidence_ids': evidence_ids,
405
+ 'changed_at': changed_at,
406
+ })
407
+ if not candidates:
408
+ return []
409
+ records, _, _ = await store.runtime.driver.execute_query(
410
+ _MATERIALIZE_WORKFLOW_CANDIDATE_QUERY,
411
+ space_id=space['id'],
412
+ candidates=candidates,
413
+ )
414
+ return [record['candidate_id'] for record in records]
415
+
416
+
417
+ def _candidate(row: dict, *, evaluated_at: datetime | None) -> WorkflowCandidate:
418
+ now = evaluated_at or datetime.now(timezone.utc)
419
+ first_observed_at = native_datetime(row['first_observed_at'])
420
+ last_observed_at = native_datetime(row['last_observed_at'])
421
+ age_days = max((now - last_observed_at).total_seconds() / 86_400, 0)
422
+ recency_score = _recency_score(age_days)
423
+ occurrence_count = int(row.get('occurrence_count') or 0)
424
+ distinct_session_count = int(row.get('distinct_session_count') or 0)
425
+ negative_evidence_count = int(row.get('negative_evidence_count') or 0)
426
+ decline_count = int(row.get('decline_count') or 0)
427
+ authority = row.get('confirmation_authority') or 'none'
428
+ candidate_version = int(row.get('candidate_version') or 1)
429
+ evidence_revision = int(row.get('evidence_revision') or 1)
430
+ decision_revision = int(row.get('decision_revision') or 0)
431
+ rule_fingerprint = row.get('rule_fingerprint') or row['candidate_id']
432
+ authorization = _authorization(row)
433
+ execution_authorized = bool(
434
+ authorization
435
+ and authorization.active
436
+ and authorization.candidate_version == candidate_version
437
+ and authorization.rule_fingerprint == rule_fingerprint
438
+ )
439
+ score = _recommendation_score(
440
+ occurrence_count=occurrence_count,
441
+ distinct_session_count=distinct_session_count,
442
+ recency_score=recency_score,
443
+ authority=authority,
444
+ negative_evidence_count=negative_evidence_count,
445
+ decline_count=decline_count,
446
+ )
447
+ recommended = (
448
+ score >= RECOMMENDATION_THRESHOLD
449
+ and distinct_session_count >= MINIMUM_DISTINCT_SESSIONS
450
+ )
451
+ action = (
452
+ 'authorized_rule_available'
453
+ if execution_authorized
454
+ else 'ask_user'
455
+ if recommended
456
+ else 'none'
457
+ )
458
+ return WorkflowCandidate(
459
+ candidate_id=row['candidate_id'],
460
+ candidate_version=candidate_version,
461
+ evidence_revision=evidence_revision,
462
+ decision_revision=decision_revision,
463
+ rule_fingerprint=rule_fingerprint,
464
+ workflow_key=(
465
+ row.get('workflow_key')
466
+ or f"{row['source_step_key']}->{row['target_step_key']}"
467
+ ),
468
+ condition=_workflow_condition(row.get('condition_json')),
469
+ personal_space_id=row['personal_space_id'],
470
+ personal_project_id=row.get('personal_project_id'),
471
+ source_step_id=row['source_step_id'],
472
+ source_step_key=row['source_step_key'],
473
+ source_step_name=row['source_step_name'],
474
+ target_step_id=row['target_step_id'],
475
+ target_step_key=row['target_step_key'],
476
+ target_step_name=row['target_step_name'],
477
+ status=row.get('status') or 'pending',
478
+ occurrence_count=occurrence_count,
479
+ distinct_session_count=distinct_session_count,
480
+ recency=WorkflowRecency(
481
+ first_observed_at=first_observed_at,
482
+ last_observed_at=last_observed_at,
483
+ age_days=round(age_days, 4),
484
+ score=recency_score,
485
+ ),
486
+ confirmation_authority=authority,
487
+ negative_evidence_count=negative_evidence_count,
488
+ decline_count=decline_count,
489
+ reviewed_at=native_datetime(row.get('reviewed_at')),
490
+ review_reason=row.get('review_reason'),
491
+ recommendation=WorkflowRecommendation(
492
+ recommended=recommended,
493
+ score=score,
494
+ threshold=RECOMMENDATION_THRESHOLD,
495
+ action=action,
496
+ ),
497
+ execution_authorized=execution_authorized,
498
+ authorization=authorization,
499
+ )
500
+
501
+
502
+ def _authorization(row: dict) -> WorkflowAuthorization | None:
503
+ if not row.get('authorization_id'):
504
+ return None
505
+ return WorkflowAuthorization(
506
+ authorization_id=row['authorization_id'],
507
+ candidate_id=row.get('authorization_candidate_id') or row['candidate_id'],
508
+ candidate_version=int(
509
+ row.get('authorization_candidate_version')
510
+ or row.get('candidate_version')
511
+ or 1
512
+ ),
513
+ rule_id=row.get('authorization_rule_id') or row['authorization_id'],
514
+ rule_fingerprint=(
515
+ row.get('authorization_rule_fingerprint')
516
+ or row.get('rule_fingerprint')
517
+ or row['candidate_id']
518
+ ),
519
+ scope='durable',
520
+ active=row.get('authorization_active') is True,
521
+ authority=row['authorization_authority'],
522
+ created_at=native_datetime(row['authorization_created_at']),
523
+ high_risk_per_call_approval_required=True,
524
+ high_risk_action_categories=(
525
+ row.get('high_risk_action_categories')
526
+ or HIGH_RISK_ACTION_CATEGORIES
527
+ ),
528
+ )
529
+
530
+
531
+ def _recommendation_score(
532
+ *,
533
+ occurrence_count: int,
534
+ distinct_session_count: int,
535
+ recency_score: float,
536
+ authority: str,
537
+ negative_evidence_count: int,
538
+ decline_count: int,
539
+ ) -> float:
540
+ occurrence_signal = min(occurrence_count / MINIMUM_OCCURRENCES, 1)
541
+ session_signal = min(
542
+ distinct_session_count / MINIMUM_DISTINCT_SESSIONS,
543
+ 1,
544
+ )
545
+ authority_signal = 1 if authority in {'user', 'authoritative_source'} else 0
546
+ weighted = (
547
+ occurrence_signal * RECOMMENDATION_WEIGHTS.occurrences
548
+ + session_signal * RECOMMENDATION_WEIGHTS.distinct_sessions
549
+ + recency_score * RECOMMENDATION_WEIGHTS.recency
550
+ + authority_signal * RECOMMENDATION_WEIGHTS.confirmation_authority
551
+ )
552
+ penalty = (
553
+ decline_count * DECLINE_PENALTY
554
+ + negative_evidence_count * NEGATIVE_EVIDENCE_PENALTY
555
+ )
556
+ return round(min(max(weighted - penalty, 0), 1), 4)
557
+
558
+
559
+ def _recency_score(age_days: float) -> float:
560
+ if age_days <= 7:
561
+ return 1
562
+ if age_days <= 30:
563
+ return 0.75
564
+ if age_days <= 90:
565
+ return 0.5
566
+ return 0.25
567
+
568
+
569
+ def _review_payload_fingerprint(
570
+ candidate_id: str,
571
+ request: WorkflowCandidateReviewIntent,
572
+ ) -> str:
573
+ payload = {
574
+ 'candidate_id': candidate_id,
575
+ **request.model_dump(mode='json', exclude={'approval_token'}),
576
+ }
577
+ return _sha256(json.dumps(
578
+ payload,
579
+ ensure_ascii=False,
580
+ separators=(',', ':'),
581
+ sort_keys=True,
582
+ ))
583
+
584
+
585
+ def _sha256(value: str) -> str:
586
+ return hashlib.sha256(value.encode('utf-8')).hexdigest()
587
+
588
+
589
+ def _workflow_condition(value: str | None) -> dict[str, object]:
590
+ try:
591
+ parsed = json.loads(value or '{}')
592
+ except (TypeError, ValueError):
593
+ return {}
594
+ return parsed if isinstance(parsed, dict) else {}
595
+
596
+
597
+ _WORKFLOW_CANDIDATE_QUERY = '''
598
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
599
+ [:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate)
600
+ WHERE candidate.occurrence_count >= $minimum_occurrences
601
+ AND ($candidate_id IS NULL OR candidate.id = $candidate_id)
602
+ AND ($personal_project_id IS NULL OR
603
+ candidate.personal_project_id = $personal_project_id)
604
+ AND ($after_step_key IS NULL OR candidate.source_step_key = $after_step_key)
605
+ OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
606
+ (authorization:FuliWorkflowAuthorization {active: true})
607
+ WITH candidate, authorization
608
+ ORDER BY authorization.created_at DESC
609
+ WITH candidate, head(collect(authorization)) AS authorization
610
+ RETURN candidate.id AS candidate_id,
611
+ coalesce(candidate.candidate_version, 1) AS candidate_version,
612
+ coalesce(candidate.evidence_revision, 1) AS evidence_revision,
613
+ coalesce(candidate.decision_revision, 0) AS decision_revision,
614
+ coalesce(candidate.rule_fingerprint, candidate.id) AS rule_fingerprint,
615
+ candidate.workflow_key AS workflow_key,
616
+ coalesce(candidate.condition_json, '{}') AS condition_json,
617
+ candidate.personal_space_id AS personal_space_id,
618
+ candidate.personal_project_id AS personal_project_id,
619
+ candidate.source_step_id AS source_step_id,
620
+ candidate.source_step_key AS source_step_key,
621
+ candidate.source_step_name AS source_step_name,
622
+ candidate.target_step_id AS target_step_id,
623
+ candidate.target_step_key AS target_step_key,
624
+ candidate.target_step_name AS target_step_name,
625
+ coalesce(candidate.status, 'pending') AS status,
626
+ candidate.occurrence_count AS occurrence_count,
627
+ candidate.distinct_session_count AS distinct_session_count,
628
+ candidate.first_observed_at AS first_observed_at,
629
+ candidate.last_observed_at AS last_observed_at,
630
+ coalesce(candidate.confirmation_authority, 'none') AS confirmation_authority,
631
+ coalesce(candidate.negative_evidence_count, 0) AS negative_evidence_count,
632
+ coalesce(candidate.decline_count, 0) AS decline_count,
633
+ candidate.reviewed_at AS reviewed_at,
634
+ candidate.review_reason AS review_reason,
635
+ authorization.id AS authorization_id,
636
+ coalesce(authorization.active, false) AS authorization_active,
637
+ authorization.candidate_id AS authorization_candidate_id,
638
+ authorization.candidate_version AS authorization_candidate_version,
639
+ authorization.rule_id AS authorization_rule_id,
640
+ authorization.rule_fingerprint AS authorization_rule_fingerprint,
641
+ authorization.authority AS authorization_authority,
642
+ authorization.created_at AS authorization_created_at,
643
+ authorization.high_risk_action_categories AS high_risk_action_categories
644
+ ORDER BY candidate.last_observed_at DESC, candidate.id
645
+ LIMIT $limit
646
+ '''
647
+
648
+
649
+ _WORKFLOW_CANDIDATE_REVIEW_TARGET_QUERY = '''
650
+ /* WORKFLOW_CANDIDATE_REVIEW_TARGET */
651
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
652
+ [:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
653
+ id: $candidate_id
654
+ })
655
+ RETURN coalesce(candidate.candidate_version, 1) AS candidate_version,
656
+ coalesce(candidate.evidence_revision, 1) AS evidence_revision,
657
+ coalesce(candidate.decision_revision, 0) AS decision_revision,
658
+ coalesce(candidate.rule_fingerprint, candidate.id) AS rule_fingerprint
659
+ '''
660
+
661
+
662
+ _CREATE_WORKFLOW_REVIEW_PREVIEW_QUERY = '''
663
+ /* WORKFLOW_REVIEW_PREVIEW_CREATE */
664
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
665
+ [:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
666
+ id: $candidate_id,
667
+ candidate_version: $candidate_version,
668
+ evidence_revision: $evidence_revision,
669
+ decision_revision: $decision_revision,
670
+ rule_fingerprint: $rule_fingerprint
671
+ })
672
+ CREATE (preview:FuliWorkflowReviewPreview {
673
+ id: $preview_id,
674
+ candidate_id: $candidate_id,
675
+ candidate_version: $candidate_version,
676
+ evidence_revision: $evidence_revision,
677
+ decision_revision: $decision_revision,
678
+ rule_fingerprint: $rule_fingerprint,
679
+ token_hash: $token_hash,
680
+ payload_fingerprint: $payload_fingerprint,
681
+ issued_to_actor_id: $issued_to_actor_id,
682
+ issued_channel: $issued_channel,
683
+ created_at: $created_at,
684
+ expires_at: $expires_at,
685
+ used_at: null
686
+ })
687
+ MERGE (candidate)-[:HAS_WORKFLOW_REVIEW_PREVIEW]->(preview)
688
+ RETURN preview.id AS preview_id
689
+ '''
690
+
691
+
692
+ _WORKFLOW_REVIEW_EVENT_QUERY = '''
693
+ /* WORKFLOW_REVIEW_EVENT_LOOKUP */
694
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
695
+ [:HAS_WORKFLOW_CANDIDATE]->(:FuliWorkflowCandidate)-
696
+ [:HAS_WORKFLOW_REVIEW_EVENT]->(event:FuliWorkflowReviewEvent {
697
+ id: $event_id
698
+ })
699
+ RETURN event.payload_fingerprint AS payload_fingerprint
700
+ '''
701
+
702
+
703
+ _REJECT_WORKFLOW_CANDIDATE_QUERY = '''
704
+ /* WORKFLOW_CANDIDATE_REJECT */
705
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
706
+ [:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
707
+ id: $candidate_id
708
+ })
709
+ MATCH (candidate)-[:HAS_WORKFLOW_REVIEW_PREVIEW]->
710
+ (preview:FuliWorkflowReviewPreview {
711
+ token_hash: $token_hash
712
+ })
713
+ WHERE preview.used_at IS NULL
714
+ AND preview.expires_at >= $reviewed_at
715
+ AND preview.payload_fingerprint = $payload_fingerprint
716
+ AND preview.candidate_version = $candidate_version
717
+ AND preview.evidence_revision = $evidence_revision
718
+ AND preview.decision_revision = $decision_revision
719
+ AND preview.issued_to_actor_id = $actor_id
720
+ AND preview.rule_fingerprint = candidate.rule_fingerprint
721
+ SET candidate.review_lock_nonce =
722
+ coalesce(candidate.review_lock_nonce, 0) + 1
723
+ WITH candidate, preview
724
+ WHERE preview.used_at IS NULL
725
+ AND candidate.candidate_version = $candidate_version
726
+ AND candidate.evidence_revision = $evidence_revision
727
+ AND candidate.decision_revision = $decision_revision
728
+ AND candidate.rule_fingerprint = preview.rule_fingerprint
729
+ MERGE (event:FuliWorkflowReviewEvent {id: $event_id})
730
+ ON CREATE SET event.candidate_id = $candidate_id,
731
+ event.candidate_version = $candidate_version,
732
+ event.evidence_revision = $evidence_revision,
733
+ event.decision_revision = $decision_revision,
734
+ event.payload_fingerprint = $payload_fingerprint,
735
+ event.decision = 'reject',
736
+ event.reason = $reason,
737
+ event.authority = $authority,
738
+ event.authority_label = $authority_label,
739
+ event.decision_source = $decision_source,
740
+ event.created_at = $reviewed_at,
741
+ preview.used_at = $reviewed_at,
742
+ preview.used_by_event_id = $event_id,
743
+ candidate.status = 'rejected',
744
+ candidate.decision_revision = $decision_revision + 1,
745
+ candidate.decline_count =
746
+ coalesce(candidate.decline_count, 0) + 1,
747
+ candidate.reviewed_at = $reviewed_at,
748
+ candidate.review_reason = $reason,
749
+ candidate.review_authority = $authority
750
+ MERGE (candidate)-[:HAS_WORKFLOW_REVIEW_EVENT]->(event)
751
+ WITH candidate, event
752
+ OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
753
+ (authorization:FuliWorkflowAuthorization {active: true})
754
+ WITH candidate, event, collect(authorization) AS active_authorizations
755
+ FOREACH (authorization IN active_authorizations |
756
+ SET authorization.active = false,
757
+ authorization.revoked_at = $reviewed_at,
758
+ authorization.revocation_reason = $reason
759
+ )
760
+ RETURN event.id AS event_id
761
+ '''
762
+
763
+
764
+ _APPROVE_WORKFLOW_CANDIDATE_QUERY = '''
765
+ /* WORKFLOW_CANDIDATE_APPROVE */
766
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
767
+ [:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
768
+ id: $candidate_id
769
+ })
770
+ MATCH (candidate)-[:HAS_WORKFLOW_REVIEW_PREVIEW]->
771
+ (preview:FuliWorkflowReviewPreview {
772
+ token_hash: $token_hash
773
+ })
774
+ WHERE preview.used_at IS NULL
775
+ AND preview.expires_at >= $reviewed_at
776
+ AND preview.payload_fingerprint = $payload_fingerprint
777
+ AND preview.candidate_version = $candidate_version
778
+ AND preview.evidence_revision = $evidence_revision
779
+ AND preview.decision_revision = $decision_revision
780
+ AND preview.issued_to_actor_id = $actor_id
781
+ AND preview.rule_fingerprint = candidate.rule_fingerprint
782
+ SET candidate.review_lock_nonce =
783
+ coalesce(candidate.review_lock_nonce, 0) + 1
784
+ WITH candidate, preview
785
+ WHERE preview.used_at IS NULL
786
+ AND candidate.candidate_version = $candidate_version
787
+ AND candidate.evidence_revision = $evidence_revision
788
+ AND candidate.decision_revision = $decision_revision
789
+ AND candidate.rule_fingerprint = preview.rule_fingerprint
790
+ MERGE (event:FuliWorkflowReviewEvent {id: $event_id})
791
+ ON CREATE SET event.candidate_id = $candidate_id,
792
+ event.candidate_version = $candidate_version,
793
+ event.evidence_revision = $evidence_revision,
794
+ event.decision_revision = $decision_revision,
795
+ event.payload_fingerprint = $payload_fingerprint,
796
+ event.decision = 'approve',
797
+ event.reason = $reason,
798
+ event.authority = $authority,
799
+ event.authority_label = $authority_label,
800
+ event.decision_source = $decision_source,
801
+ event.created_at = $reviewed_at,
802
+ preview.used_at = $reviewed_at,
803
+ preview.used_by_event_id = $event_id,
804
+ candidate.status = 'approved',
805
+ candidate.decision_revision = $decision_revision + 1,
806
+ candidate.reviewed_at = $reviewed_at,
807
+ candidate.review_reason = $reason,
808
+ candidate.review_authority = $authority
809
+ MERGE (candidate)-[:HAS_WORKFLOW_REVIEW_EVENT]->(event)
810
+ WITH candidate, event
811
+ OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
812
+ (previous:FuliWorkflowAuthorization {active: true})
813
+ WHERE previous IS NULL OR previous.id <> $authorization_id
814
+ WITH candidate, event, collect(previous) AS previous_authorizations
815
+ FOREACH (previous IN previous_authorizations |
816
+ SET previous.active = false,
817
+ previous.revoked_at = $reviewed_at,
818
+ previous.revocation_reason = 'superseded by a later workflow approval'
819
+ )
820
+ MERGE (rule:FuliWorkflowRule {id: $rule_id})
821
+ ON CREATE SET rule.candidate_id = $candidate_id,
822
+ rule.candidate_version = $candidate_version,
823
+ rule.rule_fingerprint = candidate.rule_fingerprint,
824
+ rule.source_step_id = candidate.source_step_id,
825
+ rule.source_step_key = candidate.source_step_key,
826
+ rule.target_step_id = candidate.target_step_id,
827
+ rule.target_step_key = candidate.target_step_key,
828
+ rule.condition_json = candidate.condition_json,
829
+ rule.created_at = $reviewed_at
830
+ MERGE (candidate)-[:MATERIALIZED_AS_WORKFLOW_RULE]->(rule)
831
+ MERGE (authorization:FuliWorkflowAuthorization {id: $authorization_id})
832
+ ON CREATE SET authorization.candidate_id = $candidate_id,
833
+ authorization.candidate_version = $candidate_version,
834
+ authorization.rule_id = $rule_id,
835
+ authorization.rule_fingerprint = candidate.rule_fingerprint,
836
+ authorization.scope = 'durable',
837
+ authorization.active = true,
838
+ authorization.authority = $authority,
839
+ authorization.authority_label = $authority_label,
840
+ authorization.decision_source = $decision_source,
841
+ authorization.created_at = $reviewed_at,
842
+ authorization.high_risk_per_call_approval_required = true,
843
+ authorization.high_risk_action_categories =
844
+ $high_risk_action_categories
845
+ MERGE (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->(authorization)
846
+ MERGE (authorization)-[:AUTHORIZES_WORKFLOW_RULE]->(rule)
847
+ RETURN event.id AS event_id
848
+ '''
849
+
850
+
851
+ _WORKFLOW_CANDIDATE_EVIDENCE_QUERY = '''
852
+ /* WORKFLOW_CANDIDATE_EVIDENCE_AGGREGATION */
853
+ UNWIND $pairs AS pair
854
+ MATCH (source:Entity {uuid: pair.source_step_id})-
855
+ [evidence:RELATES_TO {
856
+ group_id: $group_id,
857
+ name: 'RECOMMENDS_NEXT'
858
+ }]->(target:Entity {uuid: pair.target_step_id})
859
+ WHERE evidence.invalid_at IS NULL
860
+ AND evidence.fuli_key = pair.workflow_key
861
+ AND coalesce(evidence.fuli_workflow_condition_json, '{}') = pair.condition_json
862
+ AND evidence.fuli_workflow_session_authority = 'mcp_host'
863
+ WITH pair, source, target, collect(DISTINCT evidence) AS evidence_items
864
+ UNWIND evidence_items AS evidence
865
+ UNWIND coalesce(evidence.episodes, []) AS episode_id
866
+ MATCH (episode:Episodic {uuid: episode_id, group_id: $group_id})
867
+ WHERE episode.fuli_workflow_session_authority = 'mcp_host'
868
+ AND (
869
+ episode.fuli_personal_project_id = $personal_project_id
870
+ OR (
871
+ episode.fuli_personal_project_id IS NULL
872
+ AND $personal_project_id IS NULL
873
+ )
874
+ )
875
+ WITH pair, source, target,
876
+ collect(DISTINCT evidence) AS evidence_items,
877
+ collect(DISTINCT episode) AS episodes
878
+ WITH pair, source, target, episodes,
879
+ CASE
880
+ WHEN any(evidence IN evidence_items
881
+ WHERE evidence.fuli_workflow_confirmation_authority = 'user')
882
+ THEN 'user'
883
+ WHEN any(evidence IN evidence_items
884
+ WHERE evidence.fuli_workflow_confirmation_authority =
885
+ 'authoritative_source')
886
+ THEN 'authoritative_source'
887
+ WHEN any(evidence IN evidence_items
888
+ WHERE evidence.fuli_workflow_confirmation_authority =
889
+ 'agent_proposed')
890
+ THEN 'agent_proposed'
891
+ WHEN any(evidence IN evidence_items
892
+ WHERE evidence.fuli_workflow_confirmation_authority =
893
+ 'import_proposed')
894
+ THEN 'import_proposed'
895
+ ELSE 'none'
896
+ END AS confirmation_authority,
897
+ reduce(
898
+ total = 0,
899
+ evidence IN evidence_items |
900
+ total + coalesce(evidence.fuli_negative_evidence_count, 0)
901
+ ) AS negative_evidence_count,
902
+ [evidence IN evidence_items | evidence.uuid] AS evidence_ids
903
+ UNWIND episodes AS episode
904
+ RETURN source.uuid AS source_step_id,
905
+ pair.workflow_key AS workflow_key,
906
+ coalesce(source.fuli_key, pair.source_step_key) AS source_step_key,
907
+ source.name AS source_step_name,
908
+ target.uuid AS target_step_id,
909
+ coalesce(target.fuli_key, pair.target_step_key) AS target_step_key,
910
+ target.name AS target_step_name,
911
+ pair.condition_json AS condition_json,
912
+ count(DISTINCT episode.uuid) AS occurrence_count,
913
+ count(DISTINCT episode.fuli_session_id) AS distinct_session_count,
914
+ min(episode.valid_at) AS first_observed_at,
915
+ max(episode.valid_at) AS last_observed_at,
916
+ confirmation_authority,
917
+ negative_evidence_count,
918
+ evidence_ids
919
+ '''
920
+
921
+
922
+ _MATERIALIZE_WORKFLOW_CANDIDATE_QUERY = '''
923
+ UNWIND $candidates AS row
924
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
925
+ MATCH (source:Entity {uuid: row.source_step_id})
926
+ MATCH (target:Entity {uuid: row.target_step_id})
927
+ MERGE (candidate:FuliWorkflowCandidate {id: row.candidate_id})
928
+ ON CREATE SET candidate.status = 'pending',
929
+ candidate.decline_count = 0,
930
+ candidate.candidate_version = 1,
931
+ candidate.evidence_revision = 1,
932
+ candidate.decision_revision = 0,
933
+ candidate.rule_fingerprint = row.rule_fingerprint,
934
+ candidate.evidence_fingerprint = row.evidence_fingerprint,
935
+ candidate.created_at = row.changed_at
936
+ WITH space, source, target, candidate, row,
937
+ candidate.rule_fingerprint <> row.rule_fingerprint AS rule_changed,
938
+ candidate.evidence_fingerprint <> row.evidence_fingerprint
939
+ AS evidence_changed
940
+ SET candidate.candidate_version =
941
+ CASE WHEN rule_changed
942
+ THEN coalesce(candidate.candidate_version, 1) + 1
943
+ ELSE coalesce(candidate.candidate_version, 1) END,
944
+ candidate.evidence_revision =
945
+ CASE WHEN evidence_changed
946
+ THEN coalesce(candidate.evidence_revision, 1) + 1
947
+ ELSE coalesce(candidate.evidence_revision, 1) END,
948
+ candidate.decision_revision =
949
+ CASE WHEN rule_changed
950
+ THEN coalesce(candidate.decision_revision, 0) + 1
951
+ ELSE coalesce(candidate.decision_revision, 0) END,
952
+ candidate.status =
953
+ CASE WHEN rule_changed THEN 'pending'
954
+ ELSE coalesce(candidate.status, 'pending') END,
955
+ candidate.reviewed_at =
956
+ CASE WHEN rule_changed THEN null ELSE candidate.reviewed_at END,
957
+ candidate.review_reason =
958
+ CASE WHEN rule_changed THEN null ELSE candidate.review_reason END,
959
+ candidate.personal_space_id = row.personal_space_id,
960
+ candidate.personal_project_id = row.personal_project_id,
961
+ candidate.workflow_key = row.workflow_key,
962
+ candidate.source_step_id = row.source_step_id,
963
+ candidate.source_step_key = row.source_step_key,
964
+ candidate.source_step_name = row.source_step_name,
965
+ candidate.target_step_id = row.target_step_id,
966
+ candidate.target_step_key = row.target_step_key,
967
+ candidate.target_step_name = row.target_step_name,
968
+ candidate.condition_json = row.condition_json,
969
+ candidate.rule_fingerprint = row.rule_fingerprint,
970
+ candidate.evidence_fingerprint = row.evidence_fingerprint,
971
+ candidate.occurrence_count = row.occurrence_count,
972
+ candidate.distinct_session_count = row.distinct_session_count,
973
+ candidate.first_observed_at = row.first_observed_at,
974
+ candidate.last_observed_at = row.last_observed_at,
975
+ candidate.confirmation_authority = row.confirmation_authority,
976
+ candidate.negative_evidence_count = row.negative_evidence_count,
977
+ candidate.evidence_ids = row.evidence_ids,
978
+ candidate.updated_at = row.changed_at
979
+ WITH space, source, target, candidate, row, rule_changed
980
+ OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
981
+ (authorization:FuliWorkflowAuthorization {active: true})
982
+ WITH space, source, target, candidate, row, rule_changed,
983
+ collect(authorization) AS active_authorizations
984
+ FOREACH (authorization IN
985
+ CASE WHEN rule_changed THEN active_authorizations ELSE [] END |
986
+ SET authorization.active = false,
987
+ authorization.revoked_at = row.changed_at,
988
+ authorization.revocation_reason = 'workflow rule fingerprint changed'
989
+ )
990
+ MERGE (space)-[:HAS_WORKFLOW_CANDIDATE]->(candidate)
991
+ MERGE (candidate)-[:FROM_WORKFLOW_STEP]->(source)
992
+ MERGE (candidate)-[:TO_WORKFLOW_STEP]->(target)
993
+ RETURN candidate.id AS candidate_id,
994
+ candidate.candidate_version AS candidate_version,
995
+ candidate.evidence_revision AS evidence_revision
996
+ '''