@melite-plugin/dsh-gallery 0.0.0-stage → 0.1.5-pre-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/lib/index.js ADDED
@@ -0,0 +1,629 @@
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
+
127
+ // root contributes BOTH run trees — output/ (real runs) and
128
+ // test-output/ (test:true runs); the artifact door resolved only
129
+ // output/ and 404'd every test run's preview bytes.
130
+ return crossRootOrder(flowDir, roots.flatMap((r) => [`${r}/output`, `${r}/test-output`]));
131
+ };
132
+ /** The first tree holding the target wins (the grammar was already
133
+ * proven by isRunRelative; containment re-proves per tree — the
134
+ * read door's second lock, kept per root). Null = miss. */
135
+ const resolveRunTarget = async (fs, path, target) => {
136
+ for (const tree of flowTrees()) {
137
+ const root = path.resolve(tree);
138
+ const full = path.resolve(root, target);
139
+ if (full !== root && !full.startsWith(root + path.sep))
140
+ continue;
141
+ try {
142
+ await fs.stat(full);
143
+ return full;
144
+ }
145
+ catch { /* not in this tree */ }
146
+ }
147
+ return null;
148
+ };
149
+ /** One upstream JSON document. Cross-host fetches the owning DSH
150
+ * verbatim; in-tree answers over the seat. An absent seat is a
151
+ * LOUD 502 naming the fix — no fallback absorbs it. */
152
+ const upstream = async (lane, seat, call) => {
153
+ if (gatewayUrl !== '') {
154
+ const response = await fetch(`${gatewayUrl}${lane}`, { signal: AbortSignal.timeout(timeoutMs) });
155
+ const body = await response.text();
156
+ if (!response.ok) {
157
+ throw new GalleryUpstreamError(`upstream ${lane} answered ${response.status}`, body.slice(0, 2_000), response.status);
158
+ }
159
+ return JSON.parse(body);
160
+ }
161
+ if (seat === undefined) {
162
+ throw new GallerySeatError(`infer-gallery: no estate seat for ${lane} in this tree — mount @melite-plugin/dsh-estate or pin gatewayUrl (the owning DSH)`);
163
+ }
164
+ return call();
165
+ };
166
+ ctx.logger.info('infer-gallery serving (%s)', gatewayUrl === '' ? 'in-tree estate seats' : `owning DSH ${gatewayUrl}`);
167
+ /* The FLOWS door: the drafts list (the composed action cards, the
168
+ * queue-native flow instances). Newest-first; DRAFTS_LIMIT caps
169
+ * the payload — the sidebar is a browser, not an archive. Draft
170
+ * rows are params docs: no artifact names to rewrite, unlike
171
+ * runs. */
172
+ ctx.effect(() => ctx.webServer.register({
173
+ kind: 'exact',
174
+ path: '/infer-gallery/drafts',
175
+ handler: async (_req, res) => {
176
+ try {
177
+ const signal = new AbortController().signal;
178
+ const payload = (await upstream('/v1/flows/drafts', ctx.get('draftsRemote', false), () => ctx.get('draftsRemote', false).list(signal)));
179
+ const all = Array.isArray(payload?.drafts) ? payload.drafts : [];
180
+ const drafts = all.slice(0, DRAFTS_LIMIT);
181
+ const body = JSON.stringify({
182
+ gateway: gatewayUrl,
183
+ total: all.length,
184
+ truncated: all.length > drafts.length,
185
+ drafts,
186
+ });
187
+ res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
188
+ res.end(Buffer.from(body));
189
+ }
190
+ catch (error) {
191
+ ctx.logger.warn('infer-gallery: drafts failed: %s', error instanceof Error ? error.message : String(error));
192
+ if (error instanceof GalleryUpstreamError) {
193
+ fail(res, error.status, 'gateway_error', { upstream: `${gatewayUrl}/v1/flows/drafts`, body: error.body });
194
+ }
195
+ else if (error instanceof GallerySeatError) {
196
+ fail(res, 502, 'no_estate_seat', { message: error.message, lane: '/v1/flows/drafts' });
197
+ }
198
+ else {
199
+ fail(res, 502, 'gateway_unreachable', { message: error instanceof Error ? error.message : String(error), gatewayUrl, upstream: `${gatewayUrl}/v1/flows/drafts` });
200
+ }
201
+ }
202
+ },
203
+ }), 'infer-gallery: /infer-gallery/drafts route');
204
+
205
+
206
+
207
+
208
+
209
+ ctx.effect(() => ctx.webServer.register({
210
+ kind: 'exact',
211
+ path: '/infer-gallery/flow',
212
+ handler: async (req, res) => {
213
+ const url = new URL(req.url ?? '/', 'http://infer-gallery.invalid');
214
+ const runId = url.searchParams.get('run_id') ?? '';
215
+ if (!/^[A-Za-z0-9_-]+$/.test(runId)) {
216
+ fail(res, 400, 'bad_run_id', { message: 'run_id must be [A-Za-z0-9_-]+' });
217
+ return;
218
+ }
219
+ try {
220
+ const fs = await import('node:fs/promises');
221
+ const path = await import('node:path');
222
+ // §23: the sidecar reads walk the trees — a maker run in a
223
+ // second root round-trips its params too.
224
+ const flowFile = await resolveRunTarget(fs, path, `${runId}/_flow.json`);
225
+ if (flowFile === null) {
226
+ throw new Error(`no readable _flow.json for ${runId}`);
227
+ }
228
+ const raw = await fs.readFile(flowFile, 'utf8');
229
+ let doc;
230
+ try {
231
+ doc = JSON.parse(raw);
232
+ }
233
+ catch {
234
+ doc = JSON.parse(raw.replaceAll(',""', ',"'));
235
+ }
236
+ // THE OLD-RUN FLOOR (stroke 45): pre-snapshot runs recorded
237
+ // their invocation verbatim in _request.json (model/prompt/
238
+ // variant/...) — the honest reconstruction material when no
239
+ // card_snapshot exists. Same trees, same confinement.
240
+ let request = null;
241
+ try {
242
+ const reqFile = await resolveRunTarget(fs, path, `${runId}/_request.json`);
243
+ if (reqFile !== null)
244
+ request = JSON.parse(await fs.readFile(reqFile, 'utf8'));
245
+ }
246
+ catch {
247
+ request = null;
248
+ }
249
+ const body = JSON.stringify({ run_id: runId, flow: doc, request });
250
+ res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
251
+ res.end(Buffer.from(body));
252
+ }
253
+ catch (error) {
254
+ const message = error instanceof Error ? error.message : String(error);
255
+ ctx.logger.warn('infer-gallery: flow doc read failed: %s', message);
256
+ fail(res, 404, 'no_sidecar', { message: `no readable _flow.json for ${runId}` });
257
+ }
258
+ },
259
+ }), 'infer-gallery: /infer-gallery/flow route');
260
+ ctx.effect(() => ctx.webServer.register({
261
+ kind: 'exact',
262
+ path: '/infer-gallery/runs',
263
+ handler: async (_req, res) => {
264
+ try {
265
+ const signal = new AbortController().signal;
266
+ const payload = (await upstream(`/v1/flows/runs?limit=${RUNS_LIMIT}`, ctx.get('runs', false), () => ctx.get('runs', false).list(RUNS_LIMIT, signal)));
267
+ const runs = Array.isArray(payload?.runs) ? payload.runs : [];
268
+ for (const run of runs) {
269
+ run.thumbnail = proxyAssetUrl(run.thumbnail);
270
+ if (Array.isArray(run.artifacts)) {
271
+ for (const artifact of run.artifacts) {
272
+ if (artifact !== null && typeof artifact === 'object') {
273
+ ;
274
+ artifact.url = proxyAssetUrl(artifact.url);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ const body = JSON.stringify({ gateway: gatewayUrl, runs });
280
+ res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(body)) });
281
+ res.end(Buffer.from(body));
282
+ }
283
+ catch (error) {
284
+ ctx.logger.warn('infer-gallery: runs failed: %s', error instanceof Error ? error.message : String(error));
285
+ if (error instanceof GalleryUpstreamError) {
286
+ fail(res, error.status, 'gateway_error', { upstream: `${gatewayUrl}/v1/flows/runs`, body: error.body });
287
+ }
288
+ else if (error instanceof GallerySeatError) {
289
+ fail(res, 502, 'no_estate_seat', { message: error.message, lane: '/v1/flows/runs' });
290
+ }
291
+ else {
292
+ fail(res, 502, 'gateway_unreachable', { message: error instanceof Error ? error.message : String(error), gatewayUrl, upstream: `${gatewayUrl}/v1/flows/runs` });
293
+ }
294
+ }
295
+ },
296
+ }), 'infer-gallery: /infer-gallery/runs route');
297
+ ctx.effect(() => ctx.webServer.register({
298
+ kind: 'exact',
299
+ path: '/infer-gallery/artifact',
300
+ handler: async (req, res) => {
301
+ const url = new URL(req.url ?? '/', 'http://infer-gallery.invalid');
302
+ const target = url.searchParams.get('path') ?? '';
303
+ if (!isRunRelative(target)) {
304
+ fail(res, 400, 'forbidden_path', { message: 'artifact route only serves run-relative names (run-<id>/<file>)', path: target });
305
+ return;
306
+ }
307
+ // THE SERVABLE HALF IS THE CONTRACT'S (the audit's H5: the
308
+ // estate's media route refused private records the gallery
309
+ // served; one predicate, two doors). The sidecar refusal below
310
+ // keeps its own NAME for the case it names.
311
+ if (!servableLeaf(target.split('/').pop() ?? '')) {
312
+ fail(res, 400, 'protected_sidecar', { message: 'run sidecars are not artifacts', path: target });
313
+ return;
314
+ }
315
+
316
+ // media elements seek by asking `Range: bytes=…` — answering
317
+ // whole-file 200s left the playhead stuck at the buffer edge.
318
+ // The parse is the LEAF's one spelling (parseRangeHeader) and
319
+ // the content-type is the vocab leaf's MIME table (this route
320
+ // had already drifted: mp4 answered video/webm, audio answered
321
+ // octet-stream). Cross-host relays the ask upstream and passes
322
+ // the upstream's range answer through untouched.
323
+ const rangeAsk = req.headers?.range;
324
+ try {
325
+ if (gatewayUrl !== '') {
326
+ // Cross-host: the owning DSH's asset tree (the /v1/assets
327
+ // prefix rides only in this posture — the in-tree rows are
328
+ // run-relative by the REBUILD-6 divergence law).
329
+ const response = await fetch(`${gatewayUrl}${GATEWAY_MEDIA_PREFIX}/${target}`, {
330
+ signal: AbortSignal.timeout(timeoutMs),
331
+ headers: rangeAsk !== undefined ? { range: rangeAsk } : {},
332
+ });
333
+ if (response.status === 416) {
334
+ // THE UNSATISFIABLE VERDICT passes through (the relay
335
+ // law's loud arm): the player that asked past EOF learns
336
+ // the file's true window from the upstream's content-range
337
+ // — never a gateway_error that reads as a dead file.
338
+ const unsatisfiable = {
339
+ 'content-type': 'application/json',
340
+ 'cache-control': 'no-store',
341
+ 'accept-ranges': 'bytes',
342
+ };
343
+ const upstreamWindow = response.headers.get('content-range');
344
+ if (upstreamWindow !== null)
345
+ unsatisfiable['content-range'] = upstreamWindow;
346
+ res.writeHead(416, unsatisfiable);
347
+ res.end('{"error":"infer-gallery/range-not-satisfiable"}');
348
+ return;
349
+ }
350
+ if (response.ok && req.method === 'HEAD' && response.body === null) {
351
+ // HEAD answers carry no body by definition — relay the
352
+ // upstream's metadata with an empty body (a bodiless GET
353
+ // below is still a broken upstream, refused loud).
354
+ const heads = {
355
+ 'content-type': response.headers.get('content-type') ?? contentTypeForArtifact(target),
356
+ 'content-length': response.headers.get('content-length') ?? '0',
357
+ 'cache-control': 'no-store',
358
+ };
359
+ const upstreamRange = response.headers.get('content-range');
360
+ if (upstreamRange !== null)
361
+ heads['content-range'] = upstreamRange;
362
+ if (response.headers.get('accept-ranges') !== null)
363
+ heads['accept-ranges'] = 'bytes';
364
+ res.writeHead(response.status, heads);
365
+ res.end();
366
+ return;
367
+ }
368
+ if (!response.ok || response.body === null) {
369
+ fail(res, response.status, 'gateway_error', { upstream: `${gatewayUrl}${GATEWAY_MEDIA_PREFIX}/${target}` });
370
+ return;
371
+ }
372
+ const buffer = Buffer.from(await response.arrayBuffer());
373
+ const heads = {
374
+ 'content-type': response.headers.get('content-type') ?? contentTypeForArtifact(target),
375
+ 'cache-control': 'no-store',
376
+ // explicit length: remote proxies (tailscale serve) mishandle chunked
377
+ // framing on lossy links — a known length lets them buffer instead.
378
+ 'content-length': String(buffer.byteLength),
379
+ };
380
+ const upstreamRange = response.headers.get('content-range');
381
+ if (upstreamRange !== null)
382
+ heads['content-range'] = upstreamRange;
383
+ if (response.headers.get('accept-ranges') !== null)
384
+ heads['accept-ranges'] = 'bytes';
385
+ res.writeHead(response.status, heads);
386
+ res.end(buffer);
387
+ return;
388
+ }
389
+ // In-tree: the runs output trees themselves (§23: the seat
390
+ // manifest — first tree holding the file serves). DOUBLE
391
+ // CONFINEMENT (the media route's law): the grammar refused
392
+ // traversal above; resolved-path containment proves the read
393
+ // stays inside the WINNING tree.
394
+ const fs = await import('node:fs/promises');
395
+ const path = await import('node:path');
396
+ const full = await resolveRunTarget(fs, path, target);
397
+ if (full === null) {
398
+ // byte-identical to the legacy ENOENT (the message names
399
+ // the OWN tree — the miss stays debuggable, never a
400
+ // synthesized foreign path)
401
+ throw new Error(`ENOENT: no such file or directory, open '${path.resolve(flowDir, target)}'`);
402
+ }
403
+ const type = contentTypeForArtifact(target);
404
+ if (req.method === 'HEAD') {
405
+ // HEAD asks for metadata, never bytes — stat the size and
406
+ // answer empty (the media route's own law; reading the whole
407
+ // file to discard it wastes a full decode per probe).
408
+ const stat = await fs.stat(full);
409
+ res.writeHead(200, {
410
+ 'content-type': type,
411
+ 'cache-control': 'no-store',
412
+ 'content-length': String(stat.size),
413
+ 'accept-ranges': 'bytes',
414
+ });
415
+ res.end();
416
+ return;
417
+ }
418
+ const file = await fs.readFile(full);
419
+ const range = parseRangeHeader(rangeAsk, file.byteLength);
420
+ if (range === 'unsatisfiable') {
421
+ res.writeHead(416, {
422
+ 'content-type': 'application/json',
423
+ 'content-range': `bytes */${String(file.byteLength)}`,
424
+ 'accept-ranges': 'bytes',
425
+ 'cache-control': 'no-store',
426
+ });
427
+ res.end('{"error":"infer-gallery/range-not-satisfiable"}');
428
+ return;
429
+ }
430
+ if (range !== null) {
431
+ const slice = file.subarray(range.start, range.end + 1);
432
+ res.writeHead(206, {
433
+ 'content-type': type,
434
+ 'cache-control': 'no-store',
435
+ 'content-length': String(slice.byteLength),
436
+ 'content-range': `bytes ${String(range.start)}-${String(range.end)}/${String(file.byteLength)}`,
437
+ 'accept-ranges': 'bytes',
438
+ });
439
+ res.end(slice);
440
+ return;
441
+ }
442
+ res.writeHead(200, {
443
+ 'content-type': type,
444
+ 'cache-control': 'no-store',
445
+ 'content-length': String(file.byteLength),
446
+ 'accept-ranges': 'bytes',
447
+ });
448
+ res.end(file);
449
+ }
450
+ catch (error) {
451
+ const message = error instanceof Error ? error.message : String(error);
452
+ ctx.logger.warn('infer-gallery: artifact failed: %s', message);
453
+ fail(res, 502, 'artifact_unreadable', { message, path: target });
454
+ }
455
+ },
456
+ }), 'infer-gallery: /infer-gallery/artifact route');
457
+ /* THE MUTATING DOORS (the modal's delete + rename buttons): POST
458
+ * with a JSON body — the artifact route above stays read-only, so
459
+ * a prefetcher can never mutate by URL. Both doors share the
460
+ * read door's confinement (isRunRelative + realpath containment)
461
+ * plus the sidecar guard (isProtectedSidecar): `_`-prefixed names
462
+ * are provenance, never media. Cross-host relays the same JSON
463
+ * upstream and passes its answer through untouched. */
464
+ ctx.effect(() => ctx.webServer.register({
465
+ kind: 'exact',
466
+ path: '/infer-gallery/artifact-delete',
467
+ handler: async (req, res) => {
468
+ if (req.method !== 'POST') {
469
+ fail(res, 405, 'method_not_allowed', { message: 'POST {path} only' });
470
+ return;
471
+ }
472
+ try {
473
+ const body = await readJsonBody(req);
474
+ const target = typeof body?.path === 'string' ? body.path : '';
475
+ if (!isRunRelative(target)) {
476
+ fail(res, 400, 'forbidden_path', { message: 'artifact route only serves run-relative names (run-<id>/<file>)', path: target });
477
+ return;
478
+ }
479
+ if (isProtectedSidecar(target)) {
480
+ fail(res, 400, 'protected_sidecar', { message: 'sidecar files are provenance, never media — refusing', path: target });
481
+ return;
482
+ }
483
+ if (gatewayUrl !== '') {
484
+ const response = await fetch(`${gatewayUrl}/infer-gallery/artifact-delete`, {
485
+ method: 'POST',
486
+ signal: AbortSignal.timeout(timeoutMs),
487
+ headers: { 'content-type': 'application/json' },
488
+ body: JSON.stringify({ path: target }),
489
+ });
490
+ const text = await response.text();
491
+ res.writeHead(response.status, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(text)) });
492
+ res.end(text);
493
+ return;
494
+ }
495
+ const fs = await import('node:fs/promises');
496
+ const path = await import('node:path');
497
+ // §23: the unlink walks the trees (first tree holding the
498
+ // file wins — the door that lists it manages it).
499
+ const full = await resolveRunTarget(fs, path, target);
500
+ if (full === null) {
501
+ fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
502
+ return;
503
+ }
504
+ try {
505
+ await fs.unlink(full);
506
+ }
507
+ catch (error) {
508
+ const code = error.code;
509
+ if (code === 'ENOENT') {
510
+ fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
511
+ return;
512
+ }
513
+ throw error;
514
+ }
515
+ const ok = JSON.stringify({ ok: true, path: target });
516
+ res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(ok)) });
517
+ res.end(Buffer.from(ok));
518
+ }
519
+ catch (error) {
520
+ const message = error instanceof Error ? error.message : String(error);
521
+ ctx.logger.warn('infer-gallery: artifact-delete failed: %s', message);
522
+ fail(res, 502, 'artifact_unreadable', { message });
523
+ }
524
+ },
525
+ }), 'infer-gallery: /infer-gallery/artifact-delete route');
526
+ ctx.effect(() => ctx.webServer.register({
527
+ kind: 'exact',
528
+ path: '/infer-gallery/artifact-rename',
529
+ handler: async (req, res) => {
530
+ if (req.method !== 'POST') {
531
+ fail(res, 405, 'method_not_allowed', { message: 'POST {path, name} only' });
532
+ return;
533
+ }
534
+ try {
535
+ const body = await readJsonBody(req);
536
+ const target = typeof body?.path === 'string' ? body.path : '';
537
+ const name = typeof body?.name === 'string' ? body.name : '';
538
+ if (!isRunRelative(target)) {
539
+ fail(res, 400, 'forbidden_path', { message: 'artifact route only serves run-relative names (run-<id>/<file>)', path: target });
540
+ return;
541
+ }
542
+ if (isProtectedSidecar(target)) {
543
+ fail(res, 400, 'protected_sidecar', { message: 'sidecar files are provenance, never media — refusing', path: target });
544
+ return;
545
+ }
546
+ if (!isBareFileName(name)) {
547
+ fail(res, 400, 'bad_name', { message: 'name must be one bare file segment (no slashes, no leading _)', name });
548
+ return;
549
+ }
550
+ if (gatewayUrl !== '') {
551
+ const response = await fetch(`${gatewayUrl}/infer-gallery/artifact-rename`, {
552
+ method: 'POST',
553
+ signal: AbortSignal.timeout(timeoutMs),
554
+ headers: { 'content-type': 'application/json' },
555
+ body: JSON.stringify({ path: target, name }),
556
+ });
557
+ const text = await response.text();
558
+ res.writeHead(response.status, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(text)) });
559
+ res.end(text);
560
+ return;
561
+ }
562
+ const dir = target.slice(0, target.lastIndexOf('/'));
563
+ const next = dir + '/' + name;
564
+ /* v8 ignore next 3 -- `name` already passed isBareFileName, so
565
+ * the joined target always satisfies the grammar; the check
566
+ * stays as defense against future callers joining paths. */
567
+ if (!isRunRelative(next) || isProtectedSidecar(next)) {
568
+ fail(res, 400, 'bad_name', { message: 'rename target refuses the artifact grammar', name });
569
+ return;
570
+ }
571
+ if (next === target) {
572
+ fail(res, 400, 'bad_name', { message: 'rename to the same name changes nothing', name });
573
+ return;
574
+ }
575
+ const fs = await import('node:fs/promises');
576
+ const path = await import('node:path');
577
+ // §23: the rename walks the trees; the new name lands beside
578
+ // the source in the WINNING tree (a bare segment joined onto
579
+ // the source's own directory — containment holds by
580
+ // construction, the walk proved the tree).
581
+ const full = await resolveRunTarget(fs, path, target);
582
+ if (full === null) {
583
+ fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
584
+ return;
585
+ }
586
+ const fullNext = path.resolve(path.dirname(full), name);
587
+ try {
588
+ await fs.stat(full);
589
+ }
590
+ catch {
591
+ fail(res, 404, 'no_artifact', { message: 'no such artifact', path: target });
592
+ return;
593
+ }
594
+ try {
595
+ await fs.stat(fullNext);
596
+ fail(res, 409, 'already_exists', { message: 'a file already carries that name', path: next });
597
+ return;
598
+ }
599
+ catch {
600
+ // absent — the rename may proceed.
601
+ }
602
+ await fs.rename(full, fullNext);
603
+ const ok = JSON.stringify({ ok: true, path: next, url: proxyAssetUrl(next) });
604
+ res.writeHead(200, { ...JSON_HEADERS, 'content-length': String(Buffer.byteLength(ok)) });
605
+ res.end(Buffer.from(ok));
606
+ }
607
+ catch (error) {
608
+ const message = error instanceof Error ? error.message : String(error);
609
+ ctx.logger.warn('infer-gallery: artifact-rename failed: %s', message);
610
+ fail(res, 502, 'artifact_unreadable', { message });
611
+ }
612
+ },
613
+ }), 'infer-gallery: /infer-gallery/artifact-rename route');
614
+ }
615
+ /** Cross-host upstream answered an error status (its body rides). */
616
+ class GalleryUpstreamError extends Error {
617
+ body;
618
+ status;
619
+ constructor(message, body, status) {
620
+ super(message);
621
+ this.body = body;
622
+ this.status = status;
623
+ }
624
+ }
625
+ /** In-tree posture with the estate absent — the loud 502 (nothing
626
+ * absorbs a missing seat). */
627
+ class GallerySeatError extends Error {
628
+ }
629
+ //# sourceMappingURL=index.js.map