@melite-plugin/dsh-gallery 0.0.0-stage → 0.1.4-alpha
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/LICENSE +557 -0
- package/cordis.patch.yml +17 -0
- package/lib/client.js +7535 -0
- package/lib/index.d.ts +57 -0
- package/lib/index.js +625 -0
- package/package.json +77 -4
- package/README.md +0 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { LEGACY_RUN_MEDIA_PREFIX, RUN_ID_RE } from "@melite/contracts/benchStage";
|
|
3
|
+
/** The upstream's artifact prefix: the gateway route that has served
|
|
4
|
+
* run artifacts since the app's own mount — a LEGACY spelling the
|
|
5
|
+
* contract still owns (the media route serves both). */
|
|
6
|
+
const GATEWAY_MEDIA_PREFIX = LEGACY_RUN_MEDIA_PREFIX;
|
|
7
|
+
import { crossRootOrder } from '@melite/contracts/rootsManifest';
|
|
8
|
+
import { segmentConfined, servableLeaf } from '@melite/contracts/mediaKinds';
|
|
9
|
+
import { contentTypeForArtifact } from '@melite/contracts/mediaKinds';
|
|
10
|
+
import { parseRangeHeader } from '@melite/contracts/mediaRange';
|
|
11
|
+
export const name = 'infer-gallery';
|
|
12
|
+
/** Routes arrive with the webServer service. */
|
|
13
|
+
export const inject = ['webServer'];
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const RUNS_LIMIT = '500';
|
|
21
|
+
const DRAFTS_LIMIT = 200;
|
|
22
|
+
/** Sidecar tree default: the estate runs stream (SERVER-STATE ROOT
|
|
23
|
+
* LAW — build/ was the C era's cwd and is gone), anchored to the
|
|
24
|
+
* checkout so the default moves with the clone. */
|
|
25
|
+
const DEFAULT_FLOW_DIR = join(import.meta.dirname, '..', '..', '..', '..', 'data', 'runs', 'output');
|
|
26
|
+
/** A run dir name: run-<hex>-<word> (the runs grammar). */
|
|
27
|
+
const RUN_DIR = RUN_ID_RE;
|
|
28
|
+
/** A run-relative artifact name: run-<hex>-<word>/<file...> — the
|
|
29
|
+
* confinement the artifact route enforces. The file part is one or
|
|
30
|
+
* more segments (real artifacts live in subdirs — generate/...,
|
|
31
|
+
* input_prep/...). THE SEGMENT GRAMMAR IS THE CONTRACT'S
|
|
32
|
+
* (segmentConfined — the audit's ×3): this route's old private
|
|
33
|
+
* `[A-Za-z0-9._-]+` charset REFUSED names the engine mints (`out
|
|
34
|
+
* 1.png`, unicode, parens served on the estate media route and
|
|
35
|
+
* answered 400 here — the doors disagreed on the same artifact).
|
|
36
|
+
* `.`/`..` refuse at the grammar; realpath containment re-checks
|
|
37
|
+
* at the read. */
|
|
38
|
+
function isRunRelative(target) {
|
|
39
|
+
// the SHAPE half (well-formed, run-relative, no escape) — the
|
|
40
|
+
// SERVABLE half is the contract's predicate (servableLeaf,
|
|
41
|
+
// the audit's H5), applied by the route AFTER its own named
|
|
42
|
+
// sidecar refusal, so both voices survive.
|
|
43
|
+
const slash = target.indexOf('/');
|
|
44
|
+
if (slash <= 0)
|
|
45
|
+
return false;
|
|
46
|
+
if (!RUN_DIR.test(target.slice(0, slash)))
|
|
47
|
+
return false;
|
|
48
|
+
const rest = target.slice(slash + 1);
|
|
49
|
+
if (rest.length === 0)
|
|
50
|
+
return false;
|
|
51
|
+
return rest.split('/').every(segmentConfined);
|
|
52
|
+
}
|
|
53
|
+
/** Sidecar/shadow guard for the MUTATING doors (delete + rename):
|
|
54
|
+
* any `_`-prefixed segment names provenance, never media — the
|
|
55
|
+
* runs tree's own convention (`_flow.json`, `_request.json`,
|
|
56
|
+
* `_thumb/…`). A mutating door that unlinks/renames one would eat
|
|
57
|
+
* the run's own record; refuse LOUD before touching disk. */
|
|
58
|
+
function isProtectedSidecar(target) {
|
|
59
|
+
return target.split('/').slice(1).some((seg) => seg.startsWith('_'));
|
|
60
|
+
}
|
|
61
|
+
/** A bare rename target: one segment, never a path — the door joins
|
|
62
|
+
* it onto the source's own directory, so slashes/`..` refuse here
|
|
63
|
+
* (directory moves are not renames). `_`-prefixed names refuse as
|
|
64
|
+
* sidecars (see above); the length cap keeps ext4 happy. */
|
|
65
|
+
function isBareFileName(name) {
|
|
66
|
+
if (name.length === 0 || name.length > 128)
|
|
67
|
+
return false;
|
|
68
|
+
// ONE segment, never a path — the door joins this onto the
|
|
69
|
+
// source's own directory, so a slash is a directory move, not a
|
|
70
|
+
// rename. (The old charset refused slashes implicitly; the shared
|
|
71
|
+
// grammar judges BYTES of a segment, so the door states its own
|
|
72
|
+
// shape policy here.)
|
|
73
|
+
if (name.includes('/'))
|
|
74
|
+
return false;
|
|
75
|
+
if (!segmentConfined(name))
|
|
76
|
+
return false; // the shared grammar (no `.`/`..`/NUL)
|
|
77
|
+
if (name.startsWith('_'))
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/** Read one JSON body off the node:http idiom (the estate doors'
|
|
82
|
+
* own spelling — data/end/error over IncomingMessage). */
|
|
83
|
+
function readJsonBody(req) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
req.on('data', (d) => { if (d)
|
|
87
|
+
chunks.push(d); });
|
|
88
|
+
req.on('end', () => {
|
|
89
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
90
|
+
try {
|
|
91
|
+
resolve(raw === '' ? null : JSON.parse(raw));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
resolve(undefined);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
req.on('error', reject);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' };
|
|
101
|
+
/** Rewrite one run-relative artifact name to a same-origin route
|
|
102
|
+
* URL (empty in, empty out). */
|
|
103
|
+
function proxyAssetUrl(path) {
|
|
104
|
+
if (typeof path !== 'string' || path === '')
|
|
105
|
+
return '';
|
|
106
|
+
return `/infer-gallery/artifact?path=${encodeURIComponent(path)}`;
|
|
107
|
+
}
|
|
108
|
+
const fail = (res, status, code, payload) => {
|
|
109
|
+
const body = JSON.stringify({ error: code, ...payload });
|
|
110
|
+
res.writeHead(status, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
|
|
111
|
+
res.end(body);
|
|
112
|
+
};
|
|
113
|
+
export function apply(ctx, config = {}) {
|
|
114
|
+
const gatewayUrl = (config.gatewayUrl ?? '').replace(/\/+$/, '');
|
|
115
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
116
|
+
const flowDir = config.flowDir ?? DEFAULT_FLOW_DIR;
|
|
117
|
+
const flowTrees = () => {
|
|
118
|
+
const seat = ctx.get('queue', false);
|
|
119
|
+
if (seat === undefined)
|
|
120
|
+
return [flowDir];
|
|
121
|
+
const roots = seat.stateRoots !== undefined && seat.stateRoots.length > 0
|
|
122
|
+
? [...seat.stateRoots]
|
|
123
|
+
: [seat.stateRoot];
|
|
124
|
+
// THE SAME ORDER THE MEDIA LIST TAKES (crossRootOrder — the
|
|
125
|
+
// audit's H4): own tree first, then the manifest roots, deduped
|
|
126
|
+
return crossRootOrder(flowDir, roots.map((r) => `${r}/output`));
|
|
127
|
+
};
|
|
128
|
+
/** The first tree holding the target wins (the grammar was already
|
|
129
|
+
* proven by isRunRelative; containment re-proves per tree — the
|
|
130
|
+
* read door's second lock, kept per root). Null = miss. */
|
|
131
|
+
const resolveRunTarget = async (fs, path, target) => {
|
|
132
|
+
for (const tree of flowTrees()) {
|
|
133
|
+
const root = path.resolve(tree);
|
|
134
|
+
const full = path.resolve(root, target);
|
|
135
|
+
if (full !== root && !full.startsWith(root + path.sep))
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
await fs.stat(full);
|
|
139
|
+
return full;
|
|
140
|
+
}
|
|
141
|
+
catch { /* not in this tree */ }
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
/** One upstream JSON document. Cross-host fetches the owning DSH
|
|
146
|
+
* verbatim; in-tree answers over the seat. An absent seat is a
|
|
147
|
+
* LOUD 502 naming the fix — no fallback absorbs it. */
|
|
148
|
+
const upstream = async (lane, seat, call) => {
|
|
149
|
+
if (gatewayUrl !== '') {
|
|
150
|
+
const response = await fetch(`${gatewayUrl}${lane}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
151
|
+
const body = await response.text();
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw new GalleryUpstreamError(`upstream ${lane} answered ${response.status}`, body.slice(0, 2_000), response.status);
|
|
154
|
+
}
|
|
155
|
+
return JSON.parse(body);
|
|
156
|
+
}
|
|
157
|
+
if (seat === undefined) {
|
|
158
|
+
throw new GallerySeatError(`infer-gallery: no estate seat for ${lane} in this tree — mount @melite-plugin/dsh-estate or pin gatewayUrl (the owning DSH)`);
|
|
159
|
+
}
|
|
160
|
+
return call();
|
|
161
|
+
};
|
|
162
|
+
ctx.logger.info('infer-gallery serving (%s)', gatewayUrl === '' ? 'in-tree estate seats' : `owning DSH ${gatewayUrl}`);
|
|
163
|
+
/* The FLOWS door: the drafts list (the composed action cards, the
|
|
164
|
+
* queue-native flow instances). Newest-first; DRAFTS_LIMIT caps
|
|
165
|
+
* the payload — the sidebar is a browser, not an archive. Draft
|
|
166
|
+
* rows are params docs: no artifact names to rewrite, unlike
|
|
167
|
+
* runs. */
|
|
168
|
+
ctx.effect(() => ctx.webServer.register({
|
|
169
|
+
kind: 'exact',
|
|
170
|
+
path: '/infer-gallery/drafts',
|
|
171
|
+
handler: async (_req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const signal = new AbortController().signal;
|
|
174
|
+
const payload = (await upstream('/v1/flows/drafts', ctx.get('draftsRemote', false), () => ctx.get('draftsRemote', false).list(signal)));
|
|
175
|
+
const all = Array.isArray(payload?.drafts) ? payload.drafts : [];
|
|
176
|
+
const drafts = all.slice(0, DRAFTS_LIMIT);
|
|
177
|
+
const body = JSON.stringify({
|
|
178
|
+
gateway: gatewayUrl,
|
|
179
|
+
total: all.length,
|
|
180
|
+
truncated: all.length > drafts.length,
|
|
181
|
+
drafts,
|
|
182
|
+
});
|
|
183
|
+
res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
|
|
184
|
+
res.end(Buffer.from(body));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
ctx.logger.warn('infer-gallery: drafts failed: %s', error instanceof Error ? error.message : String(error));
|
|
188
|
+
if (error instanceof GalleryUpstreamError) {
|
|
189
|
+
fail(res, error.status, 'gateway_error', { upstream: `${gatewayUrl}/v1/flows/drafts`, body: error.body });
|
|
190
|
+
}
|
|
191
|
+
else if (error instanceof GallerySeatError) {
|
|
192
|
+
fail(res, 502, 'no_estate_seat', { message: error.message, lane: '/v1/flows/drafts' });
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
fail(res, 502, 'gateway_unreachable', { message: error instanceof Error ? error.message : String(error), gatewayUrl, upstream: `${gatewayUrl}/v1/flows/drafts` });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
}), 'infer-gallery: /infer-gallery/drafts route');
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
ctx.effect(() => ctx.webServer.register({
|
|
206
|
+
kind: 'exact',
|
|
207
|
+
path: '/infer-gallery/flow',
|
|
208
|
+
handler: async (req, res) => {
|
|
209
|
+
const url = new URL(req.url ?? '/', 'http://infer-gallery.invalid');
|
|
210
|
+
const runId = url.searchParams.get('run_id') ?? '';
|
|
211
|
+
if (!/^[A-Za-z0-9_-]+$/.test(runId)) {
|
|
212
|
+
fail(res, 400, 'bad_run_id', { message: 'run_id must be [A-Za-z0-9_-]+' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const fs = await import('node:fs/promises');
|
|
217
|
+
const path = await import('node:path');
|
|
218
|
+
// §23: the sidecar reads walk the trees — a maker run in a
|
|
219
|
+
// second root round-trips its params too.
|
|
220
|
+
const flowFile = await resolveRunTarget(fs, path, `${runId}/_flow.json`);
|
|
221
|
+
if (flowFile === null) {
|
|
222
|
+
throw new Error(`no readable _flow.json for ${runId}`);
|
|
223
|
+
}
|
|
224
|
+
const raw = await fs.readFile(flowFile, 'utf8');
|
|
225
|
+
let doc;
|
|
226
|
+
try {
|
|
227
|
+
doc = JSON.parse(raw);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
doc = JSON.parse(raw.replaceAll(',""', ',"'));
|
|
231
|
+
}
|
|
232
|
+
// THE OLD-RUN FLOOR (stroke 45): pre-snapshot runs recorded
|
|
233
|
+
// their invocation verbatim in _request.json (model/prompt/
|
|
234
|
+
// variant/...) — the honest reconstruction material when no
|
|
235
|
+
// card_snapshot exists. Same trees, same confinement.
|
|
236
|
+
let request = null;
|
|
237
|
+
try {
|
|
238
|
+
const reqFile = await resolveRunTarget(fs, path, `${runId}/_request.json`);
|
|
239
|
+
if (reqFile !== null)
|
|
240
|
+
request = JSON.parse(await fs.readFile(reqFile, 'utf8'));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
request = null;
|
|
244
|
+
}
|
|
245
|
+
const body = JSON.stringify({ run_id: runId, flow: doc, request });
|
|
246
|
+
res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
|
|
247
|
+
res.end(Buffer.from(body));
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
+
ctx.logger.warn('infer-gallery: flow doc read failed: %s', message);
|
|
252
|
+
fail(res, 404, 'no_sidecar', { message: `no readable _flow.json for ${runId}` });
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
}), 'infer-gallery: /infer-gallery/flow route');
|
|
256
|
+
ctx.effect(() => ctx.webServer.register({
|
|
257
|
+
kind: 'exact',
|
|
258
|
+
path: '/infer-gallery/runs',
|
|
259
|
+
handler: async (_req, res) => {
|
|
260
|
+
try {
|
|
261
|
+
const signal = new AbortController().signal;
|
|
262
|
+
const payload = (await upstream(`/v1/flows/runs?limit=${RUNS_LIMIT}`, ctx.get('runs', false), () => ctx.get('runs', false).list(RUNS_LIMIT, signal)));
|
|
263
|
+
const runs = Array.isArray(payload?.runs) ? payload.runs : [];
|
|
264
|
+
for (const run of runs) {
|
|
265
|
+
run.thumbnail = proxyAssetUrl(run.thumbnail);
|
|
266
|
+
if (Array.isArray(run.artifacts)) {
|
|
267
|
+
for (const artifact of run.artifacts) {
|
|
268
|
+
if (artifact !== null && typeof artifact === 'object') {
|
|
269
|
+
;
|
|
270
|
+
artifact.url = proxyAssetUrl(artifact.url);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const body = JSON.stringify({ gateway: gatewayUrl, runs });
|
|
276
|
+
res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
|
|
277
|
+
res.end(Buffer.from(body));
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
ctx.logger.warn('infer-gallery: runs failed: %s', error instanceof Error ? error.message : String(error));
|
|
281
|
+
if (error instanceof GalleryUpstreamError) {
|
|
282
|
+
fail(res, error.status, 'gateway_error', { upstream: `${gatewayUrl}/v1/flows/runs`, body: error.body });
|
|
283
|
+
}
|
|
284
|
+
else if (error instanceof GallerySeatError) {
|
|
285
|
+
fail(res, 502, 'no_estate_seat', { message: error.message, lane: '/v1/flows/runs' });
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
fail(res, 502, 'gateway_unreachable', { message: error instanceof Error ? error.message : String(error), gatewayUrl, upstream: `${gatewayUrl}/v1/flows/runs` });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
}), 'infer-gallery: /infer-gallery/runs route');
|
|
293
|
+
ctx.effect(() => ctx.webServer.register({
|
|
294
|
+
kind: 'exact',
|
|
295
|
+
path: '/infer-gallery/artifact',
|
|
296
|
+
handler: async (req, res) => {
|
|
297
|
+
const url = new URL(req.url ?? '/', 'http://infer-gallery.invalid');
|
|
298
|
+
const target = url.searchParams.get('path') ?? '';
|
|
299
|
+
if (!isRunRelative(target)) {
|
|
300
|
+
fail(res, 400, 'forbidden_path', { message: 'artifact route only serves run-relative names (run-<id>/<file>)', path: target });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// THE SERVABLE HALF IS THE CONTRACT'S (the audit's H5: the
|
|
304
|
+
// estate's media route refused private records the gallery
|
|
305
|
+
// served; one predicate, two doors). The sidecar refusal below
|
|
306
|
+
// keeps its own NAME for the case it names.
|
|
307
|
+
if (!servableLeaf(target.split('/').pop() ?? '')) {
|
|
308
|
+
fail(res, 400, 'protected_sidecar', { message: 'run sidecars are not artifacts', path: target });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// media elements seek by asking `Range: bytes=…` — answering
|
|
313
|
+
// whole-file 200s left the playhead stuck at the buffer edge.
|
|
314
|
+
// The parse is the LEAF's one spelling (parseRangeHeader) and
|
|
315
|
+
// the content-type is the vocab leaf's MIME table (this route
|
|
316
|
+
// had already drifted: mp4 answered video/webm, audio answered
|
|
317
|
+
// octet-stream). Cross-host relays the ask upstream and passes
|
|
318
|
+
// the upstream's range answer through untouched.
|
|
319
|
+
const rangeAsk = req.headers?.range;
|
|
320
|
+
try {
|
|
321
|
+
if (gatewayUrl !== '') {
|
|
322
|
+
// Cross-host: the owning DSH's asset tree (the /v1/assets
|
|
323
|
+
// prefix rides only in this posture — the in-tree rows are
|
|
324
|
+
// run-relative by the REBUILD-6 divergence law).
|
|
325
|
+
const response = await fetch(`${gatewayUrl}${GATEWAY_MEDIA_PREFIX}/${target}`, {
|
|
326
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
327
|
+
headers: rangeAsk !== undefined ? { range: rangeAsk } : {},
|
|
328
|
+
});
|
|
329
|
+
if (response.status === 416) {
|
|
330
|
+
// THE UNSATISFIABLE VERDICT passes through (the relay
|
|
331
|
+
// law's loud arm): the player that asked past EOF learns
|
|
332
|
+
// the file's true window from the upstream's content-range
|
|
333
|
+
// — never a gateway_error that reads as a dead file.
|
|
334
|
+
const unsatisfiable = {
|
|
335
|
+
'content-type': 'application/json',
|
|
336
|
+
'cache-control': 'no-store',
|
|
337
|
+
'accept-ranges': 'bytes',
|
|
338
|
+
};
|
|
339
|
+
const upstreamWindow = response.headers.get('content-range');
|
|
340
|
+
if (upstreamWindow !== null)
|
|
341
|
+
unsatisfiable['content-range'] = upstreamWindow;
|
|
342
|
+
res.writeHead(416, unsatisfiable);
|
|
343
|
+
res.end('{"error":"infer-gallery/range-not-satisfiable"}');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (response.ok && req.method === 'HEAD' && response.body === null) {
|
|
347
|
+
// HEAD answers carry no body by definition — relay the
|
|
348
|
+
// upstream's metadata with an empty body (a bodiless GET
|
|
349
|
+
// below is still a broken upstream, refused loud).
|
|
350
|
+
const heads = {
|
|
351
|
+
'content-type': response.headers.get('content-type') ?? contentTypeForArtifact(target),
|
|
352
|
+
'content-length': response.headers.get('content-length') ?? '0',
|
|
353
|
+
'cache-control': 'no-store',
|
|
354
|
+
};
|
|
355
|
+
const upstreamRange = response.headers.get('content-range');
|
|
356
|
+
if (upstreamRange !== null)
|
|
357
|
+
heads['content-range'] = upstreamRange;
|
|
358
|
+
if (response.headers.get('accept-ranges') !== null)
|
|
359
|
+
heads['accept-ranges'] = 'bytes';
|
|
360
|
+
res.writeHead(response.status, heads);
|
|
361
|
+
res.end();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!response.ok || response.body === null) {
|
|
365
|
+
fail(res, response.status, 'gateway_error', { upstream: `${gatewayUrl}${GATEWAY_MEDIA_PREFIX}/${target}` });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
369
|
+
const heads = {
|
|
370
|
+
'content-type': response.headers.get('content-type') ?? contentTypeForArtifact(target),
|
|
371
|
+
'cache-control': 'no-store',
|
|
372
|
+
// explicit length: remote proxies (tailscale serve) mishandle chunked
|
|
373
|
+
// framing on lossy links — a known length lets them buffer instead.
|
|
374
|
+
'content-length': String(buffer.byteLength),
|
|
375
|
+
};
|
|
376
|
+
const upstreamRange = response.headers.get('content-range');
|
|
377
|
+
if (upstreamRange !== null)
|
|
378
|
+
heads['content-range'] = upstreamRange;
|
|
379
|
+
if (response.headers.get('accept-ranges') !== null)
|
|
380
|
+
heads['accept-ranges'] = 'bytes';
|
|
381
|
+
res.writeHead(response.status, heads);
|
|
382
|
+
res.end(buffer);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// In-tree: the runs output trees themselves (§23: the seat
|
|
386
|
+
// manifest — first tree holding the file serves). DOUBLE
|
|
387
|
+
// CONFINEMENT (the media route's law): the grammar refused
|
|
388
|
+
// traversal above; resolved-path containment proves the read
|
|
389
|
+
// stays inside the WINNING tree.
|
|
390
|
+
const fs = await import('node:fs/promises');
|
|
391
|
+
const path = await import('node:path');
|
|
392
|
+
const full = await resolveRunTarget(fs, path, target);
|
|
393
|
+
if (full === null) {
|
|
394
|
+
// byte-identical to the legacy ENOENT (the message names
|
|
395
|
+
// the OWN tree — the miss stays debuggable, never a
|
|
396
|
+
// synthesized foreign path)
|
|
397
|
+
throw new Error(`ENOENT: no such file or directory, open '${path.resolve(flowDir, target)}'`);
|
|
398
|
+
}
|
|
399
|
+
const type = contentTypeForArtifact(target);
|
|
400
|
+
if (req.method === 'HEAD') {
|
|
401
|
+
// HEAD asks for metadata, never bytes — stat the size and
|
|
402
|
+
// answer empty (the media route's own law; reading the whole
|
|
403
|
+
// file to discard it wastes a full decode per probe).
|
|
404
|
+
const stat = await fs.stat(full);
|
|
405
|
+
res.writeHead(200, {
|
|
406
|
+
'content-type': type,
|
|
407
|
+
'cache-control': 'no-store',
|
|
408
|
+
'content-length': String(stat.size),
|
|
409
|
+
'accept-ranges': 'bytes',
|
|
410
|
+
});
|
|
411
|
+
res.end();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const file = await fs.readFile(full);
|
|
415
|
+
const range = parseRangeHeader(rangeAsk, file.byteLength);
|
|
416
|
+
if (range === 'unsatisfiable') {
|
|
417
|
+
res.writeHead(416, {
|
|
418
|
+
'content-type': 'application/json',
|
|
419
|
+
'content-range': `bytes */${String(file.byteLength)}`,
|
|
420
|
+
'accept-ranges': 'bytes',
|
|
421
|
+
'cache-control': 'no-store',
|
|
422
|
+
});
|
|
423
|
+
res.end('{"error":"infer-gallery/range-not-satisfiable"}');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (range !== null) {
|
|
427
|
+
const slice = file.subarray(range.start, range.end + 1);
|
|
428
|
+
res.writeHead(206, {
|
|
429
|
+
'content-type': type,
|
|
430
|
+
'cache-control': 'no-store',
|
|
431
|
+
'content-length': String(slice.byteLength),
|
|
432
|
+
'content-range': `bytes ${String(range.start)}-${String(range.end)}/${String(file.byteLength)}`,
|
|
433
|
+
'accept-ranges': 'bytes',
|
|
434
|
+
});
|
|
435
|
+
res.end(slice);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
res.writeHead(200, {
|
|
439
|
+
'content-type': type,
|
|
440
|
+
'cache-control': 'no-store',
|
|
441
|
+
'content-length': String(file.byteLength),
|
|
442
|
+
'accept-ranges': 'bytes',
|
|
443
|
+
});
|
|
444
|
+
res.end(file);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
448
|
+
ctx.logger.warn('infer-gallery: artifact failed: %s', message);
|
|
449
|
+
fail(res, 502, 'artifact_unreadable', { message, path: target });
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
}), 'infer-gallery: /infer-gallery/artifact route');
|
|
453
|
+
/* THE MUTATING DOORS (the modal's delete + rename buttons): POST
|
|
454
|
+
* with a JSON body — the artifact route above stays read-only, so
|
|
455
|
+
* a prefetcher can never mutate by URL. Both doors share the
|
|
456
|
+
* read door's confinement (isRunRelative + realpath containment)
|
|
457
|
+
* plus the sidecar guard (isProtectedSidecar): `_`-prefixed names
|
|
458
|
+
* are provenance, never media. Cross-host relays the same JSON
|
|
459
|
+
* upstream and passes its answer through untouched. */
|
|
460
|
+
ctx.effect(() => ctx.webServer.register({
|
|
461
|
+
kind: 'exact',
|
|
462
|
+
path: '/infer-gallery/artifact-delete',
|
|
463
|
+
handler: async (req, res) => {
|
|
464
|
+
if (req.method !== 'POST') {
|
|
465
|
+
fail(res, 405, 'method_not_allowed', { message: 'POST {path} only' });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const body = await readJsonBody(req);
|
|
470
|
+
const target = typeof body?.path === 'string' ? body.path : '';
|
|
471
|
+
if (!isRunRelative(target)) {
|
|
472
|
+
fail(res, 400, 'forbidden_path', { message: 'artifact route only serves run-relative names (run-<id>/<file>)', path: target });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (isProtectedSidecar(target)) {
|
|
476
|
+
fail(res, 400, 'protected_sidecar', { message: 'sidecar files are provenance, never media — refusing', path: target });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (gatewayUrl !== '') {
|
|
480
|
+
const response = await fetch(`${gatewayUrl}/infer-gallery/artifact-delete`, {
|
|
481
|
+
method: 'POST',
|
|
482
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
483
|
+
headers: { 'content-type': 'application/json' },
|
|
484
|
+
body: JSON.stringify({ path: target }),
|
|
485
|
+
});
|
|
486
|
+
const text = await response.text();
|
|
487
|
+
res.writeHead(response.status, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(text)) });
|
|
488
|
+
res.end(text);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const fs = await import('node:fs/promises');
|
|
492
|
+
const path = await import('node:path');
|
|
493
|
+
// §23: the unlink walks the trees (first tree holding the
|
|
494
|
+
// file wins — the door that lists it manages it).
|
|
495
|
+
const full = await resolveRunTarget(fs, path, target);
|
|
496
|
+
if (full === null) {
|
|
497
|
+
fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
await fs.unlink(full);
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
const code = error.code;
|
|
505
|
+
if (code === 'ENOENT') {
|
|
506
|
+
fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
const ok = JSON.stringify({ ok: true, path: target });
|
|
512
|
+
res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(ok)) });
|
|
513
|
+
res.end(Buffer.from(ok));
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
517
|
+
ctx.logger.warn('infer-gallery: artifact-delete failed: %s', message);
|
|
518
|
+
fail(res, 502, 'artifact_unreadable', { message });
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
}), 'infer-gallery: /infer-gallery/artifact-delete route');
|
|
522
|
+
ctx.effect(() => ctx.webServer.register({
|
|
523
|
+
kind: 'exact',
|
|
524
|
+
path: '/infer-gallery/artifact-rename',
|
|
525
|
+
handler: async (req, res) => {
|
|
526
|
+
if (req.method !== 'POST') {
|
|
527
|
+
fail(res, 405, 'method_not_allowed', { message: 'POST {path, name} only' });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
const body = await readJsonBody(req);
|
|
532
|
+
const target = typeof body?.path === 'string' ? body.path : '';
|
|
533
|
+
const name = typeof body?.name === 'string' ? body.name : '';
|
|
534
|
+
if (!isRunRelative(target)) {
|
|
535
|
+
fail(res, 400, 'forbidden_path', { message: 'artifact route only serves run-relative names (run-<id>/<file>)', path: target });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (isProtectedSidecar(target)) {
|
|
539
|
+
fail(res, 400, 'protected_sidecar', { message: 'sidecar files are provenance, never media — refusing', path: target });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!isBareFileName(name)) {
|
|
543
|
+
fail(res, 400, 'bad_name', { message: 'name must be one bare file segment (no slashes, no leading _)', name });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (gatewayUrl !== '') {
|
|
547
|
+
const response = await fetch(`${gatewayUrl}/infer-gallery/artifact-rename`, {
|
|
548
|
+
method: 'POST',
|
|
549
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
550
|
+
headers: { 'content-type': 'application/json' },
|
|
551
|
+
body: JSON.stringify({ path: target, name }),
|
|
552
|
+
});
|
|
553
|
+
const text = await response.text();
|
|
554
|
+
res.writeHead(response.status, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(text)) });
|
|
555
|
+
res.end(text);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const dir = target.slice(0, target.lastIndexOf('/'));
|
|
559
|
+
const next = dir + '/' + name;
|
|
560
|
+
/* v8 ignore next 3 -- `name` already passed isBareFileName, so
|
|
561
|
+
* the joined target always satisfies the grammar; the check
|
|
562
|
+
* stays as defense against future callers joining paths. */
|
|
563
|
+
if (!isRunRelative(next) || isProtectedSidecar(next)) {
|
|
564
|
+
fail(res, 400, 'bad_name', { message: 'rename target refuses the artifact grammar', name });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (next === target) {
|
|
568
|
+
fail(res, 400, 'bad_name', { message: 'rename to the same name changes nothing', name });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const fs = await import('node:fs/promises');
|
|
572
|
+
const path = await import('node:path');
|
|
573
|
+
// §23: the rename walks the trees; the new name lands beside
|
|
574
|
+
// the source in the WINNING tree (a bare segment joined onto
|
|
575
|
+
// the source's own directory — containment holds by
|
|
576
|
+
// construction, the walk proved the tree).
|
|
577
|
+
const full = await resolveRunTarget(fs, path, target);
|
|
578
|
+
if (full === null) {
|
|
579
|
+
fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const fullNext = path.resolve(path.dirname(full), name);
|
|
583
|
+
try {
|
|
584
|
+
await fs.stat(full);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await fs.stat(fullNext);
|
|
592
|
+
fail(res, 409, 'already_exists', { message: 'a file already carries that name', path: next });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// absent — the rename may proceed.
|
|
597
|
+
}
|
|
598
|
+
await fs.rename(full, fullNext);
|
|
599
|
+
const ok = JSON.stringify({ ok: true, path: next, url: proxyAssetUrl(next) });
|
|
600
|
+
res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(ok)) });
|
|
601
|
+
res.end(Buffer.from(ok));
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
605
|
+
ctx.logger.warn('infer-gallery: artifact-rename failed: %s', message);
|
|
606
|
+
fail(res, 502, 'artifact_unreadable', { message });
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
}), 'infer-gallery: /infer-gallery/artifact-rename route');
|
|
610
|
+
}
|
|
611
|
+
/** Cross-host upstream answered an error status (its body rides). */
|
|
612
|
+
class GalleryUpstreamError extends Error {
|
|
613
|
+
body;
|
|
614
|
+
status;
|
|
615
|
+
constructor(message, body, status) {
|
|
616
|
+
super(message);
|
|
617
|
+
this.body = body;
|
|
618
|
+
this.status = status;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/** In-tree posture with the estate absent — the loud 502 (nothing
|
|
622
|
+
* absorbs a missing seat). */
|
|
623
|
+
class GallerySeatError extends Error {
|
|
624
|
+
}
|
|
625
|
+
//# sourceMappingURL=index.js.map
|