fuli-context 0.2.0

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 (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,370 @@
1
+ from fastapi import HTTPException
2
+
3
+ from .models import (
4
+ MembershipCreate,
5
+ MembershipRecord,
6
+ ProposalCreate,
7
+ ProposalDecision,
8
+ ProposalRecord,
9
+ PublicationDraftCreate,
10
+ PublicationDraftDecision,
11
+ PublicationDraftRecord,
12
+ SubscriptionCreate,
13
+ SubscriptionDeleteResult,
14
+ SubscriptionRecord,
15
+ )
16
+ from .provider_values import native_datetime, now_utc, stable_uuid
17
+
18
+
19
+ class StorePublication:
20
+ async def create_publication_draft(
21
+ self,
22
+ actor: dict,
23
+ request: PublicationDraftCreate,
24
+ ) -> PublicationDraftRecord:
25
+ self._require_personal()
26
+ await self.authorize(actor, request.personal_space_id, 'maintainer')
27
+ draft_id = stable_uuid(
28
+ self.settings.provider_id,
29
+ request.personal_space_id,
30
+ request.provider_url,
31
+ request.target_project_id,
32
+ request.episode.idempotency_key,
33
+ )
34
+ created_at = now_utc()
35
+ records, _, _ = await self.runtime.driver.execute_query(
36
+ '''
37
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
38
+ MERGE (draft:FuliPublicationDraft {id: $id})
39
+ ON CREATE SET draft.personal_space_id = $personal_space_id,
40
+ draft.target_project_id = $target_project_id,
41
+ draft.provider_url = $provider_url,
42
+ draft.status = 'pending',
43
+ draft.payload = $payload,
44
+ draft.created_at = $created_at
45
+ MERGE (space)-[:HAS_PUBLICATION_DRAFT]->(draft)
46
+ RETURN draft
47
+ ''',
48
+ personal_space_id=request.personal_space_id,
49
+ id=draft_id,
50
+ target_project_id=request.target_project_id,
51
+ provider_url=request.provider_url,
52
+ payload=request.episode.model_dump_json(),
53
+ created_at=created_at,
54
+ )
55
+ return self._publication_draft(records[0]['draft'])
56
+
57
+ async def list_publication_drafts(
58
+ self,
59
+ actor: dict,
60
+ personal_space_id: str,
61
+ draft_status: str = 'pending',
62
+ ) -> list[PublicationDraftRecord]:
63
+ self._require_personal()
64
+ await self.authorize(actor, personal_space_id, 'reader')
65
+ records, _, _ = await self.runtime.driver.execute_query(
66
+ '''
67
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
68
+ [:HAS_PUBLICATION_DRAFT]->
69
+ (draft:FuliPublicationDraft {status: $status})
70
+ RETURN draft ORDER BY draft.created_at DESC
71
+ ''',
72
+ personal_space_id=personal_space_id,
73
+ status=draft_status,
74
+ routing_='r',
75
+ )
76
+ return [self._publication_draft(record['draft']) for record in records]
77
+
78
+ async def get_publication_draft(
79
+ self,
80
+ actor: dict,
81
+ personal_space_id: str,
82
+ draft_id: str,
83
+ ) -> PublicationDraftRecord:
84
+ self._require_personal()
85
+ await self.authorize(actor, personal_space_id, 'reader')
86
+ records, _, _ = await self.runtime.driver.execute_query(
87
+ '''
88
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
89
+ [:HAS_PUBLICATION_DRAFT]->
90
+ (draft:FuliPublicationDraft {id: $draft_id})
91
+ RETURN draft
92
+ ''',
93
+ personal_space_id=personal_space_id,
94
+ draft_id=draft_id,
95
+ routing_='r',
96
+ )
97
+ if not records:
98
+ raise HTTPException(status_code=404, detail='publication draft not found')
99
+ return self._publication_draft(records[0]['draft'])
100
+
101
+ async def decide_publication_draft(
102
+ self,
103
+ actor: dict,
104
+ personal_space_id: str,
105
+ draft_id: str,
106
+ decision: PublicationDraftDecision,
107
+ ) -> PublicationDraftRecord:
108
+ self._require_personal()
109
+ space = await self.authorize(actor, personal_space_id, 'maintainer')
110
+ records, _, _ = await self.runtime.driver.execute_query(
111
+ '''
112
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
113
+ [:HAS_PUBLICATION_DRAFT]->
114
+ (draft:FuliPublicationDraft {id: $draft_id, status: 'pending'})
115
+ RETURN draft
116
+ ''',
117
+ personal_space_id=personal_space_id,
118
+ draft_id=draft_id,
119
+ )
120
+ if not records:
121
+ raise HTTPException(status_code=409, detail='publication draft is not pending')
122
+ draft = self._publication_draft(records[0]['draft'])
123
+ if decision.decision == 'keep_personal':
124
+ await self._commit_episode(space, draft.episode)
125
+ final_status = 'kept_personal'
126
+ elif decision.decision == 'submit_public':
127
+ final_status = 'submitted'
128
+ else:
129
+ final_status = 'ignored'
130
+ updated, _, _ = await self.runtime.driver.execute_query(
131
+ '''
132
+ MATCH (draft:FuliPublicationDraft {id: $draft_id, status: 'pending'})
133
+ SET draft.status = $status,
134
+ draft.shared_proposal_id = $shared_proposal_id,
135
+ draft.decided_at = $decided_at
136
+ RETURN draft
137
+ ''',
138
+ draft_id=draft_id,
139
+ status=final_status,
140
+ shared_proposal_id=decision.shared_proposal_id,
141
+ decided_at=now_utc(),
142
+ )
143
+ return self._publication_draft(updated[0]['draft'])
144
+
145
+ async def add_membership(
146
+ self,
147
+ actor: dict,
148
+ project_id: str,
149
+ request: MembershipCreate,
150
+ ) -> MembershipRecord:
151
+ self._require_workspace()
152
+ await self.authorize(actor, project_id, 'maintainer')
153
+ created_at = now_utc()
154
+ records, _, _ = await self.runtime.driver.execute_query(
155
+ '''
156
+ MATCH (principal:FuliPrincipal {id: $principal_id})
157
+ MATCH (project:FuliSpace {id: $project_id, kind: 'project'})
158
+ MERGE (principal)-[membership:MEMBER_OF]->(project)
159
+ SET membership.role = $role,
160
+ membership.created_at = coalesce(membership.created_at, $created_at),
161
+ membership.updated_at = $created_at
162
+ RETURN membership.created_at AS created_at
163
+ ''',
164
+ principal_id=request.principal_id,
165
+ project_id=project_id,
166
+ role=request.role,
167
+ created_at=created_at,
168
+ )
169
+ if not records:
170
+ raise HTTPException(status_code=404, detail='principal or project not found')
171
+ return MembershipRecord(
172
+ project_id=project_id,
173
+ principal_id=request.principal_id,
174
+ role=request.role,
175
+ created_at=native_datetime(records[0]['created_at']),
176
+ )
177
+
178
+ async def subscribe(
179
+ self,
180
+ actor: dict,
181
+ request: SubscriptionCreate,
182
+ ) -> SubscriptionRecord:
183
+ self._require_personal()
184
+ await self.authorize(actor, request.personal_space_id, 'maintainer')
185
+ subscription_id = stable_uuid(
186
+ self.settings.provider_id,
187
+ request.personal_space_id,
188
+ request.provider_url,
189
+ request.project_id,
190
+ )
191
+ created_at = now_utc()
192
+ records, _, _ = await self.runtime.driver.execute_query(
193
+ '''
194
+ MATCH (personal:FuliSpace {id: $personal_space_id, kind: 'personal'})
195
+ MERGE (subscription:FuliSubscription {id: $id})
196
+ ON CREATE SET subscription.created_at = $created_at
197
+ SET subscription.project_id = $project_id,
198
+ subscription.provider_url = $provider_url,
199
+ subscription.project_name = $project_name,
200
+ subscription.active = true
201
+ MERGE (personal)-[:SUBSCRIBES_TO]->(subscription)
202
+ RETURN subscription
203
+ ''',
204
+ personal_space_id=request.personal_space_id,
205
+ id=subscription_id,
206
+ project_id=request.project_id,
207
+ provider_url=request.provider_url,
208
+ project_name=request.project_name,
209
+ created_at=created_at,
210
+ )
211
+ return self._subscription(records[0]['subscription'], request.personal_space_id)
212
+
213
+ async def list_subscriptions(
214
+ self,
215
+ actor: dict,
216
+ personal_space_id: str,
217
+ ) -> list[SubscriptionRecord]:
218
+ self._require_personal()
219
+ await self.authorize(actor, personal_space_id, 'reader')
220
+ records, _, _ = await self.runtime.driver.execute_query(
221
+ '''
222
+ MATCH (personal:FuliSpace {id: $personal_space_id})-[:SUBSCRIBES_TO]->
223
+ (subscription:FuliSubscription {active: true})
224
+ RETURN subscription ORDER BY subscription.created_at
225
+ ''',
226
+ personal_space_id=personal_space_id,
227
+ routing_='r',
228
+ )
229
+ return [
230
+ self._subscription(record['subscription'], personal_space_id)
231
+ for record in records
232
+ ]
233
+
234
+ async def unsubscribe(
235
+ self,
236
+ actor: dict,
237
+ personal_space_id: str,
238
+ project_id: str,
239
+ provider_url: str,
240
+ ) -> SubscriptionDeleteResult:
241
+ self._require_personal()
242
+ await self.authorize(actor, personal_space_id, 'maintainer')
243
+ records, _, _ = await self.runtime.driver.execute_query(
244
+ '''
245
+ MATCH (:FuliSpace {id: $personal_space_id})-[:SUBSCRIBES_TO]->
246
+ (subscription:FuliSubscription {
247
+ project_id: $project_id,
248
+ provider_url: $provider_url,
249
+ active: true
250
+ })
251
+ SET subscription.active = false,
252
+ subscription.deleted_at = $deleted_at
253
+ RETURN subscription.id AS id
254
+ ''',
255
+ personal_space_id=personal_space_id,
256
+ project_id=project_id,
257
+ provider_url=provider_url,
258
+ deleted_at=now_utc(),
259
+ )
260
+ return SubscriptionDeleteResult(project_id=project_id, deleted=bool(records))
261
+
262
+ async def create_proposal(
263
+ self,
264
+ actor: dict,
265
+ project_id: str,
266
+ request: ProposalCreate,
267
+ ) -> ProposalRecord:
268
+ self._require_workspace()
269
+ await self.authorize(actor, project_id, 'contributor')
270
+ proposal_id = stable_uuid(
271
+ self.settings.provider_id,
272
+ project_id,
273
+ actor['id'],
274
+ request.episode.idempotency_key,
275
+ )
276
+ payload = request.episode.model_dump_json()
277
+ created_at = now_utc()
278
+ records, _, _ = await self.runtime.driver.execute_query(
279
+ '''
280
+ MATCH (project:FuliSpace {id: $project_id, kind: 'project'})
281
+ MERGE (proposal:FuliProposal {id: $id})
282
+ ON CREATE SET proposal.project_id = $project_id,
283
+ proposal.submitted_by = $submitted_by,
284
+ proposal.status = 'pending',
285
+ proposal.payload = $payload,
286
+ proposal.created_at = $created_at
287
+ MERGE (proposal)-[:TARGETS]->(project)
288
+ RETURN proposal
289
+ ''',
290
+ project_id=project_id,
291
+ id=proposal_id,
292
+ submitted_by=actor['id'],
293
+ payload=payload,
294
+ created_at=created_at,
295
+ )
296
+ return self._proposal(records[0]['proposal'])
297
+
298
+ async def list_proposals(
299
+ self,
300
+ actor: dict,
301
+ project_id: str,
302
+ proposal_status: str = 'pending',
303
+ ) -> list[ProposalRecord]:
304
+ self._require_workspace()
305
+ await self.authorize(actor, project_id, 'maintainer')
306
+ records, _, _ = await self.runtime.driver.execute_query(
307
+ '''
308
+ MATCH (proposal:FuliProposal {project_id: $project_id, status: $status})
309
+ RETURN proposal ORDER BY proposal.created_at
310
+ ''',
311
+ project_id=project_id,
312
+ status=proposal_status,
313
+ routing_='r',
314
+ )
315
+ return [self._proposal(record['proposal']) for record in records]
316
+
317
+ async def decide_proposal(
318
+ self,
319
+ actor: dict,
320
+ project_id: str,
321
+ proposal_id: str,
322
+ decision: ProposalDecision,
323
+ ) -> ProposalRecord:
324
+ self._require_workspace()
325
+ space = await self.authorize(actor, project_id, 'maintainer')
326
+ records, _, _ = await self.runtime.driver.execute_query(
327
+ '''
328
+ MATCH (proposal:FuliProposal {
329
+ id: $proposal_id,
330
+ project_id: $project_id,
331
+ status: 'pending'
332
+ })
333
+ SET proposal.status = 'processing'
334
+ RETURN proposal
335
+ ''',
336
+ proposal_id=proposal_id,
337
+ project_id=project_id,
338
+ )
339
+ if not records:
340
+ raise HTTPException(status_code=409, detail='proposal is not pending')
341
+ proposal = self._proposal(records[0]['proposal'])
342
+ decided_at = now_utc()
343
+ if decision.decision == 'approve':
344
+ try:
345
+ await self._commit_episode(space, proposal.episode)
346
+ except Exception:
347
+ await self.runtime.driver.execute_query(
348
+ "MATCH (p:FuliProposal {id: $id, status: 'processing'}) SET p.status = 'pending'",
349
+ id=proposal_id,
350
+ )
351
+ raise
352
+ final_status = 'approved'
353
+ else:
354
+ final_status = 'rejected'
355
+ updated, _, _ = await self.runtime.driver.execute_query(
356
+ '''
357
+ MATCH (proposal:FuliProposal {id: $id, status: 'processing'})
358
+ SET proposal.status = $status,
359
+ proposal.decided_at = $decided_at,
360
+ proposal.decided_by = $decided_by,
361
+ proposal.decision_note = $note
362
+ RETURN proposal
363
+ ''',
364
+ id=proposal_id,
365
+ status=final_status,
366
+ decided_at=decided_at,
367
+ decided_by=actor['id'],
368
+ note=decision.note,
369
+ )
370
+ return self._proposal(updated[0]['proposal'])
@@ -0,0 +1,134 @@
1
+ from .models import (
2
+ PersonalProjectRecord,
3
+ ProjectProfile,
4
+ ProjectReleaseRecord,
5
+ ProjectRelationRecord,
6
+ ProposalRecord,
7
+ PublicationDraftRecord,
8
+ SpaceRecord,
9
+ StructuredEpisode,
10
+ SubscriptionRecord,
11
+ )
12
+ from .provider_values import native_datetime, stable_uuid
13
+
14
+
15
+ class StoreRecords:
16
+ def _space(
17
+ self,
18
+ raw,
19
+ *,
20
+ owner_id: str,
21
+ role: str,
22
+ can_manage: bool = False,
23
+ ) -> SpaceRecord:
24
+ value = dict(raw)
25
+ current_release = None
26
+ if value.get('release_version'):
27
+ current_release = ProjectReleaseRecord(
28
+ id=value.get('release_id') or stable_uuid(
29
+ value['id'], 'release', value['release_version']
30
+ ),
31
+ project_id=value['id'],
32
+ version=value['release_version'],
33
+ summary=value.get('release_summary') or '未记录更新内容',
34
+ publisher_id=value.get('release_publisher_id') or owner_id,
35
+ publisher_name=value.get('release_publisher_name') or owner_id,
36
+ published_at=native_datetime(
37
+ value.get('released_at') or value['created_at']
38
+ ),
39
+ )
40
+ return SpaceRecord(
41
+ id=value['id'],
42
+ name=value['name'],
43
+ kind=value['kind'],
44
+ group_id=value['group_id'],
45
+ description=value.get('description'),
46
+ visibility=value['visibility'],
47
+ owner_id=owner_id,
48
+ role=role,
49
+ publication_key=value.get('publication_key'),
50
+ can_manage=can_manage,
51
+ profile=(
52
+ ProjectProfile.model_validate_json(value['profile_json'])
53
+ if value.get('profile_json') else None
54
+ ),
55
+ current_release=current_release,
56
+ created_at=native_datetime(value['created_at']),
57
+ )
58
+
59
+ def _project_release(self, raw) -> ProjectReleaseRecord:
60
+ value = dict(raw)
61
+ return ProjectReleaseRecord(
62
+ id=value['id'],
63
+ project_id=value['project_id'],
64
+ version=value['version'],
65
+ summary=value['summary'],
66
+ publisher_id=value['publisher_id'],
67
+ publisher_name=value.get('publisher_name') or value['publisher_id'],
68
+ published_at=native_datetime(value['published_at']),
69
+ )
70
+
71
+ def _personal_project(self, raw, personal_space_id: str) -> PersonalProjectRecord:
72
+ value = dict(raw)
73
+ return PersonalProjectRecord(
74
+ project_id=value['project_id'],
75
+ personal_space_id=personal_space_id,
76
+ publication_key=value['publication_key'],
77
+ profile=ProjectProfile.model_validate_json(value['profile_json']),
78
+ created_at=native_datetime(value['created_at']),
79
+ updated_at=native_datetime(value['updated_at']),
80
+ )
81
+
82
+ def _project_relation(self, raw) -> ProjectRelationRecord:
83
+ value = dict(raw)
84
+ return ProjectRelationRecord(
85
+ id=value['id'],
86
+ source_project_id=value['source_project_id'],
87
+ target_project_id=value['target_project_id'],
88
+ relation_type=value['relation_type'],
89
+ status=value['status'],
90
+ note=value.get('note'),
91
+ created_by=value['created_by'],
92
+ created_at=native_datetime(value['created_at']),
93
+ decided_by=value.get('decided_by'),
94
+ decided_at=native_datetime(value.get('decided_at')),
95
+ )
96
+
97
+ def _publication_draft(self, raw) -> PublicationDraftRecord:
98
+ value = dict(raw)
99
+ return PublicationDraftRecord(
100
+ id=value['id'],
101
+ personal_space_id=value['personal_space_id'],
102
+ target_project_id=value['target_project_id'],
103
+ provider_url=value['provider_url'],
104
+ status=value['status'],
105
+ episode=StructuredEpisode.model_validate_json(value['payload']),
106
+ created_at=native_datetime(value['created_at']),
107
+ decided_at=native_datetime(value.get('decided_at')),
108
+ shared_proposal_id=value.get('shared_proposal_id'),
109
+ )
110
+
111
+ def _subscription(self, raw, personal_space_id: str) -> SubscriptionRecord:
112
+ value = dict(raw)
113
+ return SubscriptionRecord(
114
+ id=value['id'],
115
+ personal_space_id=personal_space_id,
116
+ project_id=value['project_id'],
117
+ provider_url=value['provider_url'],
118
+ project_name=value['project_name'],
119
+ created_at=native_datetime(value['created_at']),
120
+ )
121
+
122
+ def _proposal(self, raw) -> ProposalRecord:
123
+ value = dict(raw)
124
+ return ProposalRecord(
125
+ id=value['id'],
126
+ project_id=value['project_id'],
127
+ submitted_by=value['submitted_by'],
128
+ status=value['status'],
129
+ episode=StructuredEpisode.model_validate_json(value['payload']),
130
+ created_at=native_datetime(value['created_at']),
131
+ decided_at=native_datetime(value.get('decided_at')),
132
+ decided_by=value.get('decided_by'),
133
+ decision_note=value.get('decision_note'),
134
+ )
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "fuli-graph-provider"
3
+ version = "0.2.0"
4
+ description = "Graphiti-backed personal and shared project provider for Fuli."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "fastapi==0.116.1",
8
+ "graphiti-core==0.29.2",
9
+ "pydantic-settings==2.10.1",
10
+ "uvicorn[standard]==0.35.0",
11
+ ]
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=80"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [tool.setuptools.packages.find]
18
+ include = ["fuli_graph*"]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "httpx==0.28.1",
23
+ "pytest==8.4.1",
24
+ "pytest-asyncio==1.1.0",
25
+ ]
26
+
27
+ [tool.pytest.ini_options]
28
+ asyncio_mode = "auto"
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+
34
+ [tool.ruff.format]
35
+ quote-style = "single"