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.
- package/README.md +64 -0
- package/dist/api-image-agent.d.ts +32 -0
- package/dist/api-image-agent.js +132 -0
- package/dist/api-image-output.d.ts +8 -0
- package/dist/api-image-output.js +41 -0
- package/dist/api.d.ts +35 -0
- package/dist/api.js +153 -0
- package/dist/auth.d.ts +58 -0
- package/dist/auth.js +273 -0
- package/dist/backend/adapter.d.ts +35 -0
- package/dist/backend/adapter.js +439 -0
- package/dist/backend/capabilities.d.ts +23 -0
- package/dist/backend/capabilities.js +75 -0
- package/dist/backend/diagnostics.d.ts +9 -0
- package/dist/backend/diagnostics.js +80 -0
- package/dist/backend/index.d.ts +6 -0
- package/dist/backend/index.js +7 -0
- package/dist/backend/models.d.ts +15 -0
- package/dist/backend/models.js +43 -0
- package/dist/backend/paths.d.ts +6 -0
- package/dist/backend/paths.js +69 -0
- package/dist/backend/stdio.d.ts +66 -0
- package/dist/backend/stdio.js +172 -0
- package/dist/backend/types.d.ts +90 -0
- package/dist/backend/types.js +11 -0
- package/dist/backup-api.d.ts +10 -0
- package/dist/backup-api.js +40 -0
- package/dist/blob-store.d.ts +40 -0
- package/dist/blob-store.js +218 -0
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.js +63 -0
- package/dist/cli-output.d.ts +4 -0
- package/dist/cli-output.js +130 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -0
- package/dist/client-pairing.d.ts +8 -0
- package/dist/client-pairing.js +30 -0
- package/dist/directories.d.ts +14 -0
- package/dist/directories.js +34 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +7 -0
- package/dist/events.d.ts +22 -0
- package/dist/events.js +155 -0
- package/dist/execution-chains.d.ts +24 -0
- package/dist/execution-chains.js +231 -0
- package/dist/execution-settings.d.ts +10 -0
- package/dist/execution-settings.js +38 -0
- package/dist/graph-document-history.d.ts +43 -0
- package/dist/graph-document-history.js +275 -0
- package/dist/graph-files.d.ts +14 -0
- package/dist/graph-files.js +59 -0
- package/dist/graph-transfer.d.ts +53 -0
- package/dist/graph-transfer.js +501 -0
- package/dist/graphs.d.ts +68 -0
- package/dist/graphs.js +771 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +61 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +42 -0
- package/dist/image-credentials.d.ts +11 -0
- package/dist/image-credentials.js +130 -0
- package/dist/image-providers.d.ts +51 -0
- package/dist/image-providers.js +271 -0
- package/dist/input-materialization.d.ts +17 -0
- package/dist/input-materialization.js +95 -0
- package/dist/inputs.d.ts +74 -0
- package/dist/inputs.js +301 -0
- package/dist/local-control.d.ts +13 -0
- package/dist/local-control.js +37 -0
- package/dist/network.d.ts +11 -0
- package/dist/network.js +48 -0
- package/dist/openai-image-driver.d.ts +42 -0
- package/dist/openai-image-driver.js +368 -0
- package/dist/operations/backups.d.ts +40 -0
- package/dist/operations/backups.js +391 -0
- package/dist/operations/capacity.d.ts +7 -0
- package/dist/operations/capacity.js +35 -0
- package/dist/operations/cli-context.d.ts +7 -0
- package/dist/operations/cli-context.js +58 -0
- package/dist/operations/daemon.d.ts +36 -0
- package/dist/operations/daemon.js +159 -0
- package/dist/operations/files.d.ts +12 -0
- package/dist/operations/files.js +84 -0
- package/dist/operations/lifecycle.d.ts +28 -0
- package/dist/operations/lifecycle.js +155 -0
- package/dist/operations/local-admin.d.ts +6 -0
- package/dist/operations/local-admin.js +38 -0
- package/dist/operations/logs.d.ts +16 -0
- package/dist/operations/logs.js +37 -0
- package/dist/operations/plugins.d.ts +10 -0
- package/dist/operations/plugins.js +11 -0
- package/dist/operations/snapshot-worker.d.ts +1 -0
- package/dist/operations/snapshot-worker.js +18 -0
- package/dist/operations/snapshot.d.ts +1 -0
- package/dist/operations/snapshot.js +14 -0
- package/dist/operations/update.d.ts +26 -0
- package/dist/operations/update.js +216 -0
- package/dist/persistence/asset-business-schema.d.ts +2 -0
- package/dist/persistence/asset-business-schema.js +36 -0
- package/dist/persistence/database.d.ts +7 -0
- package/dist/persistence/database.js +91 -0
- package/dist/persistence/plugin-schema.d.ts +9 -0
- package/dist/persistence/plugin-schema.js +38 -0
- package/dist/persistence/repositories.d.ts +20 -0
- package/dist/persistence/repositories.js +106 -0
- package/dist/persistence/resource-schema.d.ts +2 -0
- package/dist/persistence/resource-schema.js +44 -0
- package/dist/persistence/schema.d.ts +6 -0
- package/dist/persistence/schema.js +236 -0
- package/dist/persistence/workflow-schema.d.ts +2 -0
- package/dist/persistence/workflow-schema.js +19 -0
- package/dist/plugin-api.d.ts +10 -0
- package/dist/plugin-api.js +40 -0
- package/dist/plugin-management.d.ts +36 -0
- package/dist/plugin-management.js +112 -0
- package/dist/plugins.d.ts +55 -0
- package/dist/plugins.js +239 -0
- package/dist/project-discovery.d.ts +3 -0
- package/dist/project-discovery.js +57 -0
- package/dist/project-files-api.d.ts +16 -0
- package/dist/project-files-api.js +243 -0
- package/dist/project-files.d.ts +97 -0
- package/dist/project-files.js +557 -0
- package/dist/project-lock.d.ts +4 -0
- package/dist/project-lock.js +35 -0
- package/dist/project-probe-worker.d.ts +1 -0
- package/dist/project-probe-worker.js +17 -0
- package/dist/project-probe.d.ts +22 -0
- package/dist/project-probe.js +67 -0
- package/dist/projects.d.ts +29 -0
- package/dist/projects.js +219 -0
- package/dist/publication.d.ts +31 -0
- package/dist/publication.js +642 -0
- package/dist/resource-api.d.ts +15 -0
- package/dist/resource-api.js +256 -0
- package/dist/resources-orphans.d.ts +41 -0
- package/dist/resources-orphans.js +235 -0
- package/dist/resources.d.ts +246 -0
- package/dist/resources.js +714 -0
- package/dist/run-api.d.ts +10 -0
- package/dist/run-api.js +308 -0
- package/dist/run-context.d.ts +54 -0
- package/dist/run-context.js +137 -0
- package/dist/runs.d.ts +82 -0
- package/dist/runs.js +506 -0
- package/dist/runtime-backend.d.ts +24 -0
- package/dist/runtime-backend.js +214 -0
- package/dist/runtime.d.ts +97 -0
- package/dist/runtime.js +553 -0
- package/dist/scheduler.d.ts +85 -0
- package/dist/scheduler.js +344 -0
- package/dist/service.d.ts +29 -0
- package/dist/service.js +515 -0
- package/dist/skills-api.d.ts +18 -0
- package/dist/skills-api.js +120 -0
- package/dist/storage-api.d.ts +21 -0
- package/dist/storage-api.js +92 -0
- package/dist/transfer-api.d.ts +10 -0
- package/dist/transfer-api.js +60 -0
- package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
- package/dist/vendor/protocol/contracts.typecheck.js +35 -0
- package/dist/vendor/protocol/execution-chain.d.ts +6 -0
- package/dist/vendor/protocol/execution-chain.js +26 -0
- package/dist/vendor/protocol/graph-binary.d.ts +15 -0
- package/dist/vendor/protocol/graph-binary.js +121 -0
- package/dist/vendor/protocol/graph-title.d.ts +2 -0
- package/dist/vendor/protocol/graph-title.js +23 -0
- package/dist/vendor/protocol/index.d.ts +617 -0
- package/dist/vendor/protocol/index.js +20 -0
- package/dist/vendor/protocol/pairing.d.ts +29 -0
- package/dist/vendor/protocol/pairing.js +71 -0
- package/dist/vendor/protocol/preview.d.ts +2 -0
- package/dist/vendor/protocol/preview.js +9 -0
- package/dist/vendor/protocol/resource-mime.d.ts +2 -0
- package/dist/vendor/protocol/resource-mime.js +35 -0
- package/dist/video-thumbnail.d.ts +2 -0
- package/dist/video-thumbnail.js +34 -0
- package/package.json +9 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { inflateSync } from 'node:zlib';
|
|
6
|
+
import { hashBytes, mimeMatchesBytes, sniffMime, supportsText, validMime } from './blob-store.js';
|
|
7
|
+
import { runDirectories } from './directories.js';
|
|
8
|
+
import { ServiceError } from './errors.js';
|
|
9
|
+
import { Graphs } from './graphs.js';
|
|
10
|
+
import { atomic } from './persistence/database.js';
|
|
11
|
+
import { canonicalJson } from './persistence/repositories.js';
|
|
12
|
+
import { Resources } from './resources.js';
|
|
13
|
+
import { Runs } from './runs.js';
|
|
14
|
+
import { ProjectFiles, classifyProjectFile } from './project-files.js';
|
|
15
|
+
function invalid(message) { throw new ServiceError('INVALID_REQUEST', message); }
|
|
16
|
+
function conflict(message) { throw new ServiceError('REVISION_CONFLICT', message); }
|
|
17
|
+
const MAX_TOTAL = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_TEXT = 1024 * 1024;
|
|
19
|
+
const MAX_MANIFEST = 64 * 1024;
|
|
20
|
+
const IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
|
21
|
+
const imageMime = (mime) => IMAGE_MIMES.has(mime);
|
|
22
|
+
// Binary project outputs remain live file references, not in-memory resources.
|
|
23
|
+
const streamedProjectFile = (role, mime) => role === 'project-file' && !supportsText(mime) && !imageMime(mime);
|
|
24
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
function generatedTextTitle(text) {
|
|
26
|
+
const firstLine = text.split(/\r?\n/).find(line => line.trim())?.trim() ?? '';
|
|
27
|
+
const heading = firstLine.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
|
|
28
|
+
const title = (heading?.[1] ?? firstLine).trim();
|
|
29
|
+
// Graphs limits node titles to 1024 UTF-16 code units. Keep a generated
|
|
30
|
+
// heading within that bound without splitting a surrogate pair.
|
|
31
|
+
const bounded = title.slice(0, 1024);
|
|
32
|
+
return /[\uD800-\uDBFF]$/.test(bounded) ? bounded.slice(0, -1) : bounded;
|
|
33
|
+
}
|
|
34
|
+
const DEFAULT_EXECUTION_TITLES = new Set(['执行任务', 'Execution task']);
|
|
35
|
+
function executionTitle(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (typeof value !== 'string' || !value.trim() || value !== value.trim() || value.length > 80 || [...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
|
|
39
|
+
invalid('executionTitle must be a single concise line of at most 80 characters');
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function promptExecutionTitle(prompt) {
|
|
43
|
+
const line = prompt.split(String.fromCharCode(10)).find(part => part.trim())?.trim() ?? '';
|
|
44
|
+
const first = line.replace(/^#+ /, '').split(/[。!?.!?]/, 1)[0].trim();
|
|
45
|
+
const title = first.slice(0, 80).trim();
|
|
46
|
+
return title && !DEFAULT_EXECUTION_TITLES.has(title) ? title : undefined;
|
|
47
|
+
}
|
|
48
|
+
function generatedTextContent(current, text, output, title = generatedTextTitle(text)) {
|
|
49
|
+
if (!object(current))
|
|
50
|
+
return invalid('Text generation target requires editable text content');
|
|
51
|
+
if (!title.trim() || title.length > 1024)
|
|
52
|
+
invalid('Generated node title must be 1..1024 characters');
|
|
53
|
+
const next = { ...current, title, text };
|
|
54
|
+
// Repair nodes written by the old publication path, which embedded output-file
|
|
55
|
+
// metadata into an ordinary text node and made the UI treat it as a resource.
|
|
56
|
+
for (const key of ['resourceId', 'resourceVersion', 'mime', 'outputKey', 'runId', 'outputs'])
|
|
57
|
+
delete next[key];
|
|
58
|
+
delete next.generatedOutput;
|
|
59
|
+
if (output !== undefined)
|
|
60
|
+
next.generatedOutput = output;
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
function generatedImageContent(current, output) {
|
|
64
|
+
if (!object(current) || !object(output))
|
|
65
|
+
invalid('Image generation target requires editable image content');
|
|
66
|
+
// The new resource replaces the old media, not the node's generation inputs.
|
|
67
|
+
return { ...current, ...output };
|
|
68
|
+
}
|
|
69
|
+
function manifest(value, version) {
|
|
70
|
+
if (!object(value) || !Array.isArray(value.outputs) || !value.outputs.length || value.outputs.length > 33)
|
|
71
|
+
return invalid('Expected 1..33 explicit outputs');
|
|
72
|
+
if (version === 2 ? value.version !== 2 : value.version !== undefined)
|
|
73
|
+
invalid(version === 2 ? 'Execution publication requires manifest version 2' : 'Legacy publication does not accept manifest version 2');
|
|
74
|
+
if (version === 1 && value.executionTitle !== undefined)
|
|
75
|
+
invalid('Only execution publication accepts executionTitle');
|
|
76
|
+
const taskTitle = executionTitle(value.executionTitle);
|
|
77
|
+
const keys = new Set(), paths = new Set();
|
|
78
|
+
let total = 0, markdown = 0, deliveries = 0;
|
|
79
|
+
const outputs = value.outputs.map((item) => {
|
|
80
|
+
if (!object(item) || typeof item.outputKey !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(item.outputKey) || keys.has(item.outputKey))
|
|
81
|
+
return invalid('Invalid or duplicate outputKey');
|
|
82
|
+
const role = version === 2 && ['delivery-document', 'project-file', 'workgraph-node'].includes(String(item.role)) ? item.role : version === 2 ? invalid('Manifest version 2 requires an explicit output role') : undefined;
|
|
83
|
+
if (typeof item.path !== 'string' || !item.path || item.path.length > 512 || isAbsolute(item.path) || item.path.includes(String.fromCharCode(92)) || [...item.path].some(char => char.charCodeAt(0) < 32) || item.path.split('/').some(part => !part || part === '.' || part === '..') || paths.has((role ?? 'legacy') + ':' + item.path))
|
|
84
|
+
return invalid('Invalid or duplicate output path');
|
|
85
|
+
if (typeof item.mime !== 'string' || !validMime(item.mime))
|
|
86
|
+
return invalid('Invalid output MIME');
|
|
87
|
+
const mime = item.mime.toLowerCase();
|
|
88
|
+
if (!Number.isSafeInteger(item.bytes) || Number(item.bytes) < (role === 'project-file' ? 0 : 1))
|
|
89
|
+
return invalid('Invalid output byte count: ' + item.outputKey);
|
|
90
|
+
if (typeof item.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(item.sha256))
|
|
91
|
+
return invalid('Invalid output SHA-256: ' + item.outputKey);
|
|
92
|
+
const streaming = streamedProjectFile(role, mime);
|
|
93
|
+
if (!streaming && Number(item.bytes) > MAX_TOTAL)
|
|
94
|
+
invalid('In-memory output exceeds 64 MiB: ' + item.outputKey);
|
|
95
|
+
if (!streaming && !supportsText(mime) && !imageMime(mime) && Number(item.bytes) > 50 * 1024 * 1024)
|
|
96
|
+
return invalid('File output exceeds 50 MiB');
|
|
97
|
+
if (mime === 'text/markdown' && role !== 'project-file') {
|
|
98
|
+
markdown++;
|
|
99
|
+
if (Number(item.bytes) > MAX_TEXT || !item.path.toLowerCase().endsWith('.md'))
|
|
100
|
+
invalid('Markdown output must be a bounded .md file');
|
|
101
|
+
}
|
|
102
|
+
if (role === 'delivery-document') {
|
|
103
|
+
deliveries++;
|
|
104
|
+
if (mime !== 'text/markdown')
|
|
105
|
+
invalid('delivery-document must be Markdown');
|
|
106
|
+
}
|
|
107
|
+
let nodeType, title;
|
|
108
|
+
if (role === 'workgraph-node') {
|
|
109
|
+
if (!['text', 'document', 'image', 'file'].includes(String(item.nodeType)))
|
|
110
|
+
invalid('workgraph-node requires a supported nodeType');
|
|
111
|
+
nodeType = item.nodeType;
|
|
112
|
+
if (typeof item.title !== 'string' || !item.title.trim() || item.title.length > 1024)
|
|
113
|
+
invalid('workgraph-node requires a title of 1..1024 characters');
|
|
114
|
+
title = item.title;
|
|
115
|
+
if ((nodeType === 'text' || nodeType === 'document') && (!supportsText(mime) || Number(item.bytes) > MAX_TEXT))
|
|
116
|
+
invalid(nodeType + ' workgraph-node requires bounded text content');
|
|
117
|
+
if (nodeType === 'image' && !imageMime(mime) && mime !== 'image/svg+xml')
|
|
118
|
+
invalid('image workgraph-node requires an image MIME');
|
|
119
|
+
}
|
|
120
|
+
else if (item.nodeType !== undefined || item.title !== undefined)
|
|
121
|
+
invalid('Only workgraph-node accepts nodeType and title');
|
|
122
|
+
keys.add(item.outputKey);
|
|
123
|
+
paths.add((role ?? 'legacy') + ':' + item.path);
|
|
124
|
+
if (!streaming)
|
|
125
|
+
total += Number(item.bytes);
|
|
126
|
+
return { outputKey: item.outputKey, path: item.path, mime, bytes: Number(item.bytes), sha256: item.sha256, ...(role ? { role } : {}), ...(nodeType && title ? { nodeType, title } : {}) };
|
|
127
|
+
});
|
|
128
|
+
if ((version === 1 && markdown !== 1) || (version === 2 && deliveries !== 1) || total > MAX_TOTAL)
|
|
129
|
+
return invalid(version === 2 ? 'Exactly one delivery-document and at most 64 MiB required' : 'Exactly one Markdown output and at most 64 MiB required');
|
|
130
|
+
return { ...(version === 2 ? { version: 2 } : {}), ...(taskTitle ? { executionTitle: taskTitle } : {}), outputs };
|
|
131
|
+
}
|
|
132
|
+
/** Reject links in every component, then read only a bounded regular-file handle.
|
|
133
|
+
* Rechecking identity after reading also rejects replacement during preflight. */
|
|
134
|
+
async function readOutput(root, path, max) {
|
|
135
|
+
const target = resolve(root, path), rel = relative(root, target);
|
|
136
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
137
|
+
invalid('Output escaped run directory');
|
|
138
|
+
let current = root;
|
|
139
|
+
for (const part of ['', ...rel.split(sep)]) {
|
|
140
|
+
if (part)
|
|
141
|
+
current = join(current, part);
|
|
142
|
+
const stat = await lstat(current);
|
|
143
|
+
if (stat.isSymbolicLink() || (current !== target && !stat.isDirectory()))
|
|
144
|
+
invalid('Output symlinks are forbidden');
|
|
145
|
+
}
|
|
146
|
+
if (await realpath(target) !== target)
|
|
147
|
+
invalid('Output must have a canonical path');
|
|
148
|
+
const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
149
|
+
try {
|
|
150
|
+
const before = await file.stat();
|
|
151
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > max)
|
|
152
|
+
invalid('Output must be a bounded private regular file');
|
|
153
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
154
|
+
let offset = 0;
|
|
155
|
+
while (offset < bytes.length) {
|
|
156
|
+
const chunk = await file.read(bytes, offset, bytes.length - offset, offset);
|
|
157
|
+
if (!chunk.bytesRead)
|
|
158
|
+
break;
|
|
159
|
+
offset += chunk.bytesRead;
|
|
160
|
+
}
|
|
161
|
+
const after = await file.stat(), entry = await lstat(target);
|
|
162
|
+
if (offset !== before.size || after.size !== before.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || entry.ino !== before.ino || entry.dev !== before.dev || await realpath(target) !== target)
|
|
163
|
+
invalid('Output changed during preflight');
|
|
164
|
+
return bytes.subarray(0, offset);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await file.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function validateBytes(output, bytes) {
|
|
171
|
+
if (bytes.length !== output.bytes || hashBytes(bytes) !== output.sha256)
|
|
172
|
+
invalid('Output size/hash mismatch');
|
|
173
|
+
if (output.role === 'project-file' && bytes.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
const detected = sniffMime(bytes);
|
|
176
|
+
if (output.mime === 'text/markdown') {
|
|
177
|
+
if (detected !== 'text/plain' || !bytes.toString('utf8').trim())
|
|
178
|
+
invalid('Invalid Markdown bytes');
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
if (!mimeMatchesBytes(output.mime, detected))
|
|
182
|
+
invalid('Output MIME does not match bytes');
|
|
183
|
+
// Structural truncation checks supplement BlobStore MIME verification. These
|
|
184
|
+
// are validation fixtures, not evidence of real backend image generation.
|
|
185
|
+
if (detected === 'image/png') {
|
|
186
|
+
let offset = 8, end = false, rowBytes = 0, height = 0;
|
|
187
|
+
const compressed = [];
|
|
188
|
+
while (offset + 12 <= bytes.length) {
|
|
189
|
+
const size = bytes.readUInt32BE(offset), kind = bytes.toString('ascii', offset + 4, offset + 8);
|
|
190
|
+
if (size > bytes.length - offset - 12)
|
|
191
|
+
invalid('Truncated PNG');
|
|
192
|
+
let crc = 0xffffffff;
|
|
193
|
+
for (const byte of bytes.subarray(offset + 4, offset + 8 + size)) {
|
|
194
|
+
crc ^= byte;
|
|
195
|
+
for (let bit = 0; bit < 8; bit++)
|
|
196
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
197
|
+
}
|
|
198
|
+
if (((crc ^ 0xffffffff) >>> 0) !== bytes.readUInt32BE(offset + 8 + size))
|
|
199
|
+
invalid('Corrupt PNG chunk checksum');
|
|
200
|
+
if (offset === 8) {
|
|
201
|
+
if (kind !== 'IHDR' || size !== 13)
|
|
202
|
+
invalid('Invalid PNG header');
|
|
203
|
+
const width = bytes.readUInt32BE(offset + 8);
|
|
204
|
+
height = bytes.readUInt32BE(offset + 12);
|
|
205
|
+
const depth = bytes[offset + 16], color = bytes[offset + 17];
|
|
206
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[color];
|
|
207
|
+
if (!width || !height || width * height > 40000000 || !channels || ![1, 2, 4, 8, 16].includes(depth) || (color === 3 && depth === 16) || (![0, 3].includes(color) && depth < 8) || bytes[offset + 18] !== 0 || bytes[offset + 19] !== 0 || bytes[offset + 20] !== 0)
|
|
208
|
+
invalid('Unsupported PNG dimensions/encoding (non-interlaced required)');
|
|
209
|
+
rowBytes = Math.ceil(width * channels * depth / 8) + 1;
|
|
210
|
+
if (rowBytes * height > MAX_TOTAL)
|
|
211
|
+
invalid('Decoded PNG exceeds limit');
|
|
212
|
+
}
|
|
213
|
+
else if (kind === 'IHDR')
|
|
214
|
+
invalid('Duplicate PNG header');
|
|
215
|
+
if (kind === 'IDAT' && size > 0)
|
|
216
|
+
compressed.push(bytes.subarray(offset + 8, offset + 8 + size));
|
|
217
|
+
offset += size + 12;
|
|
218
|
+
if (kind === 'IEND') {
|
|
219
|
+
end = size === 0 && offset === bytes.length;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!compressed.length || !end)
|
|
224
|
+
invalid('Incomplete PNG');
|
|
225
|
+
let decoded;
|
|
226
|
+
try {
|
|
227
|
+
decoded = inflateSync(Buffer.concat(compressed), { maxOutputLength: rowBytes * height });
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return invalid('Invalid PNG compressed data');
|
|
231
|
+
}
|
|
232
|
+
if (decoded.length !== rowBytes * height)
|
|
233
|
+
invalid('PNG pixel data length mismatch');
|
|
234
|
+
for (let row = 0; row < height; row++)
|
|
235
|
+
if (decoded[row * rowBytes] > 4)
|
|
236
|
+
invalid('Invalid PNG row filter');
|
|
237
|
+
}
|
|
238
|
+
else if (detected === 'image/jpeg') {
|
|
239
|
+
if (bytes.length < 32 || !bytes.subarray(-2).equals(Buffer.from([255, 217])))
|
|
240
|
+
invalid('Incomplete JPEG');
|
|
241
|
+
let offset = 2, frame = false, scan = false;
|
|
242
|
+
while (offset < bytes.length - 2) {
|
|
243
|
+
if (bytes[offset++] !== 255)
|
|
244
|
+
invalid('Invalid JPEG marker');
|
|
245
|
+
while (bytes[offset] === 255)
|
|
246
|
+
offset++;
|
|
247
|
+
const marker = bytes[offset++];
|
|
248
|
+
if (offset + 2 > bytes.length)
|
|
249
|
+
invalid('Truncated JPEG segment');
|
|
250
|
+
const length = bytes.readUInt16BE(offset);
|
|
251
|
+
if (length < 2 || offset + length > bytes.length)
|
|
252
|
+
invalid('Invalid JPEG segment length');
|
|
253
|
+
if ([0xc0, 0xc1, 0xc2].includes(marker)) {
|
|
254
|
+
if (length < 8)
|
|
255
|
+
invalid('Invalid JPEG frame');
|
|
256
|
+
const height = bytes.readUInt16BE(offset + 3), width = bytes.readUInt16BE(offset + 5);
|
|
257
|
+
if (!width || !height || width * height > 40000000)
|
|
258
|
+
invalid('Invalid JPEG dimensions');
|
|
259
|
+
frame = true;
|
|
260
|
+
}
|
|
261
|
+
if (marker === 0xda) {
|
|
262
|
+
scan = offset + length < bytes.length - 2;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
offset += length;
|
|
266
|
+
}
|
|
267
|
+
if (!frame || !scan)
|
|
268
|
+
invalid('JPEG requires frame and scan data');
|
|
269
|
+
}
|
|
270
|
+
else if (detected === 'image/gif') {
|
|
271
|
+
if (bytes.length < 14 || bytes.at(-1) !== 59 || !bytes.readUInt16LE(6) || !bytes.readUInt16LE(8) || bytes.readUInt16LE(6) * bytes.readUInt16LE(8) > 40000000)
|
|
272
|
+
invalid('Incomplete/oversized GIF');
|
|
273
|
+
let offset = 13 + (bytes[10] & 128 ? 3 * (2 ** ((bytes[10] & 7) + 1)) : 0), frames = 0;
|
|
274
|
+
while (offset < bytes.length - 1) {
|
|
275
|
+
const marker = bytes[offset++];
|
|
276
|
+
if (marker === 0x21)
|
|
277
|
+
offset++;
|
|
278
|
+
else if (marker === 0x2c) {
|
|
279
|
+
if (offset + 10 >= bytes.length)
|
|
280
|
+
invalid('Truncated GIF frame');
|
|
281
|
+
const width = bytes.readUInt16LE(offset + 4), height = bytes.readUInt16LE(offset + 6), packed = bytes[offset + 8];
|
|
282
|
+
if (!width || !height || width * height > 40000000 || ++frames > 256)
|
|
283
|
+
invalid('GIF frame limit');
|
|
284
|
+
offset += 9 + (packed & 128 ? 3 * (2 ** ((packed & 7) + 1)) : 0);
|
|
285
|
+
if (bytes[offset] < 2 || bytes[offset] > 8)
|
|
286
|
+
invalid('Invalid GIF encoding');
|
|
287
|
+
offset++;
|
|
288
|
+
}
|
|
289
|
+
else
|
|
290
|
+
invalid('Invalid GIF block');
|
|
291
|
+
while (offset < bytes.length && bytes[offset] !== 0)
|
|
292
|
+
offset += 1 + bytes[offset];
|
|
293
|
+
if (offset >= bytes.length)
|
|
294
|
+
invalid('Truncated GIF block');
|
|
295
|
+
offset++;
|
|
296
|
+
}
|
|
297
|
+
if (!frames || offset !== bytes.length - 1)
|
|
298
|
+
invalid('GIF requires complete frame data');
|
|
299
|
+
}
|
|
300
|
+
else if (detected === 'image/webp') {
|
|
301
|
+
if (bytes.length < 30 || bytes.readUInt32LE(4) + 8 !== bytes.length)
|
|
302
|
+
invalid('Incomplete WebP');
|
|
303
|
+
let offset = 12, image = false;
|
|
304
|
+
while (offset + 8 <= bytes.length) {
|
|
305
|
+
const kind = bytes.toString('ascii', offset, offset + 4), length = bytes.readUInt32LE(offset + 4), start = offset + 8;
|
|
306
|
+
if (start + length > bytes.length)
|
|
307
|
+
invalid('Truncated WebP chunk');
|
|
308
|
+
let width = 0, height = 0;
|
|
309
|
+
if (kind === 'VP8 ' && length >= 10) {
|
|
310
|
+
if (!bytes.subarray(start + 3, start + 6).equals(Buffer.from([157, 1, 42])))
|
|
311
|
+
invalid('Invalid VP8 frame');
|
|
312
|
+
width = bytes.readUInt16LE(start + 6) & 0x3fff;
|
|
313
|
+
height = bytes.readUInt16LE(start + 8) & 0x3fff;
|
|
314
|
+
image = true;
|
|
315
|
+
}
|
|
316
|
+
else if (kind === 'VP8L' && length >= 5) {
|
|
317
|
+
if (bytes[start] !== 47)
|
|
318
|
+
invalid('Invalid VP8L frame');
|
|
319
|
+
const bits = bytes.readUInt32LE(start + 1);
|
|
320
|
+
width = (bits & 0x3fff) + 1;
|
|
321
|
+
height = ((bits >>> 14) & 0x3fff) + 1;
|
|
322
|
+
image = true;
|
|
323
|
+
}
|
|
324
|
+
else if (kind === 'VP8X' && length === 10) {
|
|
325
|
+
width = bytes.readUIntLE(start + 4, 3) + 1;
|
|
326
|
+
height = bytes.readUIntLE(start + 7, 3) + 1;
|
|
327
|
+
}
|
|
328
|
+
else if (['VP8 ', 'VP8L', 'VP8X', 'ANIM', 'ANMF'].includes(kind))
|
|
329
|
+
invalid('Unsupported WebP encoding');
|
|
330
|
+
if ((width || height) && (!width || !height || width * height > 40000000))
|
|
331
|
+
invalid('WebP dimension limit');
|
|
332
|
+
offset = start + length + (length & 1);
|
|
333
|
+
}
|
|
334
|
+
if (!image || offset !== bytes.length)
|
|
335
|
+
invalid('WebP requires complete image data');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Validate API image bytes with the same structural checks used at publication. */
|
|
340
|
+
export function validateImageOutput(bytes) {
|
|
341
|
+
const mime = sniffMime(bytes);
|
|
342
|
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(mime))
|
|
343
|
+
invalid('API returned an unsupported image MIME');
|
|
344
|
+
validateBytes({ outputKey: 'image', path: 'image', mime, bytes: bytes.length, sha256: hashBytes(bytes) }, bytes);
|
|
345
|
+
return mime;
|
|
346
|
+
}
|
|
347
|
+
/** File preparation never runs inside a transaction. Only this publisher may
|
|
348
|
+
* interpret bridge result.answer; natural-language completion is not parsed. */
|
|
349
|
+
export class Publication {
|
|
350
|
+
db;
|
|
351
|
+
dirs;
|
|
352
|
+
resources;
|
|
353
|
+
graphs;
|
|
354
|
+
runs;
|
|
355
|
+
constructor(db, dirs, resources, graphs, runs) {
|
|
356
|
+
this.db = db;
|
|
357
|
+
this.dirs = dirs;
|
|
358
|
+
this.resources = resources;
|
|
359
|
+
this.graphs = graphs;
|
|
360
|
+
this.runs = runs;
|
|
361
|
+
}
|
|
362
|
+
check(run, token) {
|
|
363
|
+
const actual = this.runs.get(run.id), runtime = this.runs.runtime(run.id);
|
|
364
|
+
if (actual.serviceId !== run.serviceId || actual.projectId !== run.projectId || actual.graphId !== run.graphId || actual.nodeId !== run.nodeId)
|
|
365
|
+
invalid('Run scope mismatch');
|
|
366
|
+
if (runtime.epoch !== token.epoch || runtime.revision !== token.revision)
|
|
367
|
+
conflict('Stale publication callback');
|
|
368
|
+
if (actual.status !== 'finalizing' || runtime.details.cancelRequested === true)
|
|
369
|
+
conflict('Run is not publishable');
|
|
370
|
+
return actual;
|
|
371
|
+
}
|
|
372
|
+
async publish(run, token) {
|
|
373
|
+
if (this.db.isTransaction)
|
|
374
|
+
throw new Error('Publication preparation must run outside a transaction');
|
|
375
|
+
const actual = this.runs.get(run.id);
|
|
376
|
+
if (actual.serviceId !== run.serviceId || actual.projectId !== run.projectId || actual.graphId !== run.graphId || actual.nodeId !== run.nodeId)
|
|
377
|
+
invalid('Run scope mismatch');
|
|
378
|
+
if (actual.status === 'succeeded')
|
|
379
|
+
return; // Completed atomic commit is its replay marker.
|
|
380
|
+
this.check(run, token);
|
|
381
|
+
const root = runDirectories(this.dirs, run.id).output;
|
|
382
|
+
const runtime = this.runs.runtime(run.id);
|
|
383
|
+
const publicationVersion = runtime.details.kind === 'execution' && runtime.details.publicationVersion === 2 ? 2 : 1;
|
|
384
|
+
const result = runtime.details.result;
|
|
385
|
+
const saved = this.db.prepare('SELECT * FROM publication_manifests WHERE run_id=?').get(run.id);
|
|
386
|
+
let selected;
|
|
387
|
+
let hostAnswer;
|
|
388
|
+
if (saved) {
|
|
389
|
+
if (hashBytes(Buffer.from(String(saved.manifest))) !== saved.sha256)
|
|
390
|
+
invalid('Corrupt persisted publication manifest');
|
|
391
|
+
selected = manifest(JSON.parse(String(saved.manifest)), publicationVersion);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
let raw;
|
|
395
|
+
try {
|
|
396
|
+
raw = await readOutput(root, 'manifest.json', MAX_MANIFEST);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error.code !== 'ENOENT')
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
if (raw)
|
|
403
|
+
selected = manifest(JSON.parse(raw.toString('utf8')), publicationVersion);
|
|
404
|
+
else {
|
|
405
|
+
if (!object(result) || typeof result.answer !== 'string' || !result.answer.trim() || Buffer.byteLength(result.answer) > MAX_TEXT)
|
|
406
|
+
invalid('Successful protocol response requires answer or explicit manifest');
|
|
407
|
+
hostAnswer = Buffer.from(result.answer, 'utf8');
|
|
408
|
+
selected = publicationVersion === 2
|
|
409
|
+
? { version: 2, outputs: [{ outputKey: 'answer', role: 'delivery-document', path: 'answer.md', mime: 'text/markdown', bytes: hostAnswer.length, sha256: hashBytes(hostAnswer) }] }
|
|
410
|
+
: { outputs: [{ outputKey: 'answer', path: 'answer.md', mime: 'text/markdown', bytes: hostAnswer.length, sha256: hashBytes(hostAnswer) }] };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// The app-server result crosses the JSON-RPC boundary as Unicode and is the
|
|
414
|
+
// authoritative user-facing completion. Use it for execution delivery docs
|
|
415
|
+
// so a Windows shell cannot silently replace non-ASCII text while writing
|
|
416
|
+
// delivery.md. Explicit project files and Work Graph node files still use
|
|
417
|
+
// their manifest-declared bytes. Rewrite the frozen manifest to describe the
|
|
418
|
+
// bytes that are actually published, which also keeps restart replay stable.
|
|
419
|
+
const executionAnswer = publicationVersion === 2 && runtime.details.kind === 'execution' && object(result) && typeof result.answer === 'string' && result.answer.trim() && Buffer.byteLength(result.answer) <= MAX_TEXT
|
|
420
|
+
? Buffer.from(result.answer, 'utf8')
|
|
421
|
+
: undefined;
|
|
422
|
+
if (executionAnswer)
|
|
423
|
+
selected = { ...selected, outputs: selected.outputs.map(output => output.role === 'delivery-document' ? { ...output, bytes: executionAnswer.length, sha256: hashBytes(executionAnswer) } : output) };
|
|
424
|
+
// On recovery an answer-only manifest is regenerated from the frozen bridge
|
|
425
|
+
// result, never from a new graph snapshot or guessed completion message.
|
|
426
|
+
const projectFiles = new ProjectFiles(this.db);
|
|
427
|
+
const prepared = [];
|
|
428
|
+
for (const output of selected.outputs) {
|
|
429
|
+
if (streamedProjectFile(output.role, output.mime)) {
|
|
430
|
+
const verified = await projectFiles.fingerprint(run.projectId, output.path, output.bytes);
|
|
431
|
+
if (verified.sha256 !== output.sha256)
|
|
432
|
+
invalid('Project output SHA-256 mismatch: ' + output.outputKey);
|
|
433
|
+
if (output.bytes && !mimeMatchesBytes(output.mime, sniffMime(verified.prefix)))
|
|
434
|
+
invalid('Output MIME does not match bytes');
|
|
435
|
+
prepared.push({ output, bytes: Buffer.alloc(0), changeToken: verified.changeToken });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
let bytes;
|
|
439
|
+
if (output.role === 'delivery-document' && executionAnswer)
|
|
440
|
+
bytes = executionAnswer;
|
|
441
|
+
else if (hostAnswer)
|
|
442
|
+
bytes = hostAnswer;
|
|
443
|
+
else if (output.role === 'project-file') {
|
|
444
|
+
const current = await projectFiles.readRange(run.projectId, output.path, undefined, Math.min(Math.max(output.bytes, 1), MAX_TOTAL));
|
|
445
|
+
bytes = current.bytes;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
try {
|
|
449
|
+
bytes = await readOutput(root, output.path, Math.min(output.bytes, MAX_TOTAL));
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error.code !== 'ENOENT' || selected.outputs.length !== 1 || output.outputKey !== 'answer' || output.path !== 'answer.md' || !object(result) || typeof result.answer !== 'string')
|
|
453
|
+
throw error;
|
|
454
|
+
bytes = Buffer.from(result.answer);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
validateBytes(output, bytes);
|
|
458
|
+
if (output.role === 'project-file') {
|
|
459
|
+
const observation = (await projectFiles.stat(run.projectId, [output.path]))[0];
|
|
460
|
+
if (observation.state !== 'available' || observation.bytes !== output.bytes)
|
|
461
|
+
invalid('Project output changed during publication preflight');
|
|
462
|
+
prepared.push({ output, bytes, ...(observation.changeToken === null ? {} : { changeToken: observation.changeToken }), ...(supportsText(output.mime) ? { text: bytes.toString('utf8') } : {}) });
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
const blob = await this.resources.prepareBytes(bytes, output.mime, output.sha256);
|
|
466
|
+
prepared.push({ output, bytes, blob, ...(supportsText(output.mime) ? { text: bytes.toString('utf8') } : {}) });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const serialized = canonicalJson(selected), digest = hashBytes(Buffer.from(serialized));
|
|
470
|
+
atomic(this.db, () => {
|
|
471
|
+
this.check(run, token);
|
|
472
|
+
this.db.prepare('INSERT OR IGNORE INTO publication_manifests(run_id,manifest,sha256) VALUES(?,?,?)').run(run.id, serialized, digest);
|
|
473
|
+
if (this.db.prepare('SELECT sha256 FROM publication_manifests WHERE run_id=?').get(run.id)?.sha256 !== digest)
|
|
474
|
+
conflict('Publication manifest already frozen');
|
|
475
|
+
});
|
|
476
|
+
atomic(this.db, () => {
|
|
477
|
+
this.check(run, token);
|
|
478
|
+
this.graphs.writable(run);
|
|
479
|
+
const runtime = this.runs.runtime(run.id);
|
|
480
|
+
const source = this.db.prepare('SELECT n.*,v.content AS node_content FROM nodes n JOIN node_versions v ON v.node_id=n.id AND v.version=n.current_version WHERE n.id=? AND n.graph_id=? AND n.deleted=0').get(run.nodeId, run.graphId);
|
|
481
|
+
if (!source)
|
|
482
|
+
invalid('Publication target no longer exists');
|
|
483
|
+
const kind = runtime.details.kind;
|
|
484
|
+
if (!['execution', 'text_generation', 'image_generation'].includes(String(kind)))
|
|
485
|
+
invalid('Unknown publication kind');
|
|
486
|
+
if ((kind === 'execution' && source.type !== 'execution') || (kind === 'text_generation' && source.type !== 'text') || (kind === 'image_generation' && source.type !== 'image'))
|
|
487
|
+
invalid('Publication target type changed');
|
|
488
|
+
if (kind === 'image_generation' && prepared.filter(item => imageMime(item.output.mime)).length !== 1)
|
|
489
|
+
invalid('Image generation requires exactly one validated image');
|
|
490
|
+
const artifacts = prepared.map(item => {
|
|
491
|
+
if (item.output.role === 'project-file') {
|
|
492
|
+
const name = basename(item.output.path);
|
|
493
|
+
const content = {
|
|
494
|
+
title: name, mime: item.output.mime, bytes: item.output.bytes, outputKey: item.output.outputKey, runId: run.id,
|
|
495
|
+
source: { kind: 'project-file', serviceId: this.graphs.serviceId, projectId: run.projectId, relativePath: item.output.path },
|
|
496
|
+
observation: { state: 'available', name, mime: item.output.mime, bytes: item.output.bytes, changeToken: item.changeToken ?? null },
|
|
497
|
+
...(item.text === undefined ? {} : { text: item.text }),
|
|
498
|
+
};
|
|
499
|
+
this.db.prepare('INSERT INTO project_file_outputs(run_id,output_key,project_id,relative_path,bytes,sha256) VALUES(?,?,?,?,?,?)').run(run.id, item.output.outputKey, run.projectId, item.output.path, item.output.bytes, item.output.sha256);
|
|
500
|
+
return { ...item, content };
|
|
501
|
+
}
|
|
502
|
+
const resourceName = basename(item.output.path);
|
|
503
|
+
const title = item.output.title ?? resourceName;
|
|
504
|
+
const created = this.resources.createCanvasFromPrepared(run, item.blob, resourceName);
|
|
505
|
+
this.db.prepare('INSERT INTO canvas_outputs(run_id,graph_id,output_key,resource_id,resource_version) VALUES(?,?,?,?,1)').run(run.id, run.graphId, item.output.outputKey, created.resource.id);
|
|
506
|
+
const resourceOutput = { resourceId: created.resource.id, resourceVersion: 1, name: resourceName, mime: item.output.mime, bytes: item.output.bytes, outputKey: item.output.outputKey, runId: run.id };
|
|
507
|
+
const content = item.output.role === 'workgraph-node' && item.output.nodeType === 'text'
|
|
508
|
+
? { title, text: item.text ?? '', outputKey: item.output.outputKey, runId: run.id, generatedOutput: resourceOutput }
|
|
509
|
+
: { title, ...resourceOutput, ...(item.text === undefined ? {} : { text: item.text }) };
|
|
510
|
+
return { ...item, ...created, content };
|
|
511
|
+
});
|
|
512
|
+
const document = publicationVersion === 2 ? artifacts.find(item => item.output.role === 'delivery-document') : artifacts.find(item => item.output.mime === 'text/markdown');
|
|
513
|
+
if (kind === 'execution') {
|
|
514
|
+
const currentContent = JSON.parse(String(source.node_content));
|
|
515
|
+
const taskTitle = selected.executionTitle ?? promptExecutionTitle(this.runs.snapshot(run.id).prompt);
|
|
516
|
+
if (taskTitle && object(currentContent) && (currentContent.title === undefined || currentContent.title === '' || DEFAULT_EXECUTION_TITLES.has(String(currentContent.title)))) {
|
|
517
|
+
this.append(run, Number(source.current_version), { ...currentContent, title: taskTitle });
|
|
518
|
+
}
|
|
519
|
+
const hideOutputs = !!this.db.prepare("SELECT 1 FROM edges WHERE graph_id=? AND source_id=? AND kind='execution' LIMIT 1").get(run.graphId, run.nodeId);
|
|
520
|
+
const visibleArtifacts = artifacts.some(item => item.output.role === 'workgraph-node') ? artifacts.filter(item => item.output.role !== 'delivery-document') : artifacts;
|
|
521
|
+
const layoutGroups = hideOutputs ? [] : this.db.prepare("SELECT n.id,n.x,n.y,n.width,n.height FROM graph_groups g JOIN nodes n ON n.id=g.id AND n.graph_id=g.graph_id JOIN group_members m ON m.group_id=g.id AND m.node_id=? WHERE g.graph_id=? AND g.run_id IS NULL AND n.type='group' AND n.schema_version=1 AND n.deleted=0 AND n.width IS NOT NULL AND n.height IS NOT NULL").all(run.nodeId, run.graphId)
|
|
522
|
+
.sort((a, b) => Number(a['width']) * Number(a['height']) - Number(b['width']) * Number(b['height']) || String(a['id']).localeCompare(String(b['id'])));
|
|
523
|
+
const documentId = randomUUID();
|
|
524
|
+
const nodeIds = [];
|
|
525
|
+
for (const [index, item] of visibleArtifacts.entries()) {
|
|
526
|
+
const id = item === document ? documentId : randomUUID();
|
|
527
|
+
nodeIds.push(id);
|
|
528
|
+
const projectType = item.output.role === 'project-file' ? classifyProjectFile(item.output.path).type : null;
|
|
529
|
+
const nodeType = item.output.role === 'workgraph-node' ? item.output.nodeType : projectType ?? (imageMime(item.output.mime) || item.output.mime === 'image/svg+xml' ? 'image' : supportsText(item.output.mime) ? 'document' : 'file');
|
|
530
|
+
const x = Math.min(1e9, Number(source.x) + Number(source.width ?? 300) + 100);
|
|
531
|
+
const y = Math.min(1e9, Number(source.y) + index * 280);
|
|
532
|
+
this.graphs.insertNode(run, { id, type: nodeType, schemaVersion: 1, contentVersion: 1, content: item.content, x, y, readOnly: true }, true);
|
|
533
|
+
// Match the Web's default 300x220 output bounds; partial overlap must not
|
|
534
|
+
// make a node move with a group that does not actually contain it.
|
|
535
|
+
const group = layoutGroups.find(g => x >= Number(g['x']) && y >= Number(g['y']) && x + 300 <= Number(g['x']) + Number(g['width']) && y + 220 <= Number(g['y']) + Number(g['height']));
|
|
536
|
+
if (group)
|
|
537
|
+
this.db.prepare('INSERT INTO group_members(group_id,node_id) VALUES(?,?)').run(String(group['id']), id);
|
|
538
|
+
}
|
|
539
|
+
const edge = (sourceId, targetId, edgeKind) => {
|
|
540
|
+
const id = randomUUID();
|
|
541
|
+
this.graphs.validateEdge(run, { id, sourceId, targetId, kind: edgeKind }, true);
|
|
542
|
+
this.db.prepare('INSERT INTO edges(id,graph_id,source_id,target_id,kind) VALUES(?,?,?,?,?)').run(id, run.graphId, sourceId, targetId, edgeKind);
|
|
543
|
+
};
|
|
544
|
+
for (const id of nodeIds)
|
|
545
|
+
edge(run.nodeId, id, 'delivery');
|
|
546
|
+
// Register output ownership through delivery edges before hiding intermediate results.
|
|
547
|
+
// The output folder and dependency snapshots still retain access to the published resources.
|
|
548
|
+
if (hideOutputs)
|
|
549
|
+
for (const id of nodeIds) {
|
|
550
|
+
this.db.prepare("DELETE FROM edges WHERE target_id=? AND kind='delivery'").run(id);
|
|
551
|
+
this.db.prepare("DELETE FROM canvas_resource_references WHERE owner_kind='node' AND node_id=?").run(id);
|
|
552
|
+
this.db.prepare('UPDATE nodes SET deleted=1 WHERE id=?').run(id);
|
|
553
|
+
}
|
|
554
|
+
if (!hideOutputs && visibleArtifacts.length > 1) {
|
|
555
|
+
const groupId = randomUUID();
|
|
556
|
+
this.db.prepare('INSERT INTO graph_groups(id,graph_id,run_id,title) VALUES(?,?,?,?)').run(groupId, run.graphId, run.id, 'Run outputs');
|
|
557
|
+
for (const id of nodeIds)
|
|
558
|
+
this.db.prepare('INSERT INTO group_members(group_id,node_id) VALUES(?,?)').run(groupId, id);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
if (source.read_only)
|
|
563
|
+
invalid('Generation target is read-only');
|
|
564
|
+
const primary = kind === 'text_generation' ? document : artifacts.find(item => imageMime(item.output.mime));
|
|
565
|
+
if (!('resource' in primary))
|
|
566
|
+
invalid('Generation outputs must use Work Graph resources');
|
|
567
|
+
const primaryResource = primary.resource;
|
|
568
|
+
const currentContent = JSON.parse(String(source.node_content));
|
|
569
|
+
const content = kind === 'text_generation'
|
|
570
|
+
? typeof primary.text === 'string'
|
|
571
|
+
? generatedTextContent(currentContent, primary.text, {
|
|
572
|
+
resourceId: primaryResource.id, resourceVersion: primaryResource.current.version,
|
|
573
|
+
name: basename(primary.output.path), mime: primary.output.mime,
|
|
574
|
+
})
|
|
575
|
+
: invalid('Text generation target requires editable text content')
|
|
576
|
+
: generatedImageContent(currentContent, { ...primary.content, title: generatedTextTitle(document.text ?? ''), outputs: artifacts.map(item => item.content) });
|
|
577
|
+
if (Buffer.byteLength(canonicalJson(content)) > 2 * MAX_TEXT)
|
|
578
|
+
invalid('Generation content exceeds node content limit');
|
|
579
|
+
if (source.current_version === runtime.details.baseVersion)
|
|
580
|
+
this.append(run, Number(source.current_version), content);
|
|
581
|
+
else
|
|
582
|
+
this.db.prepare("INSERT INTO generation_candidates(run_id,node_id,base_version,content,state) VALUES(?,?,?,?,'pending')").run(run.id, run.nodeId, Number(runtime.details.baseVersion), canonicalJson(content));
|
|
583
|
+
}
|
|
584
|
+
for (const item of artifacts)
|
|
585
|
+
if ('referenceId' in item)
|
|
586
|
+
this.resources.releaseCanvasReference(run, item.referenceId);
|
|
587
|
+
this.db.prepare('UPDATE graphs SET execution_revision=execution_revision+1,layout_revision=layout_revision+? WHERE id=?').run(kind === 'execution' ? 1 : 0, run.graphId);
|
|
588
|
+
this.graphs.event(run);
|
|
589
|
+
this.runs.transition(run.id, token, 'succeeded', { publicationDigest: digest });
|
|
590
|
+
this.graphs.history.completeRun(run, run.id, true);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
append(run, expected, content) {
|
|
594
|
+
if (Buffer.byteLength(canonicalJson(content)) > 2 * MAX_TEXT)
|
|
595
|
+
invalid('Generation content exceeds node content limit');
|
|
596
|
+
const changed = this.db.prepare('UPDATE nodes SET current_version=current_version+1 WHERE id=? AND graph_id=? AND current_version=? AND read_only=0 AND deleted=0').run(run.nodeId, run.graphId, expected);
|
|
597
|
+
if (changed.changes !== 1)
|
|
598
|
+
conflict('Generation target changed');
|
|
599
|
+
this.db.prepare('INSERT INTO node_versions(node_id,version,content) VALUES(?,?,?)').run(run.nodeId, expected + 1, canonicalJson(content));
|
|
600
|
+
this.graphs.retainResources(run, run.nodeId, expected + 1, content);
|
|
601
|
+
}
|
|
602
|
+
/** Main's authenticated route must supply full scope. No implicit acceptance. */
|
|
603
|
+
decide(request) {
|
|
604
|
+
return atomic(this.db, () => {
|
|
605
|
+
if (!['accept', 'discard'].includes(request.decision) || !Number.isSafeInteger(request.expectedContentVersion) || request.expectedContentVersion < 1)
|
|
606
|
+
invalid('Invalid candidate decision');
|
|
607
|
+
this.graphs.writable(request);
|
|
608
|
+
const run = this.runs.get(request.runId);
|
|
609
|
+
if (run.serviceId !== request.serviceId || run.projectId !== request.projectId || run.graphId !== request.graphId)
|
|
610
|
+
invalid('Candidate scope mismatch');
|
|
611
|
+
const candidate = this.db.prepare('SELECT * FROM generation_candidates WHERE run_id=?').get(run.id);
|
|
612
|
+
if (!candidate || candidate.state !== 'pending')
|
|
613
|
+
conflict('Candidate is no longer pending');
|
|
614
|
+
const node = this.db.prepare('SELECT * FROM nodes WHERE id=? AND graph_id=? AND deleted=0').get(run.nodeId, run.graphId);
|
|
615
|
+
if (!node || node.current_version !== request.expectedContentVersion || node.read_only)
|
|
616
|
+
conflict('Candidate target changed');
|
|
617
|
+
const state = request.decision === 'accept' ? 'accepted' : 'discarded';
|
|
618
|
+
if (request.decision === 'accept') {
|
|
619
|
+
const proposed = JSON.parse(String(candidate.content));
|
|
620
|
+
const runtime = this.runs.runtime(run.id);
|
|
621
|
+
if (runtime.details.kind === 'text_generation') {
|
|
622
|
+
const current = this.db.prepare('SELECT content FROM node_versions WHERE node_id=? AND version=?').get(run.nodeId, request.expectedContentVersion);
|
|
623
|
+
const existing = current ? JSON.parse(String(current.content)) : null;
|
|
624
|
+
if (!object(proposed) || typeof proposed.text !== 'string')
|
|
625
|
+
invalid('Text generation candidate requires editable text content');
|
|
626
|
+
this.append(run, request.expectedContentVersion, generatedTextContent(existing, proposed.text, proposed.generatedOutput, proposed.title));
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
const current = this.db.prepare('SELECT content FROM node_versions WHERE node_id=? AND version=?').get(run.nodeId, request.expectedContentVersion);
|
|
630
|
+
this.append(run, request.expectedContentVersion, generatedImageContent(current ? JSON.parse(String(current.content)) : null, proposed));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (this.db.prepare("UPDATE generation_candidates SET state=? WHERE run_id=? AND state='pending'").run(state, run.id).changes !== 1)
|
|
634
|
+
conflict('Candidate already decided');
|
|
635
|
+
this.db.prepare('UPDATE graphs SET execution_revision=execution_revision+1 WHERE id=?').run(run.graphId);
|
|
636
|
+
this.graphs.event(run);
|
|
637
|
+
this.runs.record(run.id, 'generation.' + state, { contentVersion: request.expectedContentVersion });
|
|
638
|
+
return { state, contentVersion: request.expectedContentVersion + Number(request.decision === 'accept') };
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
//# sourceMappingURL=publication.js.map
|