fuli-context 0.7.6 → 0.7.8
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-9CVX91tr.js} +1 -1
- package/dist/web/assets/{ConnectionsPage-BWn4ywGJ.js → ConnectionsPage-CWbCVFdv.js} +1 -1
- package/dist/web/assets/EmployeeRecruitDialog-CQgNpGJ_.js +1 -0
- package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-CWg4WJhr.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-CwU4qBgD.js +1 -0
- package/dist/web/assets/KnowledgeEditDialog-B6TGAcFH.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-M2pLY0bR.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-Dfj3vIpU.js → KnowledgeOrganizerPage-DzeAlk9b.js} +1 -1
- package/dist/web/assets/KnowledgePage-BJu9w0fF.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-ChEB5Bwk.js → KnowledgeWorkspace-CCt8rnBb.js} +1 -1
- package/dist/web/assets/{OverviewPage-Cd66M0RT.js → OverviewPage-CZswVqui.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-RCFHL0Wz.js → PersonalProfilePage-f1nSF6kx.js} +1 -1
- package/dist/web/assets/PersonalProjectsPage-D__eJf0t.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
- package/dist/web/assets/ProjectAgentsPage-LrwagMGj.js +11 -0
- package/dist/web/assets/{PublicProjectsPage-mstPEjHe.js → PublicProjectsPage-BdC2Qk4n.js} +1 -1
- package/dist/web/assets/{ReviewPage-6Y1SmfZy.js → ReviewPage-Cbgl8_Si.js} +1 -1
- package/dist/web/assets/SearchableSelect-ERm-k4KI.js +1 -0
- package/dist/web/assets/{SettingsPage--LdTMUm7.js → SettingsPage-DWso4x29.js} +1 -1
- package/dist/web/assets/{WritingTastePage-CIpKswvs.js → WritingTastePage-CFpCjmIY.js} +1 -1
- package/dist/web/assets/catalog-WGLn7ERJ.js +1 -0
- package/dist/web/assets/{console-BT1P2Db4.js → console-DQgu0JIA.js} +1 -1
- package/dist/web/assets/conversation-launcher-settings-0KtlG_LV.js +1 -0
- package/dist/web/assets/{i18n-js6gfc7j.js → i18n-ByRiA3pJ.js} +3 -3
- package/dist/web/assets/{identity-CfDfanpa.js → identity-CxdPRMwH.js} +1 -1
- package/dist/web/assets/index-JAZe6lfX.js +2 -0
- package/dist/web/assets/index-wcXzzggp.css +1 -0
- package/dist/web/assets/{model-DJkLAJsg.js → model-Db4-KefQ.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-BdSti7Y2.js → useApi-CROJJdhE-CePMjc08.js} +1 -1
- package/dist/web/assets/{vue-router-Kk67EaCP.js → vue-router-CzX-hn3P.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/update-command.js +6 -43
- 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 +30 -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/semantic-version.js +42 -0
- 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/src/system/package-version-checker.js +72 -0
- package/src/system/system-service.js +3 -0
- 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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Task lifecycle shared by independent host-hook and MCP processes.
|
|
2
|
+
|
|
3
|
+
Session and token nodes are locked in the same order. End-of-task claims bind
|
|
4
|
+
an immutable payload fingerprint before side effects; retries can safely resume.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException
|
|
10
|
+
from neo4j.exceptions import ConstraintError
|
|
11
|
+
|
|
12
|
+
from .personal_project_access import authorize_personal_project
|
|
13
|
+
from .project_agent_access import authorize_project_agent
|
|
14
|
+
from .provider_values import now_utc, stable_uuid
|
|
15
|
+
from .project_agent_memory_models import ProjectAgentMemoryWrite
|
|
16
|
+
from .store_transactions import query_store_transaction
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StoreTaskContexts:
|
|
20
|
+
async def begin_task_context(self, actor, request):
|
|
21
|
+
self._require_personal()
|
|
22
|
+
space = await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
23
|
+
if request.personal_project_id:
|
|
24
|
+
await authorize_personal_project(self, actor, space, request.personal_project_id)
|
|
25
|
+
if request.project_agent_id:
|
|
26
|
+
agent = await authorize_project_agent(
|
|
27
|
+
self, actor, space, request.personal_project_id,
|
|
28
|
+
request.project_agent_id, require_active=True,
|
|
29
|
+
)
|
|
30
|
+
allowed_clients = json.loads(agent['profile_json']).get('allowed_clients', [])
|
|
31
|
+
if request.source_application not in allowed_clients:
|
|
32
|
+
raise HTTPException(status_code=403, detail='Agent is not allowed in this client')
|
|
33
|
+
record = request.model_dump(mode='json')
|
|
34
|
+
record.update(checkpoint=None, created_at=now_utc().isoformat())
|
|
35
|
+
try:
|
|
36
|
+
rows, _, _ = await self.runtime.driver.execute_query(
|
|
37
|
+
'''
|
|
38
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
|
|
39
|
+
MERGE (session:FuliTaskContextSession {id: $session_id})
|
|
40
|
+
SET session.write_serial = coalesce(session.write_serial, 0) + 1
|
|
41
|
+
MERGE (space)-[:HAS_TASK_CONTEXT_SESSION]->(session)
|
|
42
|
+
WITH session
|
|
43
|
+
OPTIONAL MATCH (session)-[:HAS_CONTEXT]->(previous:FuliTaskContext)
|
|
44
|
+
WHERE previous.token = session.current_token
|
|
45
|
+
WITH session, previous
|
|
46
|
+
OPTIONAL MATCH (session)-[:HAS_CONTEXT]->(retry:FuliTaskContext)
|
|
47
|
+
WHERE retry.token = $token OR ($turn_id IS NOT NULL AND retry.turn_id = $turn_id)
|
|
48
|
+
WITH session, previous, retry
|
|
49
|
+
ORDER BY CASE WHEN retry.token = $token THEN 0 ELSE 1 END, retry.id
|
|
50
|
+
LIMIT 1
|
|
51
|
+
FOREACH (ignored IN CASE WHEN retry IS NULL THEN [1] ELSE [] END |
|
|
52
|
+
CREATE (task:FuliTaskContext {id: $context_id, token: $token,
|
|
53
|
+
turn_id: $turn_id, record_json: $record_json, completed: false,
|
|
54
|
+
personal_space_id: $space_id, personal_project_id: $project_id,
|
|
55
|
+
project_agent_id: $agent_id, created_at: $created_at})
|
|
56
|
+
CREATE (session)-[:HAS_CONTEXT]->(task)
|
|
57
|
+
SET session.current_token = $token
|
|
58
|
+
)
|
|
59
|
+
WITH session, previous, retry
|
|
60
|
+
MATCH (session)-[:HAS_CONTEXT]->(saved:FuliTaskContext)
|
|
61
|
+
WHERE saved.token = coalesce(retry.token, $token)
|
|
62
|
+
RETURN saved.record_json AS record_json,
|
|
63
|
+
saved.token = session.current_token AS is_current,
|
|
64
|
+
previous IS NOT NULL AND NOT coalesce(previous.completed, false)
|
|
65
|
+
AND previous.token <> saved.token AS previous_checkpoint_missing
|
|
66
|
+
''',
|
|
67
|
+
space_id=request.personal_space_id,
|
|
68
|
+
session_id=self._task_session_id(
|
|
69
|
+
request.personal_space_id, request.source_application, request.session_id,
|
|
70
|
+
),
|
|
71
|
+
context_id=stable_uuid(request.personal_space_id, request.token),
|
|
72
|
+
token=request.token, turn_id=request.turn_id,
|
|
73
|
+
project_id=request.personal_project_id, agent_id=request.project_agent_id,
|
|
74
|
+
created_at=now_utc(),
|
|
75
|
+
record_json=json.dumps(record, ensure_ascii=False),
|
|
76
|
+
)
|
|
77
|
+
except ConstraintError as error:
|
|
78
|
+
# Tokens are space-wide handles; never attach one to another session.
|
|
79
|
+
raise HTTPException(status_code=409,
|
|
80
|
+
detail='Task context token is already in use') from error
|
|
81
|
+
if not rows[0]['is_current']:
|
|
82
|
+
raise HTTPException(status_code=409, detail='Task turn has been superseded')
|
|
83
|
+
saved = json.loads(rows[0]['record_json'])
|
|
84
|
+
for field in ('personal_project_id', 'project_agent_id', 'source_application', 'turn_id'):
|
|
85
|
+
if saved[field] != getattr(request, field):
|
|
86
|
+
raise HTTPException(status_code=409, detail='Task turn already has another context')
|
|
87
|
+
return {**saved, 'previous_checkpoint_missing': rows[0]['previous_checkpoint_missing']}
|
|
88
|
+
|
|
89
|
+
async def get_task_context(self, actor, space_id, token, source_application):
|
|
90
|
+
self._require_personal()
|
|
91
|
+
await self.authorize(actor, space_id, 'reader')
|
|
92
|
+
rows, _, _ = await self.runtime.driver.execute_query(
|
|
93
|
+
'''
|
|
94
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
95
|
+
[:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession)-
|
|
96
|
+
[:HAS_CONTEXT]->(task:FuliTaskContext {token: $token})
|
|
97
|
+
WHERE session.current_token = task.token
|
|
98
|
+
RETURN task.record_json AS record_json
|
|
99
|
+
''', space_id=space_id, token=token, routing_='r',
|
|
100
|
+
)
|
|
101
|
+
if not rows:
|
|
102
|
+
raise HTTPException(status_code=404, detail='Task context is unknown or superseded')
|
|
103
|
+
record = json.loads(rows[0]['record_json'])
|
|
104
|
+
if record['source_application'] != source_application:
|
|
105
|
+
raise HTTPException(status_code=404, detail='Task context not found for this client')
|
|
106
|
+
return record
|
|
107
|
+
|
|
108
|
+
async def checkpoint_task_context(self, actor, token, request):
|
|
109
|
+
if request.agent_memory is None:
|
|
110
|
+
return await self._checkpoint_task_context(actor, token, request)
|
|
111
|
+
# A rejected preparation cannot commit memory. A later host turn may
|
|
112
|
+
# still supersede a successfully prepared context; complete is separate.
|
|
113
|
+
async with query_store_transaction(self) as scoped:
|
|
114
|
+
record = await scoped._checkpoint_task_context(actor, token, request)
|
|
115
|
+
if not record['project_agent_id'] or not record['personal_project_id']:
|
|
116
|
+
raise HTTPException(status_code=409, detail='Task has no durable Agent memory target')
|
|
117
|
+
memory = await scoped.write_project_agent_memory(actor, ProjectAgentMemoryWrite(
|
|
118
|
+
personal_space_id=request.personal_space_id,
|
|
119
|
+
personal_project_id=record['personal_project_id'],
|
|
120
|
+
agent_id=record['project_agent_id'],
|
|
121
|
+
expected_revision=request.agent_memory.expected_revision,
|
|
122
|
+
memory=request.agent_memory.memory,
|
|
123
|
+
idempotency_key=f'{token}:memory',
|
|
124
|
+
source_application=record['source_application'],
|
|
125
|
+
# The client lifecycle session remains stable when entry and
|
|
126
|
+
# checkpoint hooks use different short-lived MCP processes.
|
|
127
|
+
source_session_id=record['session_id'],
|
|
128
|
+
))
|
|
129
|
+
return {**record, 'agent_memory': memory.model_dump(mode='json')}
|
|
130
|
+
|
|
131
|
+
async def _checkpoint_task_context(self, actor, token, request):
|
|
132
|
+
await self.authorize(actor, request.personal_space_id, 'maintainer')
|
|
133
|
+
record = await self.get_task_context(
|
|
134
|
+
actor, request.personal_space_id, token, request.source_application,
|
|
135
|
+
)
|
|
136
|
+
checkpoint = request.model_dump(mode='json', exclude={
|
|
137
|
+
'personal_space_id', 'source_application', 'agent_memory',
|
|
138
|
+
})
|
|
139
|
+
# Complete retries must preserve the original completion timestamp/result.
|
|
140
|
+
record['checkpoint'] = checkpoint
|
|
141
|
+
# Bind memory presence independently of the caller's fingerprint. Its
|
|
142
|
+
# content is already immutable under the memory checkpoint's payload hash.
|
|
143
|
+
# The existing checkpoint lets pre-upgrade claims retry without new writes.
|
|
144
|
+
memory_checkpoint_id = None
|
|
145
|
+
if record['project_agent_id'] and record['personal_project_id']:
|
|
146
|
+
memory_checkpoint_id = stable_uuid(self._agent_memory_id(
|
|
147
|
+
request.personal_space_id, record['personal_project_id'],
|
|
148
|
+
record['project_agent_id'],
|
|
149
|
+
), f'{token}:memory')
|
|
150
|
+
rows, _, _ = await self.runtime.driver.execute_query(
|
|
151
|
+
'''
|
|
152
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
153
|
+
[:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession)-
|
|
154
|
+
[:HAS_CONTEXT]->(task:FuliTaskContext {token: $token})
|
|
155
|
+
SET session.write_serial = coalesce(session.write_serial, 0) + 1
|
|
156
|
+
WITH session, task
|
|
157
|
+
WHERE session.current_token = task.token
|
|
158
|
+
SET task.write_serial = coalesce(task.write_serial, 0) + 1
|
|
159
|
+
WITH task
|
|
160
|
+
OPTIONAL MATCH (memory:FuliProjectAgentMemoryCheckpoint {id: $memory_checkpoint_id})
|
|
161
|
+
WITH task, task.fingerprint IS NULL AS first_claim,
|
|
162
|
+
coalesce(task.agent_memory_claimed, memory IS NOT NULL) AS memory_claimed
|
|
163
|
+
WITH task, first_claim OR (
|
|
164
|
+
task.fingerprint = $fingerprint AND task.disposition = $disposition
|
|
165
|
+
AND task.reason = $reason
|
|
166
|
+
AND (NOT $is_prepare OR memory_claimed = $has_agent_memory)
|
|
167
|
+
) AS matches,
|
|
168
|
+
CASE WHEN first_claim THEN $has_agent_memory ELSE memory_claimed END AS memory_claimed
|
|
169
|
+
FOREACH (ignored IN CASE WHEN matches AND NOT task.completed THEN [1] ELSE [] END |
|
|
170
|
+
SET task.fingerprint = $fingerprint, task.record_json = $record_json,
|
|
171
|
+
task.agent_memory_claimed = memory_claimed,
|
|
172
|
+
task.completed = $completed, task.disposition = $disposition, task.reason = $reason
|
|
173
|
+
)
|
|
174
|
+
RETURN matches, task.record_json AS record_json
|
|
175
|
+
''', space_id=request.personal_space_id, token=token,
|
|
176
|
+
fingerprint=request.fingerprint, record_json=json.dumps(record, ensure_ascii=False),
|
|
177
|
+
disposition=request.disposition, reason=request.reason,
|
|
178
|
+
completed=request.phase == 'complete',
|
|
179
|
+
is_prepare=request.phase == 'prepare',
|
|
180
|
+
has_agent_memory=request.agent_memory is not None,
|
|
181
|
+
memory_checkpoint_id=memory_checkpoint_id,
|
|
182
|
+
)
|
|
183
|
+
if not rows or not rows[0]['matches']:
|
|
184
|
+
raise HTTPException(status_code=409, detail='Task checkpoint conflicts or was superseded')
|
|
185
|
+
return json.loads(rows[0]['record_json'])
|
|
186
|
+
|
|
187
|
+
async def verify_task_checkpoint(self, actor, space_id, session_id, source_application):
|
|
188
|
+
self._require_personal()
|
|
189
|
+
await self.authorize(actor, space_id, 'reader')
|
|
190
|
+
rows, _, _ = await self.runtime.driver.execute_query(
|
|
191
|
+
'''
|
|
192
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
193
|
+
[:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession {id: $session_id})-
|
|
194
|
+
[:HAS_CONTEXT]->(task:FuliTaskContext)
|
|
195
|
+
WHERE task.token = session.current_token
|
|
196
|
+
RETURN task.record_json AS record_json, task.completed AS completed
|
|
197
|
+
''', space_id=space_id,
|
|
198
|
+
session_id=self._task_session_id(space_id, source_application, session_id),
|
|
199
|
+
routing_='r',
|
|
200
|
+
)
|
|
201
|
+
if not rows:
|
|
202
|
+
return {'status': 'not_started', 'guidance': 'No Fuli context started; do not block.'}
|
|
203
|
+
record = json.loads(rows[0]['record_json'])
|
|
204
|
+
if rows[0]['completed']:
|
|
205
|
+
return {'status': 'checkpointed', 'disposition': record['checkpoint']['disposition']}
|
|
206
|
+
return {
|
|
207
|
+
'status': 'checkpoint_required', 'decision': 'block',
|
|
208
|
+
'task_context_token': record['token'],
|
|
209
|
+
'reason': f'FULI_CHECKPOINT_REQUIRED: {record["token"]} '
|
|
210
|
+
'Before finishing, call checkpoint_task_knowledge using this task_context_token '
|
|
211
|
+
'with capture_candidates or retain_nothing; include agentMemory when durable '
|
|
212
|
+
'role context changed. Never store raw transcripts or credentials.',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def _task_session_id(self, space_id, source_application, session_id):
|
|
216
|
+
return stable_uuid(self.settings.provider_id, space_id, source_application,
|
|
217
|
+
session_id, 'task-context-session')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Explicit transaction scope for query-only Store operations.
|
|
2
|
+
|
|
3
|
+
The scoped Store owns a private driver port. Never replace the shared runtime's
|
|
4
|
+
driver: simultaneous HTTP requests must not borrow one another's transactions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from copy import copy
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@asynccontextmanager
|
|
13
|
+
async def query_store_transaction(store):
|
|
14
|
+
async with store.runtime.driver.transaction() as transaction:
|
|
15
|
+
scoped = copy(store)
|
|
16
|
+
# Query-only workflows intentionally have no raw Graphiti escape hatch.
|
|
17
|
+
scoped.runtime = SimpleNamespace(driver=TransactionQueryDriver(transaction))
|
|
18
|
+
yield scoped
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TransactionQueryDriver:
|
|
22
|
+
"""Adapt the Store execute_query port to one already-open transaction."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, transaction):
|
|
25
|
+
self._transaction = transaction
|
|
26
|
+
|
|
27
|
+
async def execute_query(self, query, **kwargs):
|
|
28
|
+
parameters = dict(kwargs.pop('params', None) or {})
|
|
29
|
+
# The transaction already owns its session/routing. Reads must observe
|
|
30
|
+
# its uncommitted writes instead of opening an independent read session.
|
|
31
|
+
kwargs.pop('routing_', None)
|
|
32
|
+
parameters.update(kwargs)
|
|
33
|
+
result = await self._transaction.run(query, **parameters)
|
|
34
|
+
records = [record async for record in result]
|
|
35
|
+
return records, None, list(records[0].keys()) if records else []
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def transaction(self):
|
|
39
|
+
# Nested query-only operations join the owner; only it commits/rolls back.
|
|
40
|
+
yield self._transaction
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Durable lifecycle metadata, never prompts, local paths or transcripts."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
|
|
7
|
+
from .models import SourceApplication, StrictModel
|
|
8
|
+
from .model_validation import reject_credentials
|
|
9
|
+
from .project_agent_memory_models import ProjectAgentWorkingMemory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskContextBegin(StrictModel):
|
|
13
|
+
personal_space_id: str = Field(min_length=1, max_length=128)
|
|
14
|
+
personal_project_id: str | None = Field(default=None, min_length=1, max_length=128)
|
|
15
|
+
project_agent_id: str | None = Field(default=None, min_length=1, max_length=128)
|
|
16
|
+
session_id: str = Field(min_length=1, max_length=256)
|
|
17
|
+
source_application: SourceApplication
|
|
18
|
+
source_session_id: str | None = Field(default=None, min_length=1, max_length=256)
|
|
19
|
+
token: str = Field(pattern=r'^fuli-task-[a-zA-Z0-9-]{8,128}$')
|
|
20
|
+
turn_id: str | None = Field(default=None, min_length=1, max_length=256)
|
|
21
|
+
memory_revision: int | None = Field(default=None, ge=0)
|
|
22
|
+
|
|
23
|
+
@model_validator(mode='after')
|
|
24
|
+
def require_project_for_agent(self):
|
|
25
|
+
if self.project_agent_id and not self.personal_project_id:
|
|
26
|
+
raise ValueError('Agent lifecycle requires an exact project')
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskContextAgentMemory(StrictModel):
|
|
31
|
+
expected_revision: int = Field(ge=0)
|
|
32
|
+
memory: ProjectAgentWorkingMemory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TaskContextCheckpoint(StrictModel):
|
|
36
|
+
personal_space_id: str = Field(min_length=1, max_length=128)
|
|
37
|
+
source_application: SourceApplication
|
|
38
|
+
phase: Literal['prepare', 'complete'] = 'complete'
|
|
39
|
+
disposition: Literal['capture_candidates', 'retain_nothing']
|
|
40
|
+
reason: str = Field(min_length=1, max_length=2000)
|
|
41
|
+
fingerprint: str = Field(pattern=r'^[a-f0-9]{64}$')
|
|
42
|
+
capture_status: str | None = Field(default=None, max_length=128)
|
|
43
|
+
agent_memory: TaskContextAgentMemory | None = None
|
|
44
|
+
|
|
45
|
+
@model_validator(mode='after')
|
|
46
|
+
def reject_sensitive_values(self):
|
|
47
|
+
if self.agent_memory is not None and self.phase != 'prepare':
|
|
48
|
+
raise ValueError('Agent memory belongs to atomic checkpoint preparation')
|
|
49
|
+
reject_credentials(self, 'task checkpoint')
|
|
50
|
+
return self
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Query
|
|
4
|
+
|
|
5
|
+
from .models import SourceApplication
|
|
6
|
+
from .task_context_models import TaskContextBegin, TaskContextCheckpoint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_task_context_routes(application, store, Actor):
|
|
10
|
+
@application.put('/v1/task-contexts')
|
|
11
|
+
async def begin_context(request: TaskContextBegin, actor: Actor):
|
|
12
|
+
return await store.begin_task_context(actor, request)
|
|
13
|
+
|
|
14
|
+
@application.get('/v1/task-contexts/{token}')
|
|
15
|
+
async def get_context(token: str, actor: Actor,
|
|
16
|
+
personal_space_id: Annotated[str, Query(min_length=1, max_length=128)],
|
|
17
|
+
source_application: SourceApplication):
|
|
18
|
+
return await store.get_task_context(actor, personal_space_id, token, source_application)
|
|
19
|
+
|
|
20
|
+
@application.put('/v1/task-contexts/{token}/checkpoint')
|
|
21
|
+
async def checkpoint_context(token: str, request: TaskContextCheckpoint, actor: Actor):
|
|
22
|
+
return await store.checkpoint_task_context(actor, token, request)
|
|
23
|
+
|
|
24
|
+
@application.get('/v1/task-context-sessions/checkpoint')
|
|
25
|
+
async def verify_checkpoint(actor: Actor,
|
|
26
|
+
personal_space_id: Annotated[str, Query(min_length=1, max_length=128)],
|
|
27
|
+
session_id: Annotated[str, Query(min_length=1, max_length=256)],
|
|
28
|
+
source_application: SourceApplication):
|
|
29
|
+
return await store.verify_task_checkpoint(
|
|
30
|
+
actor, personal_space_id, session_id, source_application,
|
|
31
|
+
)
|
|
@@ -24,7 +24,7 @@ class WorkflowTransitionObservation(StrictModel):
|
|
|
24
24
|
observed_at: datetime
|
|
25
25
|
evidence_summary: str = Field(min_length=1, max_length=2048)
|
|
26
26
|
source_application: Literal[
|
|
27
|
-
'codex', 'claude_code', 'cursor', 'kiro', 'other'
|
|
27
|
+
'codex', 'claude', 'claude_code', 'cursor', 'kiro', 'other'
|
|
28
28
|
] | None = None
|
|
29
29
|
source_turn_id: str | None = Field(default=None, min_length=1, max_length=256)
|
|
30
30
|
sensitivity: Literal['normal', 'private', 'restricted'] = 'normal'
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fuli-context",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "fuli-context",
|
|
9
|
-
"version": "0.7.
|
|
9
|
+
"version": "0.7.8",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@hono/node-server": "2.0.12",
|
|
@@ -2950,9 +2950,9 @@
|
|
|
2950
2950
|
"license": "MIT"
|
|
2951
2951
|
},
|
|
2952
2952
|
"node_modules/fast-uri": {
|
|
2953
|
-
"version": "3.1.
|
|
2954
|
-
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.
|
|
2955
|
-
"integrity": "sha512-
|
|
2953
|
+
"version": "3.1.7",
|
|
2954
|
+
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
|
2955
|
+
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
|
2956
2956
|
"funding": [
|
|
2957
2957
|
{
|
|
2958
2958
|
"type": "github",
|
|
@@ -4397,9 +4397,9 @@
|
|
|
4397
4397
|
}
|
|
4398
4398
|
},
|
|
4399
4399
|
"node_modules/qs": {
|
|
4400
|
-
"version": "6.
|
|
4401
|
-
"resolved": "https://registry.npmjs.org/qs/-/qs-6.
|
|
4402
|
-
"integrity": "sha512-
|
|
4400
|
+
"version": "6.16.0",
|
|
4401
|
+
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
|
4402
|
+
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
|
4403
4403
|
"license": "BSD-3-Clause",
|
|
4404
4404
|
"dependencies": {
|
|
4405
4405
|
"es-define-property": "^1.0.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fuli-context",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"description": "Local-first collaboration relationship graph for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"dist/web/",
|
|
11
11
|
"docs/external-knowledge-architecture.md",
|
|
12
12
|
"docs/public-personal-architecture.md",
|
|
13
|
+
"docs/project-agent-memory.md",
|
|
14
|
+
"docs/employee-agents.md",
|
|
13
15
|
"examples/external-knowledge/markdown-folder.mjs",
|
|
14
16
|
"graph-provider/Dockerfile",
|
|
15
17
|
"graph-provider/pyproject.toml",
|
|
@@ -99,6 +101,7 @@
|
|
|
99
101
|
"@hono/node-server": "2.0.12"
|
|
100
102
|
},
|
|
101
103
|
"brace-expansion": "$brace-expansion",
|
|
102
|
-
"fast-uri": "3.1.
|
|
104
|
+
"fast-uri": "3.1.7",
|
|
105
|
+
"qs": "6.16.0"
|
|
103
106
|
}
|
|
104
107
|
}
|
|
@@ -243,14 +243,26 @@ including concrete worker identity. Call `record_project_agent_executor_actual`
|
|
|
243
243
|
executor/model use. If the host cannot start a worker, report the task as blocked or failed and do
|
|
244
244
|
not fabricate an execution-summary row. The Provider's `executionSummary` remains authoritative.
|
|
245
245
|
|
|
246
|
+
Keep per-worker completion separate from the global task transition. While parallel work is still
|
|
247
|
+
being integrated, report each worker's terminal result in `workerStatus` and keep the task status
|
|
248
|
+
`running`; this preserves one terminal execution-summary row per actual worker. Only after every
|
|
249
|
+
required worker or participant result has been recorded should the coordinator send the final task
|
|
250
|
+
`completed`, `failed`, or `cancelled` activity. Never let the first completed worker close the task
|
|
251
|
+
before the remaining workers have reported their tools, session evidence, and exact Token usage.
|
|
252
|
+
|
|
246
253
|
## Report Actual Worker Execution
|
|
247
254
|
|
|
248
|
-
When a FULI task or task view returns a non-empty Provider `executionSummary`, append
|
|
249
|
-
Include the worker label and occupation emoji,
|
|
250
|
-
work summary,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
255
|
+
When a FULI task or task view returns a non-empty Provider `executionSummary`, append a compact
|
|
256
|
+
Markdown table with one row per actual worker. Include the worker label and occupation emoji,
|
|
257
|
+
actual executor/tool plus `sourceApplication`, work summary, terminal `workerStatus`/status,
|
|
258
|
+
session (`sourceSessionId`, or a session link only when the Provider supplies one), and Token usage.
|
|
259
|
+
Use only the source-labelled cumulative `tokenUsage` reported for that worker. If it is absent,
|
|
260
|
+
show `not reported`; never estimate it or copy a conversation-wide total into a worker row. If no
|
|
261
|
+
session link exists, show `sourceSessionId` or `not reported`; never invent a link. This applies only
|
|
262
|
+
to Provider-reported actual workers. When empty `executionSummary` is returned, omit rows; an absent
|
|
263
|
+
`executionSummary` also omits rows. Configured, allowed, or available clients/executors are not evidence
|
|
264
|
+
of actual use. Keep this Agent-agnostic; client-specific details and work-kind routing remain at
|
|
265
|
+
adapter boundaries.
|
|
254
266
|
|
|
255
267
|
## Classify Knowledge State
|
|
256
268
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const STOP_FAILURE_RETRY_MS = 30_000;
|
|
2
|
+
|
|
1
3
|
export function createExecutorPool({
|
|
2
4
|
adapters = new Map(),
|
|
3
5
|
idleMs,
|
|
@@ -6,6 +8,7 @@ export function createExecutorPool({
|
|
|
6
8
|
}) {
|
|
7
9
|
const entries = new Map();
|
|
8
10
|
let closed = false;
|
|
11
|
+
let closePromise = null;
|
|
9
12
|
|
|
10
13
|
async function acquire(executorId, leaseId) {
|
|
11
14
|
assertExecutorId(executorId);
|
|
@@ -21,20 +24,27 @@ export function createExecutorPool({
|
|
|
21
24
|
return snapshotEntry(executorId, entry);
|
|
22
25
|
}
|
|
23
26
|
await exclusive(entry, async () => {
|
|
27
|
+
if (closed) throw new Error('Executor pool is closed');
|
|
24
28
|
if (entry.stage !== 'running') {
|
|
25
|
-
entry.stage = 'starting';
|
|
26
29
|
try {
|
|
30
|
+
if (entry.stage === 'failed' && !await stopEntry(executorId, entry)) {
|
|
31
|
+
throw new Error(entry.lastError);
|
|
32
|
+
}
|
|
33
|
+
entry.stage = 'starting';
|
|
27
34
|
await entry.adapter.start({ executorId });
|
|
28
35
|
entry.stage = 'running';
|
|
36
|
+
entry.lastStartError = null;
|
|
29
37
|
entry.lastError = null;
|
|
30
38
|
} catch (error) {
|
|
31
39
|
entry.leases.delete(leaseId);
|
|
40
|
+
if (entry.stage === 'starting') entry.lastStartError = errorMessage(error);
|
|
32
41
|
entry.stage = 'failed';
|
|
33
42
|
entry.lastError = errorMessage(error);
|
|
34
43
|
throw error;
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
});
|
|
47
|
+
if (closed) throw new Error('Executor pool is closed');
|
|
38
48
|
return snapshotEntry(executorId, entry);
|
|
39
49
|
}
|
|
40
50
|
|
|
@@ -42,15 +52,27 @@ export function createExecutorPool({
|
|
|
42
52
|
const entry = entries.get(executorId);
|
|
43
53
|
if (!entry) return;
|
|
44
54
|
entry.leases.delete(leaseId);
|
|
55
|
+
// Runtime entries are live lease bookkeeping, not durable Agent identities.
|
|
56
|
+
// External hosts have no resources owned by this pool after their last lease.
|
|
57
|
+
if (!entry.adapter && entry.leases.size === 0) {
|
|
58
|
+
entries.delete(executorId);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
scheduleStop(executorId, entry, idleMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function scheduleStop(executorId, entry, delay) {
|
|
45
65
|
if (!entry.adapter || entry.leases.size > 0 || entry.timer || closed) return;
|
|
46
66
|
entry.timer = setTimer(() => {
|
|
47
67
|
entry.timer = null;
|
|
48
68
|
if (entry.leases.size > 0 || closed) return;
|
|
49
69
|
void exclusive(entry, async () => {
|
|
50
70
|
if (entry.leases.size > 0 || closed) return;
|
|
51
|
-
await stopEntry(executorId, entry)
|
|
71
|
+
if (!await stopEntry(executorId, entry)) {
|
|
72
|
+
scheduleStop(executorId, entry, STOP_FAILURE_RETRY_MS);
|
|
73
|
+
}
|
|
52
74
|
});
|
|
53
|
-
},
|
|
75
|
+
}, delay);
|
|
54
76
|
entry.timer?.unref?.();
|
|
55
77
|
}
|
|
56
78
|
|
|
@@ -59,10 +81,12 @@ export function createExecutorPool({
|
|
|
59
81
|
try {
|
|
60
82
|
await entry.adapter.stop({ executorId });
|
|
61
83
|
entry.stage = 'idle';
|
|
62
|
-
entry.lastError =
|
|
84
|
+
entry.lastError = entry.lastStartError;
|
|
85
|
+
return true;
|
|
63
86
|
} catch (error) {
|
|
64
87
|
entry.stage = 'failed';
|
|
65
88
|
entry.lastError = errorMessage(error);
|
|
89
|
+
return false;
|
|
66
90
|
}
|
|
67
91
|
}
|
|
68
92
|
|
|
@@ -72,11 +96,27 @@ export function createExecutorPool({
|
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
function close() {
|
|
99
|
+
if (closePromise) return closePromise;
|
|
75
100
|
closed = true;
|
|
76
|
-
|
|
101
|
+
const shutdowns = [];
|
|
102
|
+
for (const [executorId, entry] of entries) {
|
|
77
103
|
if (entry.timer) clearTimer(entry.timer);
|
|
78
104
|
entry.timer = null;
|
|
105
|
+
entry.leases.clear();
|
|
106
|
+
if (entry.adapter) shutdowns.push(exclusive(entry, async () => {
|
|
107
|
+
if (entry.stage !== 'idle' && !await stopEntry(executorId, entry)) {
|
|
108
|
+
throw new Error(`Failed to stop managed executor ${executorId}: ${entry.lastError}`);
|
|
109
|
+
}
|
|
110
|
+
}));
|
|
79
111
|
}
|
|
112
|
+
closePromise = Promise.allSettled(shutdowns).then((results) => {
|
|
113
|
+
const failures = results.filter((result) => result.status === 'rejected')
|
|
114
|
+
.map((result) => result.reason);
|
|
115
|
+
if (failures.length) {
|
|
116
|
+
throw new AggregateError(failures, failures.map((error) => error.message).join('; '));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return closePromise;
|
|
80
120
|
}
|
|
81
121
|
|
|
82
122
|
function entryFor(executorId) {
|
|
@@ -88,6 +128,7 @@ export function createExecutorPool({
|
|
|
88
128
|
timer: null,
|
|
89
129
|
transition: Promise.resolve(),
|
|
90
130
|
stage: adapters.has(executorId) ? 'idle' : 'external',
|
|
131
|
+
lastStartError: null,
|
|
91
132
|
lastError: null
|
|
92
133
|
};
|
|
93
134
|
entries.set(executorId, entry);
|
|
@@ -2,6 +2,7 @@ import { readJsonFile } from '../storage/json-file.js';
|
|
|
2
2
|
import { createNativeGraphServices } from '../native-runtime/runtime.js';
|
|
3
3
|
import {
|
|
4
4
|
ensureContainerRuntime,
|
|
5
|
+
inspectContainerRuntime,
|
|
5
6
|
runDockerCompose
|
|
6
7
|
} from '../setup/container-runtime.js';
|
|
7
8
|
import {
|
|
@@ -38,6 +39,7 @@ export function createManagedGraphServices({
|
|
|
38
39
|
readJson = readJsonFile,
|
|
39
40
|
readSettings = readRuntimeSettings,
|
|
40
41
|
ensureRuntime = ensureContainerRuntime,
|
|
42
|
+
inspectRuntime = inspectContainerRuntime,
|
|
41
43
|
runCompose = runDockerCompose,
|
|
42
44
|
fetchImpl = globalThis.fetch,
|
|
43
45
|
wait = delay,
|
|
@@ -80,15 +82,20 @@ export function createManagedGraphServices({
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
async function stopProviders() {
|
|
85
|
+
const selectedRuntime = inspectRuntime();
|
|
86
|
+
if (selectedRuntime.status === 'stopped') return;
|
|
83
87
|
const services = managedServices();
|
|
84
|
-
compose(['stop', '-t', '20', ...services.providers],
|
|
88
|
+
compose(['stop', '-t', '20', ...services.providers], selectedRuntime);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
async function stopDatabases() {
|
|
92
|
+
// Inspect only: shutdown must not launch a desktop container engine to stop it.
|
|
93
|
+
const selectedRuntime = inspectRuntime();
|
|
94
|
+
if (selectedRuntime.status === 'stopped') return;
|
|
88
95
|
const services = managedServices();
|
|
89
96
|
// Keep shutdown ordering explicit even if an external action restarted a Provider.
|
|
90
|
-
compose(['stop', '-t', '20', ...services.providers],
|
|
91
|
-
compose(['stop', '-t', '20', ...services.databases],
|
|
97
|
+
compose(['stop', '-t', '20', ...services.providers], selectedRuntime);
|
|
98
|
+
compose(['stop', '-t', '20', ...services.databases], selectedRuntime);
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
async function ready() {
|