archgraph-argo 0.10.52 → 0.11.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.
package/install-argo.ps1 CHANGED
@@ -26,7 +26,10 @@ param(
26
26
  [string]$OpenClawRepoRoot = '',
27
27
  [switch]$SkipOpenClaw,
28
28
  [string]$McpPath,
29
- [string]$GraphMcpUrl = 'https://argo.derekworkspacev5.com/mcp'
29
+ [string]$GraphMcpUrl = 'https://argo.derekworkspacev5.com/mcp',
30
+ [switch]$SkipEaWeb,
31
+ [int]$EaWebPort = 8787,
32
+ [string]$EaWebRoot = ''
30
33
  )
31
34
 
32
35
  $ErrorActionPreference = 'Stop'
@@ -737,37 +740,37 @@ Write-Host '==> Deploying Argo toolchain'
737
740
 
738
741
  $schemaSrc = Join-Path $argoDir 'schema'
739
742
  $schemaDest = Join-Path $ArgoRoot 'schema'
740
- Write-Host "[1/22] argo\schema -> $schemaDest"
743
+ Write-Host "[1/23] argo\schema -> $schemaDest"
741
744
  Copy-Tree -Source $schemaSrc -Destination $schemaDest
742
745
 
743
746
  $scriptsSrc = Join-Path $argoDir 'scripts'
744
747
  $scriptsDest = Join-Path $ArgoRoot 'scripts'
745
- Write-Host "[2/22] argo\scripts -> $scriptsDest"
748
+ Write-Host "[2/23] argo\scripts -> $scriptsDest"
746
749
  Copy-Tree -Source $scriptsSrc -Destination $scriptsDest
747
750
 
748
751
  $defaultsSrc = Join-Path $argoDir 'defaults'
749
752
  $defaultsDest = Join-Path $ArgoRoot 'defaults'
750
- Write-Host "[3/22] argo\defaults -> $defaultsDest"
753
+ Write-Host "[3/23] argo\defaults -> $defaultsDest"
751
754
  Copy-Tree -Source $defaultsSrc -Destination $defaultsDest
752
755
 
753
756
  $skillSrc = Join-Path (Join-Path $argoDir 'skills') 'argo-init'
754
757
  $skillDest = Join-Path $SkillsRoot 'argo-init'
755
- Write-Host "[4/22] argo\skills\argo-init -> $skillDest"
758
+ Write-Host "[4/23] argo\skills\argo-init -> $skillDest"
756
759
  Copy-Tree -Source $skillSrc -Destination $skillDest
757
760
 
758
761
  $ruleSrc = Join-Path (Join-Path $argoDir 'rules') 'archgraph.instructions.md'
759
762
  $ruleDest = Join-Path $PromptsRoot 'archgraph.instructions.md'
760
- Write-Host "[5/22] argo\rules\archgraph.instructions.md -> $ruleDest"
763
+ Write-Host "[5/23] argo\rules\archgraph.instructions.md -> $ruleDest"
761
764
  New-Item -ItemType Directory -Force -Path $PromptsRoot | Out-Null
762
765
  Copy-Item -Force -Path $ruleSrc -Destination $ruleDest
763
766
 
764
767
  $depsSrc = Join-Path $argoDir 'package.json'
765
768
  $depsDest = Join-Path $ArgoRoot 'package.json'
766
- Write-Host "[6/22] argo\package.json -> $depsDest"
769
+ Write-Host "[6/23] argo\package.json -> $depsDest"
767
770
  Copy-Item -Force -Path $depsSrc -Destination $depsDest
768
771
 
769
772
  $cursorSkillDest = Join-Path $CursorSkillsRoot 'argo-init'
770
- Write-Host "[7/22] argo\skills\argo-init -> $cursorSkillDest (Cursor)"
773
+ Write-Host "[7/23] argo\skills\argo-init -> $cursorSkillDest (Cursor)"
771
774
  Copy-Tree -Source $skillSrc -Destination $cursorSkillDest
772
775
 
773
776
  $mcpBridgeSrc = Join-Path $argoDir 'mcp-bridges'
@@ -776,29 +779,29 @@ Write-Host " argo\mcp-bridges -> $mcpBridgeDest (Cursor graph-mcp stdio bridge)
776
779
  Copy-Tree -Source $mcpBridgeSrc -Destination $mcpBridgeDest
777
780
 
778
781
  $openCodeSkillDest = Join-Path $OpenCodeSkillsRoot 'argo-init'
779
- Write-Host "[8/22] argo\skills\argo-init -> $openCodeSkillDest (OpenCode)"
782
+ Write-Host "[8/23] argo\skills\argo-init -> $openCodeSkillDest (OpenCode)"
780
783
  Copy-Tree -Source $skillSrc -Destination $openCodeSkillDest
781
784
 
782
- Write-Host "[9/22] argo\rules\archgraph.instructions.md -> $OpenCodeAgentsPath (OpenCode global AGENTS.md)"
785
+ Write-Host "[9/23] argo\rules\archgraph.instructions.md -> $OpenCodeAgentsPath (OpenCode global AGENTS.md)"
783
786
  Add-AgentsRule -AgentsPath $OpenCodeAgentsPath -RulePath $ruleSrc
784
787
 
785
788
  $agentsSrc = Join-Path $argoDir 'agents'
786
- Write-Host "[10/22] argo\agents -> $CopilotAgentsRoot (Copilot user-level)"
789
+ Write-Host "[10/23] argo\agents -> $CopilotAgentsRoot (Copilot user-level)"
787
790
  Copy-Agents -Source $agentsSrc -Destination $CopilotAgentsRoot
788
791
 
789
- Write-Host "[11/22] argo\agents -> $CursorAgentsRoot (Cursor user-level, converted to .md)"
792
+ Write-Host "[11/23] argo\agents -> $CursorAgentsRoot (Cursor user-level, converted to .md)"
790
793
  Copy-Agents -Source $agentsSrc -Destination $CursorAgentsRoot -Target cursor
791
794
 
792
- Write-Host "[12/22] argo\agents -> $OpenCodeAgentsRoot (OpenCode user-level, converted to .md)"
795
+ Write-Host "[12/23] argo\agents -> $OpenCodeAgentsRoot (OpenCode user-level, converted to .md)"
793
796
  Copy-Agents -Source $agentsSrc -Destination $OpenCodeAgentsRoot -Target opencode
794
797
 
795
798
  $pluginsSrc = Join-Path $argoDir 'plugins'
796
- Write-Host "[13/22] argo\plugins -> $PluginsRoot (Argo opencode plugins)"
799
+ Write-Host "[13/23] argo\plugins -> $PluginsRoot (Argo opencode plugins)"
797
800
  Copy-Tree -Source $pluginsSrc -Destination $PluginsRoot
798
801
 
799
802
  $cursorRuleSrc = Join-Path (Join-Path $argoDir 'rules') 'archgraph.instructions.md'
800
803
  $cursorRuleDest = Join-Path $CursorRulesRoot 'archgraph.mdc'
801
- Write-Host "[14/22] argo\rules\archgraph.instructions.md -> $cursorRuleDest (Cursor global rule, alwaysApply)"
804
+ Write-Host "[14/23] argo\rules\archgraph.instructions.md -> $cursorRuleDest (Cursor global rule, alwaysApply)"
802
805
  New-Item -ItemType Directory -Force -Path $CursorRulesRoot | Out-Null
803
806
  Convert-RuleFile -SourceFile $cursorRuleSrc -DestinationFile $cursorRuleDest
804
807
 
@@ -810,16 +813,16 @@ if ($SkipDsh) {
810
813
  $patchPath = Join-Path $DshHome 'cordis.patch.yml'
811
814
  $argoServer = (Join-Path $ArgoRoot 'scripts\argo-mcp-server.js').Replace('\', '/')
812
815
 
813
- Write-Host "[15/22] argo\rules\archgraph.instructions.md -> $DshHome\AGENTS.md (DeepSeek Harness user-global rule, frontmatter stripped)"
816
+ Write-Host "[15/23] argo\rules\archgraph.instructions.md -> $DshHome\AGENTS.md (DeepSeek Harness user-global rule, frontmatter stripped)"
814
817
  Write-DshAgentRule -DshHome $DshHome -RuleText $ruleSrcContent
815
818
 
816
- Write-Host "[16/22] argo\skills\argo-init -> $dshSkillDest (DeepSeek Harness skill)"
819
+ Write-Host "[16/23] argo\skills\argo-init -> $dshSkillDest (DeepSeek Harness skill)"
817
820
  Copy-Tree -Source (Join-Path $argoDir 'skills\argo-init') -Destination $dshSkillDest
818
821
 
819
- Write-Host "[17/22] argo\rules\<WakeupGuideline> -> $DshHome\plugins\dsh-argo-wakeup\index.js (DeepSeek Harness wakeup plugin)"
822
+ Write-Host "[17/23] argo\rules\<WakeupGuideline> -> $DshHome\plugins\dsh-argo-wakeup\index.js (DeepSeek Harness wakeup plugin)"
820
823
  $wakeupDshPath = New-DshWakeupPlugin -DshHome $DshHome -RuleText $ruleSrcContent
821
824
 
822
- Write-Host "[18/22] argo-workspace + argo-wakeup rows -> $patchPath (DeepSeek Harness MCP bridge + wakeup plugin)"
825
+ Write-Host "[18/23] argo-workspace + argo-wakeup rows -> $patchPath (DeepSeek Harness MCP bridge + wakeup plugin)"
823
826
  # The generated dsh-argo-workspace bridge connects directly to the argo
824
827
  # server (no dsh-mcp-client row), registers every tool as mcp__argo__* and
825
828
  # injects the current session's workspace (SessionHeader.cwd) as the
@@ -850,7 +853,7 @@ if ($SkipDsh) {
850
853
  Write-DshManagedBlock -Path $patchPath -Block $block -MarkerStart '# BEGIN ArchGraph ARGO deployment' -MarkerEnd '# END ArchGraph ARGO deployment'
851
854
  }
852
855
 
853
- Write-Host "[19/22] argo\agents -> $DshHome\.agent-presets\<id> (DeepSeek Harness agent presets)"
856
+ Write-Host "[19/23] argo\agents -> $DshHome\.agent-presets\<id> (DeepSeek Harness agent presets)"
854
857
  New-DshAgentPresets -DshHome $DshHome -AgentsSrc (Join-Path $argoDir 'agents')
855
858
 
856
859
  Write-Host " note: graph-mcp remote ($GraphMcpUrl) is NOT registered for DeepSeek Harness -"
@@ -871,10 +874,10 @@ if ($SkipOpenClaw) {
871
874
  $openClawAgentsDest = Join-Path $OpenClawWorkspace 'AGENTS.md'
872
875
 
873
876
  Write-Host '==> Deploying OpenClaw integration'
874
- Write-Host "[20/22] argo\rules\archgraph.instructions.md -> $openClawAgentsDest (OpenClaw workspace AGENTS.md, frontmatter stripped)"
877
+ Write-Host "[20/23] argo\rules\archgraph.instructions.md -> $openClawAgentsDest (OpenClaw workspace AGENTS.md, frontmatter stripped)"
875
878
  Write-OpenClawAgentRule -OpenClawWorkspace $OpenClawWorkspace -RuleText $ruleSrcContent
876
879
 
877
- Write-Host "[21/22] argo\skills\argo-init -> $openClawSkillDest (OpenClaw managed skill, all agents)"
880
+ Write-Host "[21/23] argo\skills\argo-init -> $openClawSkillDest (OpenClaw managed skill, all agents)"
878
881
  Copy-Tree -Source (Join-Path $argoDir 'skills\argo-init') -Destination $openClawSkillDest
879
882
 
880
883
  Write-Host ' OpenClaw injects AGENTS.md into Project Context on every session, so the wakeup'
@@ -1043,7 +1046,7 @@ if ($SkipMcp) {
1043
1046
  # location; -OpenClawRepoRoot overrides it (e.g. for tests or when the
1044
1047
  # installer runs from a non-workspace npm-global package dir).
1045
1048
  $openClawRepoRoot = if ($OpenClawRepoRoot) { $OpenClawRepoRoot } else { $repoRoot }
1046
- Write-Host "[22/22] argo MCP server -> $(Join-Path $OpenClawHome 'openclaw.json') (OpenClaw mcp.servers.argo, env.ARGO_REPO_ROOT pinned)"
1049
+ Write-Host "[22/23] argo MCP server -> $(Join-Path $OpenClawHome 'openclaw.json') (OpenClaw mcp.servers.argo, env.ARGO_REPO_ROOT pinned)"
1047
1050
  Write-OpenClawMcpConfig -OpenClawHome $OpenClawHome -RepoRoot $openClawRepoRoot -ArgoServer $argoServer -GraphMcpServer $openClawGraphMcpServer
1048
1051
  }
1049
1052
  }
@@ -1055,5 +1058,68 @@ if (Test-Path $wakeupPluginPath) {
1055
1058
  Write-Host "argo-wakeup plugin registered -> $OpenCodeConfigPath"
1056
1059
  }
1057
1060
 
1061
+ # EA local web service (WP2786): deploy the two service scripts next to the
1062
+ # harness scripts (REPO_ROOT = $ArgoRoot keeps the relative layout intact) plus
1063
+ # the web/ statics, then start the service in the background. Re-running the
1064
+ # installer is idempotent: an occupied port skips the startup.
1065
+ $eaServiceSrc = Join-Path $repoRoot 'scripts\ea-web-service.js'
1066
+ $eaLayoutStoreSrc = Join-Path $repoRoot 'scripts\ea-layout-store.js'
1067
+ $eaWebSrc = Join-Path $repoRoot 'web'
1068
+ Write-Host "[23/23] scripts\ea-web-service.js + scripts\ea-layout-store.js + web -> $ArgoRoot (EA local web service)"
1069
+ if ((Test-Path $eaServiceSrc) -and (Test-Path $eaLayoutStoreSrc) -and (Test-Path $eaWebSrc)) {
1070
+ Copy-Item -Force -Path $eaServiceSrc -Destination (Join-Path $scriptsDest 'ea-web-service.js')
1071
+ Copy-Item -Force -Path $eaLayoutStoreSrc -Destination (Join-Path $scriptsDest 'ea-layout-store.js')
1072
+ Copy-Tree -Source $eaWebSrc -Destination (Join-Path $ArgoRoot 'web')
1073
+ Write-Host " service scripts + web statics deployed -> $scriptsDest / $(Join-Path $ArgoRoot 'web')"
1074
+ if ($SkipEaWeb) {
1075
+ Write-Host ' EA web service startup skipped (-SkipEaWeb)'
1076
+ }
1077
+ else {
1078
+ $eaWebRoot = if ($EaWebRoot) { $EaWebRoot } else { $PWD.Path }
1079
+ $eaPortInUse = $false
1080
+ try {
1081
+ $eaProbe = New-Object System.Net.Sockets.TcpClient
1082
+ $eaProbe.Connect('127.0.0.1', $EaWebPort)
1083
+ $eaPortInUse = $true
1084
+ $eaProbe.Close()
1085
+ }
1086
+ catch {
1087
+ $eaPortInUse = $false
1088
+ }
1089
+ if ($eaPortInUse) {
1090
+ Write-Host " EA web service already listening on 127.0.0.1:$EaWebPort; startup skipped (idempotent re-deploy)"
1091
+ }
1092
+ else {
1093
+ $nodeCmd = Get-Command node -ErrorAction SilentlyContinue
1094
+ if (-not $nodeCmd) {
1095
+ throw 'EA web service startup failed: node not found on PATH'
1096
+ }
1097
+ $eaServiceDest = Join-Path $scriptsDest 'ea-web-service.js'
1098
+ $eaLogOut = Join-Path $ArgoRoot 'ea-web-service.log'
1099
+ $eaLogErr = Join-Path $ArgoRoot 'ea-web-service.err.log'
1100
+ # Start the service through WMI (Win32_Process.Create) instead of
1101
+ # Start-Process: the process is then created by the WMI service and
1102
+ # inherits NONE of this installer's stdio pipe handles. Inherited
1103
+ # pipes would keep spawnSync-based launchers (argo-deploy bin, tests)
1104
+ # blocked until the service exits. cmd.exe is used only to redirect
1105
+ # the service stdout/stderr into log files.
1106
+ $q = [char]34
1107
+ $eaCmdLine = "cmd.exe /S /c $q$q$($nodeCmd.Source)$q $q$eaServiceDest$q --root $q$eaWebRoot$q --port $EaWebPort > $q$eaLogOut$q 2> $q$eaLogErr$q$q"
1108
+ $eaCreate = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
1109
+ CommandLine = $eaCmdLine
1110
+ CurrentDirectory = $ArgoRoot
1111
+ }
1112
+ if ($eaCreate.ReturnValue -ne 0) {
1113
+ throw "EA web service startup failed: Win32_Process.Create ReturnValue $($eaCreate.ReturnValue)"
1114
+ }
1115
+ Write-Host " EA web service started in background (pid $($eaCreate.ProcessId)): http://127.0.0.1:$EaWebPort (root: $eaWebRoot)"
1116
+ Write-Host " logs: $eaLogOut / $eaLogErr"
1117
+ }
1118
+ }
1119
+ }
1120
+ else {
1121
+ Write-Host ' EA web service sources not present in this package; step skipped'
1122
+ }
1123
+
1058
1124
  Write-Host ''
1059
1125
  Write-Host 'Argo deployment complete.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.10.52",
3
+ "version": "0.11.0",
4
4
  "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -16,6 +16,9 @@
16
16
  "argo/skills/argo-init",
17
17
  "argo/rules",
18
18
  "argo/package.json",
19
+ "scripts/ea-web-service.js",
20
+ "scripts/ea-layout-store.js",
21
+ "web",
19
22
  "vendor",
20
23
  "install-argo.ps1",
21
24
  "bin",
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 布局侧车(Layout Sidecar,WP2785 M1-S2,2026-09-03 修订:默认按项目隔离,AT-2785-L5)
5
+ * — 图谱画布坐标独立持久化。
6
+ *
7
+ * 零第三方依赖(仅 Node 内置 path/fs/crypto)。与 design/KG/SystemArchitecture.json
8
+ * 完全物理隔离:坐标不进图谱 JSON、不进 schema;本模块永远不读写图谱文件本身。
9
+ *
10
+ * 存储根(按优先级):
11
+ * 1. 显式覆盖:createLayoutStore({ layoutRoot }) 选项或 EA_LAYOUT_ROOT 环境变量
12
+ * → <layoutRoot>/<projectId>/<view_id>.json(测试 / 自定义集中存储根)。
13
+ * 2. 默认(无任何覆盖):按项目隔离
14
+ * → <projectRoot>/design/KG/ea-layouts/<view_id>.json(仍是独立文件,
15
+ * 只落在 ea-layouts/ 子目录,绝不触碰 SystemArchitecture.json 本身)。
16
+ *
17
+ * 文件内容:{ version, graphKey, view_id, signature, updatedAt, elements: { <elementId>: {x,y} } }
18
+ *
19
+ * 失效键 = 视图成员身份签名(成员身份集合的 sha256,只含身份 id,不含任何内容字段):
20
+ * signature = sha256(sorted(included_elements) + sorted(included_relationships))。
21
+ * 图谱内容级修改(元素 name/description/attributes、关系描述等)不改变成员集合
22
+ * → signature 不变 → 坐标完全不动;仅成员结构变化触发新成员补位/移除成员清理。
23
+ *
24
+ * 合并语义(读取时):以当前图谱视图成员为准——仍有坐标的现存成员原样保留;
25
+ * 新成员(无坐标)按确定性算法补位(沿用 buildViewGraph 的圆形布局公式)并写回侧车;
26
+ * 已不在成员中的坐标清理。
27
+ */
28
+
29
+ const path = require('node:path');
30
+ const fs = require('node:fs');
31
+ const crypto = require('node:crypto');
32
+
33
+ const LAYOUT_VERSION = 1;
34
+ // 默认按项目隔离时,侧车相对项目根的存放目录。
35
+ const PROJECT_LAYOUT_DIR = path.join('design', 'KG', 'ea-layouts');
36
+
37
+ /**
38
+ * 解析显式存储根覆盖:选项 > EA_LAYOUT_ROOT 环境变量。
39
+ * 返回 null 表示无覆盖 → 使用默认的按项目隔离存储。
40
+ */
41
+ function resolveLayoutRoot(explicitRoot) {
42
+ if (explicitRoot) {
43
+ return path.resolve(explicitRoot);
44
+ }
45
+ if (process.env.EA_LAYOUT_ROOT) {
46
+ return path.resolve(process.env.EA_LAYOUT_ROOT);
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * 视图成员身份签名:仅由 included_elements / included_relationships 的身份 id 集合决定,
53
+ * 与元素/关系的任何内容字段无关。
54
+ */
55
+ function computeViewSignature(view) {
56
+ const elements = Array.from(new Set((view && view.included_elements) || [])).sort();
57
+ const relationships = Array.from(new Set((view && view.included_relationships) || [])).sort();
58
+ const payload = `${elements.join('\u0001')}\u0000${relationships.join('\u0001')}`;
59
+ return crypto.createHash('sha256').update(payload).digest('hex');
60
+ }
61
+
62
+ // 文件名安全化(服务端路由已限制 [A-Za-z0-9._-],此处为独立模块的防御性兜底)。
63
+ function safeFileName(segment) {
64
+ return String(segment).replace(/[^A-Za-z0-9._-]/g, '_');
65
+ }
66
+
67
+ /**
68
+ * 确定性补位:沿用 buildViewGraph 的圆形布局公式(成员索引 → 圆周位置)。
69
+ */
70
+ function defaultPosition(index, count) {
71
+ const angle = (2 * Math.PI * index) / Math.max(count, 1);
72
+ return {
73
+ x: Math.round(80 + 160 * Math.cos(angle)),
74
+ y: Math.round(80 + 160 * Math.sin(angle)),
75
+ };
76
+ }
77
+
78
+ function isValidPosition(pos) {
79
+ return !!pos
80
+ && typeof pos === 'object'
81
+ && !Array.isArray(pos)
82
+ && Number.isFinite(Number(pos.x))
83
+ && Number.isFinite(Number(pos.y));
84
+ }
85
+
86
+ function createLayoutStore(options = {}) {
87
+ // null → 默认按项目隔离存储;否则为显式集中存储根。
88
+ const explicitRoot = resolveLayoutRoot(options.layoutRoot);
89
+
90
+ /**
91
+ * 目标解析:target = { project?: {id, root, graphPath}, projectId?, graphKey? }。
92
+ * 显式根模式用 projectId 分桶;默认模式用 project.root 定位项目内 ea-layouts/ 目录。
93
+ */
94
+ function resolveTarget(target, viewId) {
95
+ const t = target || {};
96
+ const project = t.project || {};
97
+ const projectId = t.projectId || project.id || null;
98
+ const graphKey = t.graphKey || project.graphPath || null;
99
+ if (explicitRoot) {
100
+ if (!projectId) {
101
+ throw new Error('layout store: 显式存储根模式下必须提供 projectId(或 project.id)');
102
+ }
103
+ return {
104
+ projectId,
105
+ graphKey,
106
+ filePath: path.join(explicitRoot, safeFileName(projectId), `${safeFileName(viewId)}.json`),
107
+ };
108
+ }
109
+ const projectRoot = project.root;
110
+ if (!projectRoot) {
111
+ throw new Error('layout store: 默认按项目隔离存储必须提供 project.root');
112
+ }
113
+ return {
114
+ projectId,
115
+ graphKey,
116
+ filePath: path.join(path.resolve(projectRoot), PROJECT_LAYOUT_DIR, `${safeFileName(viewId)}.json`),
117
+ };
118
+ }
119
+
120
+ function filePathFor(target, viewId) {
121
+ return resolveTarget(target, viewId).filePath;
122
+ }
123
+
124
+ function readRecord(target, viewId) {
125
+ let text;
126
+ try {
127
+ text = fs.readFileSync(filePathFor(target, viewId), 'utf8');
128
+ } catch {
129
+ return null;
130
+ }
131
+ try {
132
+ const record = JSON.parse(text);
133
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
134
+ return null;
135
+ }
136
+ if (!record.elements || typeof record.elements !== 'object' || Array.isArray(record.elements)) {
137
+ record.elements = {};
138
+ }
139
+ return record;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function writeRecord(target, viewId, record) {
146
+ const filePath = filePathFor(target, viewId);
147
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
148
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
149
+ fs.writeFileSync(tempPath, JSON.stringify(record, null, 2), 'utf8');
150
+ fs.renameSync(tempPath, filePath);
151
+ return filePath;
152
+ }
153
+
154
+ /**
155
+ * 读取合并后的布局(以当前图谱视图成员为准)。
156
+ * 有补位/清理/签名变化时写回侧车;纯内容级修改(签名不变)不触发写入。
157
+ * 入参:{ project?: {id, root, graphPath}, projectId?, graphKey?, view }。
158
+ * 返回 { signature, elements: { <elementId>: {x,y} } }。
159
+ */
160
+ function mergeLayout({ project, projectId, graphKey, view }) {
161
+ if (!view || typeof view.view_id !== 'string') {
162
+ throw new Error('mergeLayout: view with view_id is required');
163
+ }
164
+ const target = { project, projectId, graphKey };
165
+ const viewId = view.view_id;
166
+ const signature = computeViewSignature(view);
167
+ const record = readRecord(target, viewId);
168
+ const saved = record ? record.elements : {};
169
+ const members = Array.isArray(view.included_elements) ? view.included_elements : [];
170
+ const elements = {};
171
+ let dirty = !record || record.signature !== signature;
172
+
173
+ members.forEach((id, index) => {
174
+ const pos = saved[id];
175
+ if (isValidPosition(pos)) {
176
+ elements[id] = { x: Number(pos.x), y: Number(pos.y) };
177
+ } else {
178
+ elements[id] = defaultPosition(index, members.length);
179
+ dirty = true;
180
+ }
181
+ });
182
+ for (const id of Object.keys(saved)) {
183
+ if (!members.includes(id)) {
184
+ dirty = true; // 已不在成员中的坐标:清理
185
+ }
186
+ }
187
+ if (dirty) {
188
+ writeRecord(target, viewId, {
189
+ version: LAYOUT_VERSION,
190
+ graphKey: resolveTarget(target, viewId).graphKey,
191
+ view_id: viewId,
192
+ signature,
193
+ updatedAt: new Date().toISOString(),
194
+ elements,
195
+ });
196
+ }
197
+ return { signature, elements };
198
+ }
199
+
200
+ /**
201
+ * 全量写入布局(按当前文档计算签名后原子写入侧车文件)。
202
+ * 入参:{ project?: {id, root, graphPath}, projectId?, graphKey?, view, elements };
203
+ * elements: { <elementId>: {x,y} };非法坐标抛错(由服务端映射为 400)。
204
+ */
205
+ function putLayout({ project, projectId, graphKey, view, elements }) {
206
+ if (!view || typeof view.view_id !== 'string') {
207
+ throw new Error('putLayout: view with view_id is required');
208
+ }
209
+ if (!elements || typeof elements !== 'object' || Array.isArray(elements)) {
210
+ throw new Error('body.elements 必须是 { <elementId>: {x,y} } 对象');
211
+ }
212
+ const normalized = {};
213
+ for (const [id, pos] of Object.entries(elements)) {
214
+ if (!isValidPosition(pos)) {
215
+ throw new Error(`elements['${id}'] 的坐标必须是有限的 {x,y} 数字`);
216
+ }
217
+ normalized[id] = { x: Number(pos.x), y: Number(pos.y) };
218
+ }
219
+ const target = { project, projectId, graphKey };
220
+ const signature = computeViewSignature(view);
221
+ writeRecord(target, view.view_id, {
222
+ version: LAYOUT_VERSION,
223
+ graphKey: resolveTarget(target, view.view_id).graphKey,
224
+ view_id: view.view_id,
225
+ signature,
226
+ updatedAt: new Date().toISOString(),
227
+ elements: normalized,
228
+ });
229
+ return { signature, view_id: view.view_id };
230
+ }
231
+
232
+ return { root: explicitRoot, filePathFor, readRecord, writeRecord, mergeLayout, putLayout };
233
+ }
234
+
235
+ module.exports = {
236
+ LAYOUT_VERSION,
237
+ PROJECT_LAYOUT_DIR,
238
+ resolveLayoutRoot,
239
+ computeViewSignature,
240
+ defaultPosition,
241
+ createLayoutStore,
242
+ };