openworkgraph 0.2.12

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 (178) hide show
  1. package/README.md +64 -0
  2. package/dist/api-image-agent.d.ts +32 -0
  3. package/dist/api-image-agent.js +132 -0
  4. package/dist/api-image-output.d.ts +8 -0
  5. package/dist/api-image-output.js +41 -0
  6. package/dist/api.d.ts +35 -0
  7. package/dist/api.js +153 -0
  8. package/dist/auth.d.ts +58 -0
  9. package/dist/auth.js +273 -0
  10. package/dist/backend/adapter.d.ts +35 -0
  11. package/dist/backend/adapter.js +439 -0
  12. package/dist/backend/capabilities.d.ts +23 -0
  13. package/dist/backend/capabilities.js +75 -0
  14. package/dist/backend/diagnostics.d.ts +9 -0
  15. package/dist/backend/diagnostics.js +80 -0
  16. package/dist/backend/index.d.ts +6 -0
  17. package/dist/backend/index.js +7 -0
  18. package/dist/backend/models.d.ts +15 -0
  19. package/dist/backend/models.js +43 -0
  20. package/dist/backend/paths.d.ts +6 -0
  21. package/dist/backend/paths.js +69 -0
  22. package/dist/backend/stdio.d.ts +66 -0
  23. package/dist/backend/stdio.js +172 -0
  24. package/dist/backend/types.d.ts +90 -0
  25. package/dist/backend/types.js +11 -0
  26. package/dist/backup-api.d.ts +10 -0
  27. package/dist/backup-api.js +40 -0
  28. package/dist/blob-store.d.ts +40 -0
  29. package/dist/blob-store.js +218 -0
  30. package/dist/cli-help.d.ts +1 -0
  31. package/dist/cli-help.js +63 -0
  32. package/dist/cli-output.d.ts +4 -0
  33. package/dist/cli-output.js +130 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +284 -0
  36. package/dist/client-pairing.d.ts +8 -0
  37. package/dist/client-pairing.js +30 -0
  38. package/dist/directories.d.ts +14 -0
  39. package/dist/directories.js +34 -0
  40. package/dist/errors.d.ts +12 -0
  41. package/dist/errors.js +7 -0
  42. package/dist/events.d.ts +22 -0
  43. package/dist/events.js +155 -0
  44. package/dist/execution-chains.d.ts +24 -0
  45. package/dist/execution-chains.js +231 -0
  46. package/dist/execution-settings.d.ts +10 -0
  47. package/dist/execution-settings.js +38 -0
  48. package/dist/graph-document-history.d.ts +43 -0
  49. package/dist/graph-document-history.js +275 -0
  50. package/dist/graph-files.d.ts +14 -0
  51. package/dist/graph-files.js +59 -0
  52. package/dist/graph-transfer.d.ts +53 -0
  53. package/dist/graph-transfer.js +501 -0
  54. package/dist/graphs.d.ts +68 -0
  55. package/dist/graphs.js +771 -0
  56. package/dist/history.d.ts +20 -0
  57. package/dist/history.js +61 -0
  58. package/dist/http.d.ts +2 -0
  59. package/dist/http.js +42 -0
  60. package/dist/image-credentials.d.ts +11 -0
  61. package/dist/image-credentials.js +130 -0
  62. package/dist/image-providers.d.ts +51 -0
  63. package/dist/image-providers.js +271 -0
  64. package/dist/input-materialization.d.ts +17 -0
  65. package/dist/input-materialization.js +95 -0
  66. package/dist/inputs.d.ts +74 -0
  67. package/dist/inputs.js +301 -0
  68. package/dist/local-control.d.ts +13 -0
  69. package/dist/local-control.js +37 -0
  70. package/dist/network.d.ts +11 -0
  71. package/dist/network.js +48 -0
  72. package/dist/openai-image-driver.d.ts +42 -0
  73. package/dist/openai-image-driver.js +368 -0
  74. package/dist/operations/backups.d.ts +40 -0
  75. package/dist/operations/backups.js +391 -0
  76. package/dist/operations/capacity.d.ts +7 -0
  77. package/dist/operations/capacity.js +35 -0
  78. package/dist/operations/cli-context.d.ts +7 -0
  79. package/dist/operations/cli-context.js +58 -0
  80. package/dist/operations/daemon.d.ts +36 -0
  81. package/dist/operations/daemon.js +159 -0
  82. package/dist/operations/files.d.ts +12 -0
  83. package/dist/operations/files.js +84 -0
  84. package/dist/operations/lifecycle.d.ts +28 -0
  85. package/dist/operations/lifecycle.js +155 -0
  86. package/dist/operations/local-admin.d.ts +6 -0
  87. package/dist/operations/local-admin.js +38 -0
  88. package/dist/operations/logs.d.ts +16 -0
  89. package/dist/operations/logs.js +37 -0
  90. package/dist/operations/plugins.d.ts +10 -0
  91. package/dist/operations/plugins.js +11 -0
  92. package/dist/operations/snapshot-worker.d.ts +1 -0
  93. package/dist/operations/snapshot-worker.js +18 -0
  94. package/dist/operations/snapshot.d.ts +1 -0
  95. package/dist/operations/snapshot.js +14 -0
  96. package/dist/operations/update.d.ts +26 -0
  97. package/dist/operations/update.js +216 -0
  98. package/dist/persistence/asset-business-schema.d.ts +2 -0
  99. package/dist/persistence/asset-business-schema.js +36 -0
  100. package/dist/persistence/database.d.ts +7 -0
  101. package/dist/persistence/database.js +91 -0
  102. package/dist/persistence/plugin-schema.d.ts +9 -0
  103. package/dist/persistence/plugin-schema.js +38 -0
  104. package/dist/persistence/repositories.d.ts +20 -0
  105. package/dist/persistence/repositories.js +106 -0
  106. package/dist/persistence/resource-schema.d.ts +2 -0
  107. package/dist/persistence/resource-schema.js +44 -0
  108. package/dist/persistence/schema.d.ts +6 -0
  109. package/dist/persistence/schema.js +236 -0
  110. package/dist/persistence/workflow-schema.d.ts +2 -0
  111. package/dist/persistence/workflow-schema.js +19 -0
  112. package/dist/plugin-api.d.ts +10 -0
  113. package/dist/plugin-api.js +40 -0
  114. package/dist/plugin-management.d.ts +36 -0
  115. package/dist/plugin-management.js +112 -0
  116. package/dist/plugins.d.ts +55 -0
  117. package/dist/plugins.js +239 -0
  118. package/dist/project-discovery.d.ts +3 -0
  119. package/dist/project-discovery.js +57 -0
  120. package/dist/project-files-api.d.ts +16 -0
  121. package/dist/project-files-api.js +243 -0
  122. package/dist/project-files.d.ts +97 -0
  123. package/dist/project-files.js +557 -0
  124. package/dist/project-lock.d.ts +4 -0
  125. package/dist/project-lock.js +35 -0
  126. package/dist/project-probe-worker.d.ts +1 -0
  127. package/dist/project-probe-worker.js +17 -0
  128. package/dist/project-probe.d.ts +22 -0
  129. package/dist/project-probe.js +67 -0
  130. package/dist/projects.d.ts +29 -0
  131. package/dist/projects.js +219 -0
  132. package/dist/publication.d.ts +31 -0
  133. package/dist/publication.js +642 -0
  134. package/dist/resource-api.d.ts +15 -0
  135. package/dist/resource-api.js +256 -0
  136. package/dist/resources-orphans.d.ts +41 -0
  137. package/dist/resources-orphans.js +235 -0
  138. package/dist/resources.d.ts +246 -0
  139. package/dist/resources.js +714 -0
  140. package/dist/run-api.d.ts +10 -0
  141. package/dist/run-api.js +308 -0
  142. package/dist/run-context.d.ts +54 -0
  143. package/dist/run-context.js +137 -0
  144. package/dist/runs.d.ts +82 -0
  145. package/dist/runs.js +506 -0
  146. package/dist/runtime-backend.d.ts +24 -0
  147. package/dist/runtime-backend.js +214 -0
  148. package/dist/runtime.d.ts +97 -0
  149. package/dist/runtime.js +553 -0
  150. package/dist/scheduler.d.ts +85 -0
  151. package/dist/scheduler.js +344 -0
  152. package/dist/service.d.ts +29 -0
  153. package/dist/service.js +515 -0
  154. package/dist/skills-api.d.ts +18 -0
  155. package/dist/skills-api.js +120 -0
  156. package/dist/storage-api.d.ts +21 -0
  157. package/dist/storage-api.js +92 -0
  158. package/dist/transfer-api.d.ts +10 -0
  159. package/dist/transfer-api.js +60 -0
  160. package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
  161. package/dist/vendor/protocol/contracts.typecheck.js +35 -0
  162. package/dist/vendor/protocol/execution-chain.d.ts +6 -0
  163. package/dist/vendor/protocol/execution-chain.js +26 -0
  164. package/dist/vendor/protocol/graph-binary.d.ts +15 -0
  165. package/dist/vendor/protocol/graph-binary.js +121 -0
  166. package/dist/vendor/protocol/graph-title.d.ts +2 -0
  167. package/dist/vendor/protocol/graph-title.js +23 -0
  168. package/dist/vendor/protocol/index.d.ts +617 -0
  169. package/dist/vendor/protocol/index.js +20 -0
  170. package/dist/vendor/protocol/pairing.d.ts +29 -0
  171. package/dist/vendor/protocol/pairing.js +71 -0
  172. package/dist/vendor/protocol/preview.d.ts +2 -0
  173. package/dist/vendor/protocol/preview.js +9 -0
  174. package/dist/vendor/protocol/resource-mime.d.ts +2 -0
  175. package/dist/vendor/protocol/resource-mime.js +35 -0
  176. package/dist/video-thumbnail.d.ts +2 -0
  177. package/dist/video-thumbnail.js +34 -0
  178. package/package.json +9 -0
package/dist/graphs.js ADDED
@@ -0,0 +1,771 @@
1
+ import { GraphDocumentVersions } from './graph-document-history.js';
2
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { WORKGRAPH_UPLOAD_MAX_BYTES, TERMINAL_RUN_STATUSES, executionOrder } from "./vendor/protocol/index.js";
4
+ import { ServiceError } from './errors.js';
5
+ import { atomic } from './persistence/database.js';
6
+ import { canonicalJson, Repositories } from './persistence/repositories.js';
7
+ import { previewEdgeError } from "./vendor/protocol/index.js";
8
+ import { PluginRegistry } from './plugins.js';
9
+ const BUILTINS = new Set(['text', 'image', 'document', 'video', 'file', 'preview', 'execution', 'group']);
10
+ export function assertProjectWritable(db, id) {
11
+ const row = db.prepare('SELECT state FROM projects WHERE id=?').get(id);
12
+ if (!row)
13
+ throw new ServiceError('NOT_FOUND', '项目不存在。');
14
+ if (row['state'] !== 'active')
15
+ throw new ServiceError('PROJECT_INACTIVE', '停用项目只读。');
16
+ }
17
+ function identifier(value) { if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value))
18
+ throw new ServiceError('INVALID_REQUEST', '无效的标识。'); }
19
+ function title(value) { if (typeof value !== 'string' || !value.trim() || value.length > 1024)
20
+ throw new ServiceError('INVALID_REQUEST', '名称必须非空且不超过 1024 字符。'); }
21
+ function position(value) { if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) > 1e9)
22
+ throw new ServiceError('INVALID_REQUEST', '无效的位置。'); }
23
+ export function nodeDimension(value) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1e9)
24
+ throw new ServiceError('INVALID_REQUEST', '节点尺寸必须为正有限数且不超过 1e9。'); }
25
+ function boundedContent(value) {
26
+ let count = 0;
27
+ const visit = (v, depth) => { if (depth > 32 || ++count > 100000)
28
+ throw new ServiceError('INVALID_REQUEST', '节点内容过深或过大。'); if (v && typeof v === 'object')
29
+ for (const item of Object.values(v))
30
+ visit(item, depth + 1); };
31
+ visit(value, 0);
32
+ if (Buffer.byteLength(canonicalJson(value)) > 2 * 1024 * 1024)
33
+ throw new ServiceError('PAYLOAD_TOO_LARGE', '单节点内容不能超过 2 MiB。');
34
+ }
35
+ export function resourceLinks(content) {
36
+ const result = new Map();
37
+ const visit = (value) => {
38
+ if (!value || typeof value !== 'object')
39
+ return;
40
+ if (!Array.isArray(value) && typeof value['resourceId'] === 'string') {
41
+ const version = value['resourceVersion'] ?? value['version'];
42
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1)
43
+ throw new ServiceError('INVALID_REQUEST', '画布资源必须固定版本。');
44
+ result.set(value['resourceId'] + ':' + version, { resourceId: value['resourceId'], version });
45
+ }
46
+ for (const item of Object.values(value))
47
+ visit(item);
48
+ };
49
+ visit(content);
50
+ return [...result.values()];
51
+ }
52
+ export class Graphs {
53
+ db;
54
+ serviceId;
55
+ repo;
56
+ history;
57
+ pageKey = randomBytes(32);
58
+ pageEpoch = randomUUID();
59
+ constructor(db, serviceId) {
60
+ this.db = db;
61
+ this.serviceId = serviceId;
62
+ this.repo = new Repositories(db);
63
+ this.history = new GraphDocumentVersions(this, db);
64
+ }
65
+ scope(scope) {
66
+ if (scope.serviceId !== this.serviceId)
67
+ throw new ServiceError('SERVICE_MISMATCH', '服务身份不匹配。');
68
+ const row = this.db.prepare('SELECT * FROM graphs WHERE id=? AND project_id=?').get(scope.graphId, scope.projectId);
69
+ if (!row)
70
+ throw new ServiceError('NOT_FOUND', '工作图不存在。');
71
+ return row;
72
+ }
73
+ writable(scope, allowArchived = false) { const graph = this.scope(scope); assertProjectWritable(this.db, scope.projectId); if (graph['trashed'] || (graph['archived'] && !allowArchived))
74
+ throw new ServiceError('CONFLICT', '归档或回收站工作图只读。'); return graph; }
75
+ snapshot(scope) {
76
+ return atomic(this.db, () => {
77
+ const graph = this.scope(scope);
78
+ const rows = this.db.prepare('SELECT n.*,v.content FROM nodes n JOIN node_versions v ON v.node_id=n.id AND v.version=n.current_version WHERE n.graph_id=? AND n.deleted=0 ORDER BY n.created_at,n.creation_order').all(scope.graphId);
79
+ const groups = new Map(this.db.prepare('SELECT g.id,g.title FROM graph_groups g JOIN nodes n ON n.id=g.id AND n.graph_id=g.graph_id WHERE g.graph_id=? AND g.run_id IS NULL AND n.type=\'group\' AND n.deleted=0').all(scope.graphId).map(row => [String(row['id']), String(row['title'])]));
80
+ const members = new Map();
81
+ for (const row of this.db.prepare('SELECT m.group_id,m.node_id FROM group_members m JOIN graph_groups g ON g.id=m.group_id JOIN nodes n ON n.id=m.node_id WHERE g.graph_id=? AND n.graph_id=? AND n.deleted=0 ORDER BY n.created_at,n.creation_order').all(scope.graphId, scope.graphId)) {
82
+ const id = String(row['group_id']);
83
+ const ids = members.get(id) ?? [];
84
+ ids.push(String(row['node_id']));
85
+ members.set(id, ids);
86
+ }
87
+ const outputNames = new Map(this.db.prepare("SELECT o.node_id,r.name FROM execution_outputs o JOIN nodes n ON n.id=o.node_id JOIN node_versions v ON v.node_id=n.id AND v.version=n.current_version JOIN canvas_resources r ON r.id=json_extract(v.content,'$.resourceId') AND r.graph_id=n.graph_id WHERE n.graph_id=?").all(scope.graphId).map(row => [String(row['node_id']), String(row['name'])]));
88
+ const asNode = (row) => {
89
+ let content = groups.has(String(row['id'])) ? { title: groups.get(String(row['id'])) } : JSON.parse(String(row['content']));
90
+ const name = outputNames.get(String(row['id']));
91
+ // Presentation fallback for immutable legacy versions, including hidden outputs.
92
+ if (row['read_only'] && name && content && typeof content === 'object' && !Array.isArray(content) && (typeof content['title'] !== 'string' || !content['title'].trim()))
93
+ content = { ...content, title: name };
94
+ return { id: String(row['id']), type: String(row['type']), schemaVersion: Number(row['schema_version']), contentVersion: Number(row['current_version']), content, x: Number(row['x']), y: Number(row['y']), ...(row['width'] === null ? {} : { width: Number(row['width']) }), ...(row['height'] === null ? {} : { height: Number(row['height']) }), ...(groups.has(String(row['id'])) ? { memberIds: members.get(String(row['id'])) ?? [] } : {}), readOnly: Boolean(row['read_only']) };
95
+ };
96
+ const nodes = rows.map(asNode);
97
+ const hiddenExecutionOutputs = this.db.prepare('SELECT n.*,v.content,o.execution_node_id FROM execution_outputs o JOIN nodes n ON n.id=o.node_id JOIN nodes owner ON owner.id=o.execution_node_id JOIN node_versions v ON v.node_id=n.id AND v.version=n.current_version WHERE n.graph_id=? AND n.deleted=1 AND owner.deleted=0 ORDER BY n.created_at,n.creation_order').all(scope.graphId).map(row => ({ executionNodeId: String(row['execution_node_id']), node: asNode(row) }));
98
+ const edges = this.db.prepare('SELECT e.* FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id WHERE e.graph_id=? AND s.deleted=0 AND t.deleted=0 ORDER BY e.id').all(scope.graphId).map(row => ({ id: String(row['id']), sourceId: String(row['source_id']), targetId: String(row['target_id']), kind: row['kind'] }));
99
+ return { ...scope, ...(graph['updated_at'] ? { updatedAt: String(graph['updated_at']) } : {}), title: String(graph['title']), archived: Boolean(graph['archived']), trashed: Boolean(graph['trashed']), executionRevision: Number(graph['execution_revision']), layoutRevision: Number(graph['layout_revision']), eventCursor: String(this.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='events'").get()?.['seq'] ?? 0), nodes, edges, hiddenExecutionOutputs, history: this.history.state(scope) };
100
+ });
101
+ }
102
+ list(projectId, includeArchived = false, includeInactive = false, trashed = false) {
103
+ return atomic(this.db, () => {
104
+ const project = this.db.prepare('SELECT state FROM projects WHERE id=?').get(projectId);
105
+ if (!project)
106
+ throw new ServiceError('NOT_FOUND', '项目不存在。');
107
+ if (project['state'] !== 'active' && !includeInactive)
108
+ return [];
109
+ return this.db.prepare('SELECT id FROM graphs WHERE project_id=? AND trashed=? AND (? OR archived=0) ORDER BY id').all(projectId, Number(trashed), Number(includeArchived)).map(row => this.snapshot({ serviceId: this.serviceId, projectId, graphId: String(row['id']) }));
110
+ });
111
+ }
112
+ /** Read-only pagination; no saved snapshots, idempotency records or GC work.
113
+ * One transaction fingerprints and reads the list. Cursors survive unrelated
114
+ * service events, but not project graph changes or a service restart. */
115
+ page(projectId, request, principal) {
116
+ identifier(projectId);
117
+ if (Object.keys(request).some(key => !['filter', 'limit', 'cursor'].includes(key))
118
+ || !['all', 'trashed'].includes(String(request['filter'])) || typeof request['filter'] !== 'string'
119
+ || !Number.isSafeInteger(request['limit']) || Number(request['limit']) < 1 || Number(request['limit']) > 100
120
+ || ('cursor' in request && (typeof request['cursor'] !== 'string' || !request['cursor'] || request['cursor'].length > 4096)))
121
+ throw new ServiceError('INVALID_REQUEST', '分页需要 filter=all|trashed、limit=1..100 和可选 cursor。');
122
+ const filter = request['filter'], limit = Number(request['limit']);
123
+ let prior;
124
+ if (typeof request['cursor'] === 'string') {
125
+ const parts = request['cursor'].split('.');
126
+ try {
127
+ if (parts.length !== 2 || !parts.every(part => /^[A-Za-z0-9_-]+$/.test(part)))
128
+ throw new Error();
129
+ const bytes = Buffer.from(parts[0], 'base64url');
130
+ if (bytes.toString('base64url') !== parts[0])
131
+ throw new Error();
132
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
133
+ if (!value || typeof value !== 'object' || Array.isArray(value))
134
+ throw new Error();
135
+ prior = value;
136
+ if (Object.keys(prior).sort().join(',') !== 'epoch,eventCursor,filter,offset,principal,projectId,serviceId,snapshotId,v'
137
+ || prior['v'] !== 1 || prior['serviceId'] !== this.serviceId || prior['projectId'] !== projectId || prior['filter'] !== filter || prior['principal'] !== principal
138
+ || typeof prior['epoch'] !== 'string' || typeof prior['snapshotId'] !== 'string' || !/^[a-f0-9]{64}$/.test(prior['snapshotId'])
139
+ || !Number.isSafeInteger(prior['offset']) || Number(prior['offset']) < 1
140
+ || typeof prior['eventCursor'] !== 'string' || !/^\d+$/.test(prior['eventCursor']) || !Number.isSafeInteger(Number(prior['eventCursor'])))
141
+ throw new Error();
142
+ }
143
+ catch {
144
+ throw new ServiceError('INVALID_REQUEST', '分页游标格式无效或不属于当前服务、项目、筛选及会话。');
145
+ }
146
+ if (prior['epoch'] !== this.pageEpoch)
147
+ throw new ServiceError('CURSOR_EXPIRED', '服务已重启,请从第一页重新读取。');
148
+ const signature = Buffer.from(parts[1], 'base64url'), expected = createHmac('sha256', this.pageKey).update(parts[0]).digest();
149
+ if (signature.toString('base64url') !== parts[1] || signature.length !== expected.length || !timingSafeEqual(signature, expected))
150
+ throw new ServiceError('INVALID_REQUEST', '分页游标校验失败。');
151
+ }
152
+ return atomic(this.db, () => {
153
+ const items = this.list(projectId, true, true, filter === 'trashed');
154
+ const state = this.db.prepare('SELECT state FROM projects WHERE id=?').get(projectId)['state'];
155
+ // Keep the project event watermark in the fingerprint to catch change-then-
156
+ // revert, including a graph created and purged between page requests.
157
+ const graphWatermark = String(this.db.prepare("SELECT COALESCE(MAX(sequence),0) n FROM events WHERE project_id=? AND type='graph.changed'").get(projectId)['n']);
158
+ const snapshotId = createHash('sha256').update(canonicalJson({ serviceId: this.serviceId, projectId, filter, state, graphWatermark, items: items.map(({ eventCursor: _, ...graph }) => graph) })).digest('hex');
159
+ const currentWatermark = String(this.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='events'").get()?.['seq'] ?? 0);
160
+ const eventFloor = Number(JSON.parse(String(this.db.prepare("SELECT value FROM settings WHERE key='eventFloor'").get()?.['value'] ?? '0')));
161
+ if (prior && (prior['snapshotId'] !== snapshotId || Number(prior['eventCursor']) > Number(currentWatermark) || Number(prior['eventCursor']) < eventFloor))
162
+ throw new ServiceError('CURSOR_EXPIRED', '工作图列表快照或事件水位已失效,请丢弃已累积页面并从第一页重读。');
163
+ const offset = prior ? Number(prior['offset']) : 0, eventCursor = prior ? String(prior['eventCursor']) : currentWatermark;
164
+ if (prior && offset >= items.length)
165
+ throw new ServiceError('INVALID_REQUEST', '分页游标位置无效。');
166
+ const next = offset + limit;
167
+ let nextCursor = null;
168
+ if (next < items.length) {
169
+ const payload = Buffer.from(JSON.stringify({ v: 1, serviceId: this.serviceId, projectId, filter, principal, epoch: this.pageEpoch, snapshotId, eventCursor, offset: next })).toString('base64url');
170
+ nextCursor = payload + '.' + createHmac('sha256', this.pageKey).update(payload).digest('base64url');
171
+ }
172
+ return { items: items.slice(offset, next).map(item => ({ ...item, eventCursor })), nextCursor, snapshotId, eventCursor };
173
+ });
174
+ }
175
+ create(projectId, name, key, principal = 'local') {
176
+ title(name);
177
+ identifier(projectId);
178
+ return this.repo.idempotent(principal + ':graph.create', key, { projectId, name }, () => {
179
+ assertProjectWritable(this.db, projectId);
180
+ const graphId = randomUUID();
181
+ this.db.prepare('INSERT INTO graphs(id,project_id,title) VALUES(?,?,?)').run(graphId, projectId, name);
182
+ const scope = { serviceId: this.serviceId, projectId, graphId };
183
+ this.event(scope);
184
+ return this.snapshot(scope);
185
+ });
186
+ }
187
+ event(scope) { const graph = this.scope(scope); this.repo.appendEvent({ eventId: randomUUID(), type: 'graph.changed', projectId: scope.projectId, graphId: scope.graphId, entityId: scope.graphId, revision: Number(graph['execution_revision']), occurredAt: new Date().toISOString(), payload: { executionRevision: Number(graph['execution_revision']), layoutRevision: Number(graph['layout_revision']), archived: Boolean(graph['archived']), trashed: Boolean(graph['trashed']) } }); }
188
+ activeNode(nodeId) { return Boolean(this.db.prepare(`SELECT 1 FROM runs WHERE node_id=? AND status NOT IN (${TERMINAL_RUN_STATUSES.map(() => '?').join(',')}) LIMIT 1`).get(nodeId, ...TERMINAL_RUN_STATUSES)); }
189
+ activeExecutionChainNodeIds(scope) {
190
+ const terminal = TERMINAL_RUN_STATUSES.map(() => '?').join(',');
191
+ const rows = this.db.prepare(`
192
+ SELECT DISTINCT member.node_id
193
+ FROM runs member
194
+ JOIN run_runtime member_rt ON member_rt.run_id=member.id
195
+ WHERE member.graph_id=?
196
+ AND json_extract(member_rt.details,'$.chainBatch') IN (
197
+ SELECT json_extract(active_rt.details,'$.chainBatch')
198
+ FROM runs active
199
+ JOIN run_runtime active_rt ON active_rt.run_id=active.id
200
+ WHERE active.graph_id=?
201
+ AND active.status NOT IN (${terminal})
202
+ AND json_extract(active_rt.details,'$.chainBatch') IS NOT NULL
203
+ )
204
+ `).all(scope.graphId, scope.graphId, ...TERMINAL_RUN_STATUSES);
205
+ return new Set(rows.map(row => String(row['node_id'])));
206
+ }
207
+ activeExecutionChain(scope) { return this.activeExecutionChainNodeIds(scope).size > 0; }
208
+ assertChainTopologyEditable(scope, operations) {
209
+ const activeNodeIds = this.activeExecutionChainNodeIds(scope);
210
+ if (!activeNodeIds.size)
211
+ return;
212
+ const blocked = operations.some(op => {
213
+ if (op.type === 'edge.create')
214
+ return op.edge.kind === 'execution' && (activeNodeIds.has(op.edge.sourceId) || activeNodeIds.has(op.edge.targetId));
215
+ if (op.type === 'edge.delete') {
216
+ const edge = this.db.prepare('SELECT source_id,target_id,kind FROM edges WHERE id=? AND graph_id=?').get(op.edgeId, scope.graphId);
217
+ return edge?.['kind'] === 'execution' && (activeNodeIds.has(String(edge['source_id'])) || activeNodeIds.has(String(edge['target_id'])));
218
+ }
219
+ if (op.type !== 'node.delete' || this.db.prepare('SELECT 1 FROM execution_outputs WHERE node_id=?').get(op.nodeId))
220
+ return false;
221
+ if (activeNodeIds.has(op.nodeId))
222
+ return true;
223
+ return this.db.prepare("SELECT source_id,target_id FROM edges WHERE graph_id=? AND kind='execution' AND (source_id=? OR target_id=?)").all(scope.graphId, op.nodeId, op.nodeId).some(edge => activeNodeIds.has(String(edge['source_id'])) || activeNodeIds.has(String(edge['target_id'])));
224
+ });
225
+ if (blocked)
226
+ throw new ServiceError('NODE_LOCKED', '正在运行的串联成员及其执行连线不可删除或修改,请先停止整体调度并完成收尾。');
227
+ }
228
+ assertSettled(scope) {
229
+ if (this.db.prepare(`SELECT 1 FROM runs WHERE graph_id=? AND status NOT IN (${TERMINAL_RUN_STATUSES.map(() => '?').join(',')}) LIMIT 1`).get(scope.graphId, ...TERMINAL_RUN_STATUSES)
230
+ || this.db.prepare('SELECT 1 FROM occupancy o JOIN runs r ON r.id=o.run_id WHERE r.graph_id=? LIMIT 1').get(scope.graphId))
231
+ throw new ServiceError('ACTIVE_RUN', '图中任务须结束或取消并完成收尾。');
232
+ }
233
+ /** A lifecycle operation is never mixed with edits, even if it restores writability. */
234
+ lifecycle(scope, op) {
235
+ const graph = this.scope(scope);
236
+ this.assertSettled(scope);
237
+ if (op.type === 'graph.archive') {
238
+ if (typeof op.archived !== 'boolean')
239
+ throw new ServiceError('INVALID_REQUEST', '归档值必须是布尔值。');
240
+ if (graph['trashed'] || Boolean(graph['archived']) === op.archived)
241
+ throw new ServiceError('CONFLICT', '不合法的归档状态转换。');
242
+ this.db.prepare('UPDATE graphs SET archived=? WHERE id=?').run(Number(op.archived), scope.graphId);
243
+ }
244
+ else {
245
+ if (typeof op.trashed !== 'boolean')
246
+ throw new ServiceError('INVALID_REQUEST', '回收站值必须是布尔值。');
247
+ if (Boolean(graph['trashed']) === op.trashed)
248
+ throw new ServiceError('CONFLICT', '不合法的回收站状态转换。');
249
+ // Preserve the previous archive state across trash/restore.
250
+ this.db.prepare('UPDATE graphs SET trashed=? WHERE id=?').run(Number(op.trashed), scope.graphId);
251
+ }
252
+ }
253
+ permanentDelete(request, resources, principal = 'local') {
254
+ if (!Number.isSafeInteger(request.expectedExecutionRevision) || !Number.isSafeInteger(request.expectedLayoutRevision) || typeof request.confirmTitle !== 'string')
255
+ throw new ServiceError('INVALID_REQUEST', '彻底删除需要版本和确认名称。');
256
+ return this.repo.idempotent(principal + ':graph.delete:' + request.graphId, request.idempotencyKey, request, () => {
257
+ const graph = this.scope(request);
258
+ assertProjectWritable(this.db, request.projectId);
259
+ this.assertSettled(request);
260
+ if (!graph['trashed'])
261
+ throw new ServiceError('CONFLICT', '只有回收站工作图可彻底删除。');
262
+ if (graph['execution_revision'] !== request.expectedExecutionRevision || graph['layout_revision'] !== request.expectedLayoutRevision)
263
+ throw new ServiceError('REVISION_CONFLICT', '工作图版本已变化。');
264
+ if (graph['title'] !== request.confirmTitle)
265
+ throw new ServiceError('CONFLICT', '确认名称与工作图不符。');
266
+ if (this.db.prepare("SELECT 1 FROM resource_maintenance_leases WHERE kind='backup'").get())
267
+ throw new ServiceError('MAINTENANCE', '备份进行中,请稍后重试。');
268
+ const id = request.graphId;
269
+ this.history.clear(id);
270
+ this.db.prepare('INSERT INTO run_file_deletions(run_id) SELECT id FROM runs WHERE graph_id=?').run(id);
271
+ const hashes = this.db.prepare('SELECT DISTINCT v.sha256 FROM canvas_resource_versions v JOIN canvas_resources r ON r.id=v.resource_id WHERE r.graph_id=?').all(id).map(row => String(row['sha256']));
272
+ // Keep request hashes as non-replayable tombstones: retries cannot recreate
273
+ // a deleted graph or disclose old snapshots through cached responses.
274
+ this.db.prepare(`UPDATE idempotency SET response='null',invalidated=1 WHERE
275
+ json_extract(response,'$.graphId')=? OR json_extract(response,'$.resource.graphId')=?
276
+ OR json_extract(response,'$.runId') IN (SELECT id FROM runs WHERE graph_id=?)
277
+ OR json_extract(response,'$.id') IN (SELECT id FROM runs WHERE graph_id=?)
278
+ OR EXISTS(SELECT 1 FROM json_each(response,'$.runIds') j WHERE j.value IN (SELECT id FROM runs WHERE graph_id=?))`).run(id, id, id, id, id);
279
+ this.db.prepare('DELETE FROM asset_references WHERE run_id IN (SELECT id FROM runs WHERE graph_id=?) OR node_id IN (SELECT id FROM nodes WHERE graph_id=?)').run(id, id);
280
+ this.db.prepare('DELETE FROM canvas_resource_references WHERE graph_id=?').run(id);
281
+ this.db.prepare('DELETE FROM node_resource_history WHERE node_id IN (SELECT id FROM nodes WHERE graph_id=?)').run(id);
282
+ this.db.prepare('DELETE FROM group_members WHERE group_id IN (SELECT id FROM graph_groups WHERE graph_id=?) OR node_id IN (SELECT id FROM nodes WHERE graph_id=?)').run(id, id);
283
+ this.db.prepare('DELETE FROM graph_groups WHERE graph_id=?').run(id);
284
+ for (const table of ['outputs', 'canvas_outputs', 'interactions', 'occupancy', 'execution_input_snapshots', 'snapshots', 'run_runtime', 'run_process_records', 'generation_candidates', 'publication_manifests'])
285
+ this.db.prepare(`DELETE FROM ${table} WHERE run_id IN (SELECT id FROM runs WHERE graph_id=?)`).run(id);
286
+ this.db.prepare('DELETE FROM runs WHERE graph_id=?').run(id);
287
+ this.db.prepare('DELETE FROM edges WHERE graph_id=?').run(id);
288
+ this.db.prepare('DELETE FROM node_versions WHERE node_id IN (SELECT id FROM nodes WHERE graph_id=?)').run(id);
289
+ this.db.prepare('DELETE FROM nodes WHERE graph_id=?').run(id);
290
+ resources.collectGarbage({ graphId: id, blobHashes: hashes });
291
+ if (this.db.prepare('SELECT 1 FROM canvas_resources WHERE graph_id=?').get(id))
292
+ throw new ServiceError('CONFLICT', '仍有外部历史引用,不能清理工作图资源。');
293
+ this.db.prepare('DELETE FROM events WHERE graph_id=?').run(id);
294
+ this.db.prepare('DELETE FROM graphs WHERE id=?').run(id);
295
+ this.repo.appendEvent({ eventId: randomUUID(), type: 'graph.changed', projectId: request.projectId, graphId: null, entityId: id, revision: Number(graph['execution_revision']) + 1, occurredAt: new Date().toISOString(), payload: { graphId: id, deleted: true } });
296
+ return { graphId: id, deleted: true };
297
+ });
298
+ }
299
+ /** Submit owns the transaction; retirement never edits frozen snapshots or Run files. */
300
+ retireExecutionOutputs(scope, executionNodeId) {
301
+ if (!this.db.isTransaction)
302
+ throw new Error('Output retirement requires a transaction');
303
+ const outputs = this.db.prepare('SELECT o.node_id FROM execution_outputs o JOIN nodes n ON n.id=o.node_id WHERE o.execution_node_id=? AND n.graph_id=?').all(executionNodeId, scope.graphId);
304
+ for (const row of outputs) {
305
+ const id = String(row['node_id']);
306
+ this.assertNodeEditable(id);
307
+ for (const edge of this.db.prepare('SELECT target_id FROM edges WHERE source_id=?').all(id))
308
+ this.assertNodeEditable(String(edge['target_id']));
309
+ }
310
+ for (const row of outputs) {
311
+ const id = String(row['node_id']);
312
+ this.db.prepare('DELETE FROM edges WHERE source_id=? OR target_id=?').run(id, id);
313
+ this.db.prepare('DELETE FROM group_members WHERE node_id=?').run(id);
314
+ this.db.prepare("DELETE FROM canvas_resource_references WHERE owner_kind='node' AND node_id=?").run(id);
315
+ this.db.prepare('UPDATE nodes SET deleted=1,undo_expires_at=NULL WHERE id=?').run(id);
316
+ }
317
+ this.db.prepare('DELETE FROM execution_outputs WHERE execution_node_id=?').run(executionNodeId);
318
+ if (outputs.length) {
319
+ this.db.prepare('UPDATE graphs SET execution_revision=execution_revision+1,layout_revision=layout_revision+1 WHERE id=?').run(scope.graphId);
320
+ this.event(scope);
321
+ }
322
+ }
323
+ restoreOutput(scope, op) {
324
+ identifier(op.executionNodeId);
325
+ identifier(op.nodeId);
326
+ position(op.x);
327
+ position(op.y);
328
+ const owner = this.node(scope, op.executionNodeId);
329
+ this.assertNodeEditable(op.executionNodeId);
330
+ if (owner['type'] !== 'execution' || !this.db.prepare('SELECT 1 FROM execution_outputs o JOIN nodes n ON n.id=o.node_id WHERE o.node_id=? AND o.execution_node_id=? AND n.graph_id=? AND n.deleted=1').get(op.nodeId, op.executionNodeId, scope.graphId))
331
+ throw new ServiceError('NOT_FOUND', '当前批次中没有该隐藏输出。');
332
+ this.db.prepare('UPDATE nodes SET deleted=0,x=?,y=?,undo_expires_at=NULL WHERE id=?').run(op.x, op.y, op.nodeId);
333
+ const node = this.node(scope, op.nodeId);
334
+ const content = JSON.parse(String(this.db.prepare('SELECT content FROM node_versions WHERE node_id=? AND version=?').get(op.nodeId, Number(node['current_version']))['content']));
335
+ this.retainResources(scope, op.nodeId, Number(node['current_version']), content);
336
+ const edge = { id: randomUUID(), sourceId: op.executionNodeId, targetId: op.nodeId, kind: 'delivery' };
337
+ this.validateEdge(scope, edge, true);
338
+ this.db.prepare('INSERT INTO edges(id,graph_id,source_id,target_id,kind) VALUES(?,?,?,?,?)').run(edge.id, scope.graphId, edge.sourceId, edge.targetId, edge.kind);
339
+ }
340
+ assertNodeEditable(nodeId) { if (this.activeNode(nodeId))
341
+ throw new ServiceError('NODE_LOCKED', '执行或生成尚未结束,节点输入已锁定。'); }
342
+ assertContentEditable(node, content) {
343
+ if (!this.activeNode(String(node['id'])))
344
+ return;
345
+ const old = JSON.parse(String(this.db.prepare('SELECT content FROM node_versions WHERE node_id=? AND version=?').get(String(node['id']), Number(node['current_version']))['content']));
346
+ if (node['type'] === 'execution') {
347
+ if (!old || typeof old !== 'object' || Array.isArray(old) || !content || typeof content !== 'object' || Array.isArray(content) || !Object.hasOwn(content, 'title'))
348
+ throw new ServiceError('NODE_LOCKED', '执行期间仅可修改节点名称。');
349
+ title(content['title']);
350
+ const withoutTitle = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'title'));
351
+ if (canonicalJson(withoutTitle(old)) !== canonicalJson(withoutTitle(content)))
352
+ throw new ServiceError('NODE_LOCKED', '执行期间仅可修改节点名称。');
353
+ return;
354
+ }
355
+ const prompt = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value['prompt'] ?? null : null;
356
+ if (canonicalJson(prompt(old)) !== canonicalJson(prompt(content)))
357
+ throw new ServiceError('NODE_LOCKED', '生成期间提示词已锁定,正文仍可编辑。');
358
+ }
359
+ validateContent(node, content) {
360
+ if (content && typeof content === 'object' && !Array.isArray(content) && content['source'] !== undefined) {
361
+ const source = content['source'];
362
+ if (!source || typeof source !== 'object' || Array.isArray(source) || typeof source['relativePath'] !== 'string')
363
+ throw new ServiceError('INVALID_REQUEST', '项目文件来源无效。');
364
+ if (source['kind'] === 'project-file') {
365
+ if (source['serviceId'] !== this.serviceId || typeof source['projectId'] !== 'string' || !source['relativePath'])
366
+ throw new ServiceError('INVALID_REQUEST', '项目文件来源无效。');
367
+ const project = this.db.prepare('SELECT g.project_id FROM graphs g JOIN nodes n ON n.graph_id=g.id WHERE n.id=?').get(String(node['id']));
368
+ if (!project || source['projectId'] !== project['project_id'])
369
+ throw new ServiceError('INVALID_REQUEST', '项目文件引用不属于当前项目。');
370
+ }
371
+ else if (source['kind'] !== 'project-file-empty' || Object.keys(source).some(key => !['kind', 'relativePath'].includes(key)))
372
+ throw new ServiceError('INVALID_REQUEST', '空引用来源无效。');
373
+ if (content['resourceId'] !== undefined || content['resourceVersion'] !== undefined)
374
+ throw new ServiceError('INVALID_REQUEST', '项目文件来源与工作图资源来源必须互斥。');
375
+ }
376
+ const state = new PluginRegistry(this.db).inspect(String(node['type']), Number(node['schema_version']), content).state;
377
+ if (state !== 'available')
378
+ throw new ServiceError(state === 'invalid' ? 'INVALID_REQUEST' : 'PLUGIN_UNAVAILABLE', '节点正文或插件资源契约不可用。');
379
+ }
380
+ node(scope, id) { const row = this.db.prepare('SELECT * FROM nodes WHERE id=? AND graph_id=? AND deleted=0').get(id, scope.graphId); if (!row)
381
+ throw new ServiceError('NOT_FOUND', '节点不存在。'); return row; }
382
+ group(scope, id) {
383
+ identifier(id);
384
+ const node = this.node(scope, id);
385
+ if (node['type'] !== 'group' || !this.db.prepare('SELECT 1 FROM graph_groups WHERE id=? AND graph_id=? AND run_id IS NULL').get(id, scope.graphId))
386
+ throw new ServiceError('INVALID_REQUEST', '仅支持画布分组节点。');
387
+ return node;
388
+ }
389
+ members(scope, id) { this.group(scope, id); return this.db.prepare('SELECT m.node_id FROM group_members m JOIN nodes n ON n.id=m.node_id WHERE m.group_id=? AND n.graph_id=? AND n.deleted=0 ORDER BY n.created_at,n.creation_order').all(id, scope.graphId).map(row => String(row['node_id'])); }
390
+ assertLayoutEditable(scope, id) {
391
+ const node = this.node(scope, id);
392
+ this.assertNodeEditable(id);
393
+ if (node['type'] === 'group')
394
+ for (const member of this.members(scope, id))
395
+ this.assertNodeEditable(member);
396
+ }
397
+ assertGeometryEditable(scope, id) {
398
+ const node = this.node(scope, id);
399
+ if (node['type'] === 'group')
400
+ this.group(scope, id);
401
+ return node;
402
+ }
403
+ /** Membership is layout metadata, never a node content/resource revision. */
404
+ setGroupMembers(scope, groupId, memberIds) {
405
+ if (!this.db.isTransaction)
406
+ throw new Error('Group membership requires the graph transaction');
407
+ this.writable(scope);
408
+ this.group(scope, groupId);
409
+ if (!Array.isArray(memberIds) || memberIds.some(id => typeof id !== 'string') || new Set(memberIds).size !== memberIds.length)
410
+ throw new ServiceError('INVALID_REQUEST', '分组成员必须是无重复的节点 ID 数组。');
411
+ for (const id of memberIds) {
412
+ identifier(id);
413
+ const member = this.node(scope, id);
414
+ if (member['type'] === 'group')
415
+ throw new ServiceError('INVALID_REQUEST', '不支持嵌套分组。');
416
+ if (this.db.prepare("SELECT 1 FROM group_members m JOIN graph_groups g ON g.id=m.group_id JOIN nodes n ON n.id=g.id AND n.graph_id=g.graph_id WHERE m.node_id=? AND m.group_id!=? AND g.run_id IS NULL AND n.type='group' AND n.deleted=0").get(id, groupId))
417
+ throw new ServiceError('CONFLICT', '节点已属于其他画布分组。');
418
+ }
419
+ this.db.prepare('DELETE FROM group_members WHERE group_id=?').run(groupId);
420
+ for (const id of memberIds)
421
+ this.db.prepare('INSERT INTO group_members(group_id,node_id) VALUES(?,?)').run(groupId, id);
422
+ }
423
+ move(scope, positions) {
424
+ if (!Array.isArray(positions) || positions.length > 1000)
425
+ throw new ServiceError('INVALID_REQUEST', '无效布局。');
426
+ const moves = new Map(), groups = [];
427
+ for (const point of positions) {
428
+ if (!point || typeof point !== 'object')
429
+ throw new ServiceError('INVALID_REQUEST', '无效布局点。');
430
+ identifier(point.nodeId);
431
+ if (moves.has(point.nodeId))
432
+ throw new ServiceError('INVALID_REQUEST', '重复移动节点。');
433
+ const node = this.assertGeometryEditable(scope, point.nodeId);
434
+ position(point.x);
435
+ position(point.y);
436
+ moves.set(point.nodeId, { x: point.x, y: point.y });
437
+ if (node['type'] === 'group')
438
+ groups.push({ id: point.nodeId, x: point.x, y: point.y, dx: point.x - Number(node['x']), dy: point.y - Number(node['y']) });
439
+ }
440
+ // As in the original canvas, an explicitly moved group owns its members'
441
+ // translation; selecting both the frame and a member never moves it twice.
442
+ for (const group of groups)
443
+ for (const id of this.members(scope, group.id)) {
444
+ const node = this.node(scope, id), x = Number(node['x']) + group.dx, y = Number(node['y']) + group.dy;
445
+ position(x);
446
+ position(y);
447
+ moves.set(id, { x, y });
448
+ }
449
+ for (const [id, point] of moves)
450
+ this.db.prepare('UPDATE nodes SET x=?,y=? WHERE id=?').run(point.x, point.y, id);
451
+ }
452
+ resize(scope, sizes) {
453
+ if (!Array.isArray(sizes) || sizes.length > 1000)
454
+ throw new ServiceError('INVALID_REQUEST', '无效尺寸列表。');
455
+ const ids = new Set();
456
+ for (const size of sizes) {
457
+ if (!size || typeof size !== 'object' || ids.has(size.nodeId))
458
+ throw new ServiceError('INVALID_REQUEST', '无效或重复尺寸节点。');
459
+ ids.add(size.nodeId);
460
+ identifier(size.nodeId);
461
+ const node = this.assertGeometryEditable(scope, size.nodeId);
462
+ if (size.width !== null)
463
+ nodeDimension(size.width);
464
+ if (size.height !== null)
465
+ nodeDimension(size.height);
466
+ if (size.x !== undefined)
467
+ position(size.x);
468
+ if (size.y !== undefined)
469
+ position(size.y);
470
+ this.db.prepare('UPDATE nodes SET width=?,height=?,x=?,y=? WHERE id=?').run(size.width, size.height, size.x ?? Number(node['x']), size.y ?? Number(node['y']), size.nodeId);
471
+ }
472
+ }
473
+ /** All retained node content versions protect resources independently of live node references. */
474
+ retainResources(scope, nodeId, version, content) {
475
+ this.db.prepare("DELETE FROM canvas_resource_references WHERE owner_kind='node' AND node_id=?").run(nodeId);
476
+ for (const link of resourceLinks(content)) {
477
+ if (!this.db.prepare('SELECT 1 FROM canvas_resources r JOIN canvas_resource_versions v ON v.resource_id=r.id WHERE r.id=? AND r.graph_id=? AND v.version=?').get(link.resourceId, scope.graphId, link.version))
478
+ throw new ServiceError('INPUT_BLOCKED', '资源不属于本图或版本不存在。');
479
+ this.db.prepare('INSERT OR IGNORE INTO node_resource_history(node_id,node_version,resource_id,resource_version) VALUES(?,?,?,?)').run(nodeId, version, link.resourceId, link.version);
480
+ this.db.prepare("INSERT INTO canvas_resource_references(id,resource_id,resource_version,graph_id,owner_kind,node_id) VALUES(?,?,?,?,'node',?)").run(randomUUID(), link.resourceId, link.version, scope.graphId, nodeId);
481
+ this.db.prepare("DELETE FROM canvas_resource_references WHERE graph_id=? AND resource_id=? AND resource_version=? AND owner_kind='graph'").run(scope.graphId, link.resourceId, link.version);
482
+ }
483
+ }
484
+ validateFileNode(scope, content) {
485
+ if (!content || typeof content !== 'object' || Array.isArray(content))
486
+ throw new ServiceError('INVALID_REQUEST', '文件节点正文无效。');
487
+ const data = content;
488
+ const source = data.source;
489
+ if (source !== undefined) {
490
+ if (data.resourceId !== undefined || data.resourceVersion !== undefined)
491
+ throw new ServiceError('INVALID_REQUEST', '项目文件来源与工作图资源来源必须互斥。');
492
+ if (!source || typeof source !== 'object' || Array.isArray(source) || typeof source.relativePath !== 'string')
493
+ throw new ServiceError('INVALID_REQUEST', '项目文件来源无效或不属于当前项目。');
494
+ if (source.kind === 'project-file-empty') {
495
+ if (Object.keys(source).some(key => !['kind', 'relativePath'].includes(key)))
496
+ throw new ServiceError('INVALID_REQUEST', '空引用来源无效。');
497
+ return;
498
+ }
499
+ if (source.kind !== 'project-file' || source.serviceId !== this.serviceId || source.projectId !== scope.projectId || !source.relativePath)
500
+ throw new ServiceError('INVALID_REQUEST', '项目文件来源无效或不属于当前项目。');
501
+ return;
502
+ }
503
+ if (data.resourceId === undefined && data.resourceVersion === undefined)
504
+ return;
505
+ if (typeof data.resourceId !== 'string' || !Number.isSafeInteger(data.resourceVersion) || Number(data.resourceVersion) < 1)
506
+ throw new ServiceError('INVALID_REQUEST', '文件节点资源版本无效。');
507
+ const row = this.db.prepare('SELECT b.bytes FROM canvas_resources r JOIN canvas_resource_versions v ON v.resource_id=r.id JOIN blobs b ON b.sha256=v.sha256 WHERE r.id=? AND r.graph_id=? AND v.version=?').get(data.resourceId, scope.graphId, Number(data.resourceVersion));
508
+ if (!row)
509
+ throw new ServiceError('INPUT_BLOCKED', '文件节点资源不存在。');
510
+ const bytes = Number(row['bytes']);
511
+ if (bytes > WORKGRAPH_UPLOAD_MAX_BYTES)
512
+ throw new ServiceError('PAYLOAD_TOO_LARGE', '文件节点最大支持 300 MB。');
513
+ if (data.bytes !== undefined && (!Number.isSafeInteger(data.bytes) || Number(data.bytes) < 0 || Number(data.bytes) !== bytes))
514
+ throw new ServiceError('INVALID_REQUEST', '文件节点资源大小无效。');
515
+ }
516
+ /** Trusted publication/import only. Public commands cannot create delivery edges/read-only nodes. */
517
+ insertNode(scope, node, trusted = false) {
518
+ identifier(node.id);
519
+ if (typeof node.type !== 'string' || !node.type || node.type.length > 128 || !Number.isSafeInteger(node.schemaVersion) || node.schemaVersion < 1 || node.contentVersion !== 1)
520
+ throw new ServiceError('INVALID_REQUEST', '节点类型或版本无效。');
521
+ if (node.readOnly && !trusted)
522
+ throw new ServiceError('INVALID_REQUEST', '客户端不能伪造服务只读产出。');
523
+ position(node.x);
524
+ position(node.y);
525
+ boundedContent(node.content);
526
+ if (node.width !== undefined)
527
+ nodeDimension(node.width);
528
+ if (node.height !== undefined)
529
+ nodeDimension(node.height);
530
+ if (node.type !== 'group' && node.memberIds !== undefined)
531
+ throw new ServiceError('INVALID_REQUEST', '仅分组可携带 memberIds。');
532
+ if (this.db.prepare('SELECT 1 FROM nodes WHERE id=?').get(node.id))
533
+ throw new ServiceError('CONFLICT', '节点标识已存在。');
534
+ if (node.type === 'group' && this.db.prepare('SELECT 1 FROM graph_groups WHERE id=?').get(node.id))
535
+ throw new ServiceError('CONFLICT', '分组标识已存在。');
536
+ const plugin = new PluginRegistry(this.db).inspect(node.type, node.schemaVersion, node.content);
537
+ if (node.content && typeof node.content === 'object' && !Array.isArray(node.content)) {
538
+ const source = node.content['source'];
539
+ if (source && typeof source === 'object' && !Array.isArray(source) && source['kind'] === 'project-file' && source['projectId'] !== scope.projectId)
540
+ throw new ServiceError('INVALID_REQUEST', '项目文件引用不属于当前项目。');
541
+ }
542
+ if (node.type === 'file' && node.schemaVersion === 1)
543
+ this.validateFileNode(scope, node.content);
544
+ if (node.type === 'group' && (plugin.state !== 'available' || node.readOnly))
545
+ throw new ServiceError('INVALID_REQUEST', '分组必须使用内置 schema 1,仅允许标题和布局。');
546
+ if (plugin.state === 'invalid' && !trusted)
547
+ throw new ServiceError('INVALID_REQUEST', '节点正文不符合资源契约。');
548
+ const known = plugin.state === 'available';
549
+ const reason = node.readOnly ? 'original' : !known ? 'missing_plugin' : 'none';
550
+ this.db.prepare('INSERT INTO nodes(id,graph_id,type,schema_version,current_version,x,y,read_only,read_only_reason,created_at,creation_order) VALUES(?,?,?,?,1,?,?,?,?,?,(SELECT COALESCE(MAX(creation_order),0)+1 FROM nodes))').run(node.id, scope.graphId, node.type, node.schemaVersion, node.x, node.y, Number(node.readOnly || !known), reason, Date.now());
551
+ this.db.prepare('UPDATE nodes SET width=?,height=? WHERE id=?').run(node.width ?? null, node.height ?? null, node.id);
552
+ this.db.prepare('INSERT INTO node_versions(node_id,version,content) VALUES(?,1,?)').run(node.id, canonicalJson(node.content));
553
+ this.retainResources(scope, node.id, 1, node.content);
554
+ if (node.type === 'group') {
555
+ const name = node.content['title'] ?? '分组';
556
+ title(name);
557
+ this.db.prepare('INSERT INTO graph_groups(id,graph_id,run_id,title) VALUES(?,?,NULL,?)').run(node.id, scope.graphId, name);
558
+ this.setGroupMembers(scope, node.id, node.memberIds ?? []);
559
+ }
560
+ }
561
+ validateEdge(scope, edge, trusted = false) {
562
+ identifier(edge.id);
563
+ if (!['reference', 'execution', 'delivery'].includes(edge.kind) || edge.sourceId === edge.targetId)
564
+ throw new ServiceError('INVALID_EDGE', '无效连线。');
565
+ identifier(edge.sourceId);
566
+ identifier(edge.targetId);
567
+ if (this.db.prepare('SELECT 1 FROM edges WHERE id=? OR (graph_id=? AND source_id=? AND target_id=?)').get(edge.id, scope.graphId, edge.sourceId, edge.targetId))
568
+ throw new ServiceError('CONFLICT', '连线已存在。');
569
+ const source = this.node(scope, edge.sourceId), target = this.node(scope, edge.targetId);
570
+ if (source['type'] === 'group' || target['type'] === 'group')
571
+ throw new ServiceError('INVALID_EDGE', '纯布局分组不能作为依赖边端点。');
572
+ if (edge.kind === 'delivery') {
573
+ if (!trusted || source['type'] !== 'execution' || !target['read_only'] || ['execution', 'group', 'preview'].includes(String(target['type'])))
574
+ throw new ServiceError('INVALID_EDGE', '交付边只能由服务发布。');
575
+ return;
576
+ }
577
+ if (edge.kind === 'execution') {
578
+ if (source['type'] !== 'execution' || target['type'] !== 'execution')
579
+ throw new ServiceError('INVALID_EDGE', '串联边只能连接执行节点。');
580
+ const dependencies = this.db.prepare("SELECT source_id,target_id FROM edges WHERE graph_id=? AND kind='execution'").all(scope.graphId).map(row => ({ sourceId: String(row['source_id']), targetId: String(row['target_id']), kind: 'execution' }));
581
+ if (!executionOrder([...dependencies, edge]))
582
+ throw new ServiceError('INVALID_EDGE', '不能创建串联连线:该连接会形成环。');
583
+ this.assertNodeEditable(edge.targetId);
584
+ return;
585
+ }
586
+ const previewError = previewEdgeError(String(source['type']), String(target['type']), Number(this.db.prepare('SELECT COUNT(*) AS n FROM edges WHERE target_id=?').get(edge.targetId)['n']));
587
+ if (previewError)
588
+ throw new ServiceError('INVALID_EDGE', previewError);
589
+ if (source['type'] === 'execution')
590
+ throw new ServiceError('INVALID_EDGE', '执行节点不能作为内容前驱,请连接输出产物。');
591
+ if (['execution', 'text', 'image', 'document', 'video', 'file'].includes(String(target['type'])) || !BUILTINS.has(String(target['type']))) {
592
+ const count = Number(this.db.prepare("SELECT COUNT(*) AS n FROM edges WHERE target_id=? AND kind='reference'").get(edge.targetId)['n']);
593
+ if (count >= 8)
594
+ throw new ServiceError('INVALID_EDGE', '直接前驱最多 8 个。');
595
+ }
596
+ // Input topology is frozen for both execution and generation. Generation
597
+ // body edits remain allowed and publish through the candidate CAS mechanism.
598
+ this.assertNodeEditable(edge.targetId);
599
+ }
600
+ command(command, principal = 'local') {
601
+ if (!Array.isArray(command.operations) || !command.operations.length || command.operations.length > 1000)
602
+ throw new ServiceError('INVALID_REQUEST', '命令需要 1~1000 个操作。');
603
+ for (const op of command.operations) {
604
+ if (!op || typeof op !== 'object' || typeof op.type !== 'string')
605
+ throw new ServiceError('INVALID_REQUEST', '无效图操作。');
606
+ if (op.type === 'node.create' && (!op.node || typeof op.node !== 'object'))
607
+ throw new ServiceError('INVALID_REQUEST', '缺少节点。');
608
+ if (op.type === 'node.project-file.associate' && (!['text', 'image', 'file'].includes(op.nodeType) || !Number.isSafeInteger(op.expectedContentVersion)))
609
+ throw new ServiceError('INVALID_REQUEST', '空引用关联参数无效。');
610
+ if (op.type === 'node.delete' && op.retainResourcesForUndo !== undefined && typeof op.retainResourcesForUndo !== 'boolean')
611
+ throw new ServiceError('INVALID_REQUEST', '撤销资源保留标记必须是布尔值。');
612
+ if (op.type === 'edge.create' && (!op.edge || typeof op.edge !== 'object'))
613
+ throw new ServiceError('INVALID_REQUEST', '缺少连线。');
614
+ if (op.type === 'layout.move' && Array.isArray(op.positions) && op.positions.some(point => !point || typeof point !== 'object'))
615
+ throw new ServiceError('INVALID_REQUEST', '无效布局点。');
616
+ }
617
+ const lifecycle = command.operations.some(op => ['graph.archive', 'graph.trash'].includes(op.type));
618
+ if (lifecycle && command.operations.length !== 1)
619
+ throw new ServiceError('INVALID_REQUEST', '生命周期转换必须单独提交。');
620
+ return this.repo.idempotent(principal + ':graph.command:' + command.graphId, command.idempotencyKey, command, () => {
621
+ const graph = lifecycle ? this.scope(command) : this.writable(command);
622
+ const historyBefore = this.history.capture(command);
623
+ if (lifecycle)
624
+ assertProjectWritable(this.db, command.projectId);
625
+ if (command.operations.some(op => op.type === 'node.delete' || op.type === 'edge.create' || op.type === 'edge.delete'))
626
+ this.assertChainTopologyEditable(command, command.operations);
627
+ const types = new Map(this.db.prepare('SELECT id,type FROM nodes WHERE graph_id=? AND deleted=0').all(command.graphId).map(row => [String(row['id']), String(row['type'])]));
628
+ for (const op of command.operations)
629
+ if (op.type === 'node.create')
630
+ types.set(op.node.id, op.node.type);
631
+ const layoutOnly = (op) => ['layout.move', 'layout.resize', 'group.members', 'group.rename', 'graph.rename'].includes(op.type) || (op.type === 'node.create' && op.node.type === 'group') || (op.type === 'node.delete' && types.get(op.nodeId) === 'group');
632
+ const execution = command.operations.some(op => !layoutOnly(op));
633
+ const layout = command.operations.some(op => layoutOnly(op) || op.type === 'node.create' || op.type === 'node.delete' || op.type === 'execution.output.restore');
634
+ if ((execution && graph['execution_revision'] !== command.expectedExecutionRevision) || (layout && graph['layout_revision'] !== command.expectedLayoutRevision))
635
+ throw new ServiceError('REVISION_CONFLICT', '工作图版本已变化,请读取最新快照。');
636
+ // The entire command is atomic: delivery documents may be deleted before or
637
+ // after their execution node, but every remaining hard target must be included.
638
+ const deleting = new Set(command.operations.flatMap(op => op.type === 'node.delete' ? [op.nodeId] : []));
639
+ for (const op of command.operations) {
640
+ if (op.type === 'node.content') {
641
+ const target = this.node(command, op.nodeId);
642
+ if (target['type'] === 'group')
643
+ throw new ServiceError('INVALID_REQUEST', '分组无正文,请使用 group.rename 或布局命令。');
644
+ this.assertContentEditable(target, op.content);
645
+ if (!target['read_only'])
646
+ this.validateContent(target, op.content);
647
+ if (target['type'] === 'file')
648
+ this.validateFileNode(command, op.content);
649
+ }
650
+ if (op.type === 'node.project-file.associate') {
651
+ const target = this.node(command, op.nodeId);
652
+ const old = JSON.parse(String(this.db.prepare('SELECT content FROM node_versions WHERE node_id=? AND version=?').get(op.nodeId, Number(target['current_version']))['content']));
653
+ const oldSource = old && typeof old === 'object' && !Array.isArray(old) ? old['source'] : null;
654
+ const nextSource = op.content && typeof op.content === 'object' && !Array.isArray(op.content) ? op.content['source'] : null;
655
+ if (target['read_only'] || !oldSource || typeof oldSource !== 'object' || Array.isArray(oldSource) || !nextSource || typeof nextSource !== 'object' || Array.isArray(nextSource))
656
+ throw new ServiceError('INVALID_REQUEST', '项目文件引用转换无效。');
657
+ const oldKind = oldSource['kind'], nextKind = nextSource['kind'];
658
+ if (!((oldKind === 'project-file-empty' && nextKind === 'project-file') || (oldKind === 'project-file' && nextKind === 'project-file-empty')))
659
+ throw new ServiceError('INVALID_REQUEST', '项目文件引用只能在空引用和已关联状态之间转换。');
660
+ this.assertNodeEditable(op.nodeId);
661
+ this.validateContent({ ...target, type: op.nodeType, schema_version: 1 }, op.content);
662
+ if (op.nodeType === 'file')
663
+ this.validateFileNode(command, op.content);
664
+ }
665
+ switch (op.type) {
666
+ case 'execution.output.restore':
667
+ this.restoreOutput(command, op);
668
+ break;
669
+ case 'node.create':
670
+ this.insertNode(command, op.node);
671
+ break;
672
+ case 'node.content': {
673
+ const node = this.node(command, op.nodeId);
674
+ if (node['read_only'])
675
+ throw new ServiceError('NODE_LOCKED', '原始交付或未知插件节点只读,请独立复制后编辑。');
676
+ if (node['current_version'] !== op.expectedContentVersion)
677
+ throw new ServiceError('REVISION_CONFLICT', '节点正文已变化。');
678
+ boundedContent(op.content);
679
+ const version = op.expectedContentVersion + 1;
680
+ this.db.prepare('INSERT INTO node_versions(node_id,version,content) VALUES(?,?,?)').run(op.nodeId, version, canonicalJson(op.content));
681
+ this.db.prepare('UPDATE nodes SET current_version=? WHERE id=?').run(version, op.nodeId);
682
+ this.retainResources(command, op.nodeId, version, op.content);
683
+ break;
684
+ }
685
+ case 'node.project-file.associate': {
686
+ const node = this.node(command, op.nodeId);
687
+ if (node['current_version'] !== op.expectedContentVersion)
688
+ throw new ServiceError('REVISION_CONFLICT', '空引用节点已变化。');
689
+ boundedContent(op.content);
690
+ const version = op.expectedContentVersion + 1;
691
+ this.db.prepare('INSERT INTO node_versions(node_id,version,content) VALUES(?,?,?)').run(op.nodeId, version, canonicalJson(op.content));
692
+ this.db.prepare('UPDATE nodes SET type=?,schema_version=1,current_version=? WHERE id=?').run(op.nodeType, version, op.nodeId);
693
+ this.retainResources(command, op.nodeId, version, op.content);
694
+ break;
695
+ }
696
+ case 'node.delete': {
697
+ const target = this.node(command, op.nodeId);
698
+ const output = this.db.prepare('SELECT execution_node_id FROM execution_outputs WHERE node_id=?').get(op.nodeId);
699
+ if (target['type'] === 'group')
700
+ this.group(command, op.nodeId);
701
+ else
702
+ this.assertLayoutEditable(command, op.nodeId);
703
+ for (const edge of this.db.prepare('SELECT target_id FROM edges WHERE source_id=?').all(op.nodeId)) {
704
+ this.assertNodeEditable(String(edge['target_id']));
705
+ }
706
+ if (this.db.prepare("SELECT target_id FROM edges WHERE source_id=? AND kind='delivery'").all(op.nodeId).some(edge => !deleting.has(String(edge['target_id']))))
707
+ throw new ServiceError('INVALID_EDGE', '请先隐藏或同时删除此执行节点的全部输出产物。');
708
+ if (target['type'] === 'group') {
709
+ this.db.prepare('DELETE FROM group_members WHERE group_id=?').run(op.nodeId);
710
+ this.db.prepare('DELETE FROM graph_groups WHERE id=? AND graph_id=? AND run_id IS NULL').run(op.nodeId, command.graphId);
711
+ }
712
+ this.db.prepare('DELETE FROM edges WHERE source_id=? OR target_id=?').run(op.nodeId, op.nodeId);
713
+ this.db.prepare('DELETE FROM group_members WHERE node_id=?').run(op.nodeId);
714
+ this.db.prepare("DELETE FROM canvas_resource_references WHERE owner_kind='node' AND node_id=?").run(op.nodeId);
715
+ // Browser history owns no file bytes. Keep exact versions for its
716
+ // bounded undo window; normal deletion retains immediate GC behavior.
717
+ const undoExpiresAt = op.retainResourcesForUndo && !target['read_only'] ? Date.now() + 24 * 60 * 60 * 1000 : null;
718
+ if (undoExpiresAt === null && !output)
719
+ this.db.prepare('DELETE FROM node_resource_history WHERE node_id=?').run(op.nodeId);
720
+ this.db.prepare('UPDATE nodes SET deleted=1,undo_expires_at=? WHERE id=?').run(undoExpiresAt, op.nodeId);
721
+ if (target['type'] === 'execution')
722
+ this.db.prepare('DELETE FROM execution_outputs WHERE execution_node_id=?').run(op.nodeId);
723
+ break;
724
+ }
725
+ case 'edge.create':
726
+ this.validateEdge(command, op.edge);
727
+ this.db.prepare('INSERT INTO edges(id,graph_id,source_id,target_id,kind) VALUES(?,?,?,?,?)').run(op.edge.id, command.graphId, op.edge.sourceId, op.edge.targetId, op.edge.kind);
728
+ break;
729
+ case 'edge.delete': {
730
+ const edge = this.db.prepare('SELECT * FROM edges WHERE id=? AND graph_id=?').get(op.edgeId, command.graphId);
731
+ if (!edge)
732
+ throw new ServiceError('NOT_FOUND', '连线不存在。');
733
+ if (edge['kind'] === 'delivery' && !deleting.has(String(edge['target_id'])))
734
+ throw new ServiceError('INVALID_EDGE', '任务到输出产物的连线只能随产物隐藏或删除。');
735
+ this.assertNodeEditable(String(edge['target_id']));
736
+ this.db.prepare('DELETE FROM edges WHERE id=?').run(op.edgeId);
737
+ break;
738
+ }
739
+ case 'layout.move':
740
+ this.move(command, op.positions);
741
+ break;
742
+ case 'layout.resize':
743
+ this.resize(command, op.sizes);
744
+ break;
745
+ case 'group.members':
746
+ this.setGroupMembers(command, op.groupId, op.memberIds);
747
+ break;
748
+ case 'group.rename':
749
+ title(op.title);
750
+ this.group(command, op.groupId);
751
+ this.db.prepare('UPDATE graph_groups SET title=? WHERE id=? AND graph_id=? AND run_id IS NULL').run(op.title, op.groupId, command.graphId);
752
+ break;
753
+ case 'graph.rename':
754
+ title(op.title);
755
+ this.db.prepare('UPDATE graphs SET title=? WHERE id=?').run(op.title, command.graphId);
756
+ break;
757
+ case 'graph.archive':
758
+ case 'graph.trash':
759
+ this.lifecycle(command, op);
760
+ break;
761
+ default: throw new ServiceError('INVALID_REQUEST', '不支持的图命令。');
762
+ }
763
+ }
764
+ this.db.prepare('UPDATE graphs SET execution_revision=execution_revision+?,layout_revision=layout_revision+? WHERE id=?').run(Number(execution), Number(layout), command.graphId);
765
+ this.history.record(command, historyBefore, lifecycle);
766
+ this.event(command);
767
+ return this.snapshot(command);
768
+ });
769
+ }
770
+ }
771
+ //# sourceMappingURL=graphs.js.map