@lumenkey/cli 0.3.1 → 0.4.0-rc.1
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 +36 -2
- package/dist/cli/copied.js +86 -85
- package/dist/cli/index.js +56 -44
- package/dist/cli/render.js +3 -2
- package/dist/core/asset-stream.d.ts +16 -0
- package/dist/core/asset-stream.js +64 -0
- package/dist/core/atomic-config-file.js +8 -7
- package/dist/core/chinese-messages.d.ts +4 -0
- package/dist/core/chinese-messages.js +907 -0
- package/dist/core/chinese-script.d.ts +7 -0
- package/dist/core/chinese-script.js +28 -0
- package/dist/core/client.js +2 -1
- package/dist/core/config-dir.js +2 -1
- package/dist/core/config.js +7 -6
- package/dist/core/download-root.d.ts +5 -0
- package/dist/core/download-root.js +64 -0
- package/dist/core/download.js +15 -12
- package/dist/core/generation-input.js +3 -2
- package/dist/core/generation.d.ts +8 -1
- package/dist/core/generation.js +13 -10
- package/dist/core/job-display.js +7 -6
- package/dist/core/locale.js +163 -148
- package/dist/core/outcome.js +6 -4
- package/dist/core/output-path.d.ts +3 -1
- package/dist/core/output-path.js +5 -2
- package/dist/core/params.js +26 -25
- package/dist/core/profiles.js +5 -4
- package/dist/core/set-values.d.ts +0 -1
- package/dist/core/set-values.js +7 -7
- package/dist/core/submission-journal.js +31 -16
- package/dist/core/version.js +2 -3
- package/dist/mcp/server.js +30 -29
- package/dist/studio/asset-integrity-hash.d.ts +28 -0
- package/dist/studio/asset-integrity-hash.js +93 -0
- package/dist/studio/asset-integrity-queue.d.ts +24 -0
- package/dist/studio/asset-integrity-queue.js +94 -0
- package/dist/studio/asset-store.d.ts +52 -0
- package/dist/studio/asset-store.js +352 -0
- package/dist/studio/canvas/LICENSES.txt +1590 -0
- package/dist/studio/canvas/dependencies.json +862 -0
- package/dist/studio/canvas/director-source-manifest.json +47 -0
- package/dist/studio/canvas/editor.css +239 -0
- package/dist/studio/canvas/editor.js +4613 -0
- package/dist/studio/canvas/integration-source-manifest.json +384 -0
- package/dist/studio/canvas/source-manifest.json +240 -0
- package/dist/studio/canvas-store.d.ts +1254 -0
- package/dist/studio/canvas-store.js +205 -0
- package/dist/studio/capture-placement.d.ts +11 -0
- package/dist/studio/capture-placement.js +85 -0
- package/dist/studio/cloud-contract.d.ts +128 -0
- package/dist/studio/cloud-contract.js +229 -0
- package/dist/studio/cloud-service.d.ts +178 -0
- package/dist/studio/cloud-service.js +202 -0
- package/dist/studio/director-captures.d.ts +59 -0
- package/dist/studio/director-captures.js +141 -0
- package/dist/studio/director-contract.d.ts +1489 -0
- package/dist/studio/director-contract.js +288 -0
- package/dist/studio/director-png.d.ts +6 -0
- package/dist/studio/director-png.js +41 -0
- package/dist/studio/director-relations.d.ts +40 -0
- package/dist/studio/director-relations.js +103 -0
- package/dist/studio/document-authority.d.ts +7 -0
- package/dist/studio/document-authority.js +50 -0
- package/dist/studio/draft-store.d.ts +95 -0
- package/dist/studio/draft-store.js +100 -0
- package/dist/studio/errors.d.ts +85 -0
- package/dist/studio/errors.js +94 -0
- package/dist/studio/launch.d.ts +5 -0
- package/dist/studio/launch.js +31 -0
- package/dist/studio/media.d.ts +9 -0
- package/dist/studio/media.js +50 -0
- package/dist/studio/output-save-contract.d.ts +1321 -0
- package/dist/studio/output-save-contract.js +26 -0
- package/dist/studio/output-save-journal.d.ts +971 -0
- package/dist/studio/output-save-journal.js +287 -0
- package/dist/studio/output-save-recovery.d.ts +18 -0
- package/dist/studio/output-save-recovery.js +161 -0
- package/dist/studio/output-save-service.d.ts +436 -0
- package/dist/studio/output-save-service.js +251 -0
- package/dist/studio/output-save-worker.d.ts +40 -0
- package/dist/studio/output-save-worker.js +360 -0
- package/dist/studio/paths.d.ts +6 -0
- package/dist/studio/paths.js +68 -0
- package/dist/studio/run-journal.d.ts +993 -0
- package/dist/studio/run-journal.js +125 -0
- package/dist/studio/server.d.ts +17 -0
- package/dist/studio/server.js +440 -0
- package/dist/studio/session.d.ts +6 -0
- package/dist/studio/session.js +26 -0
- package/dist/studio/shell.d.ts +3 -0
- package/dist/studio/shell.js +23 -0
- package/dist/studio/style.d.ts +2 -0
- package/dist/studio/style.js +29 -0
- package/dist/studio/types.d.ts +26 -0
- package/dist/studio/types.js +2 -0
- package/dist/studio/web/app.d.ts +2 -0
- package/dist/studio/web/app.js +662 -0
- package/dist/studio/web/asset-controller.d.ts +37 -0
- package/dist/studio/web/asset-controller.js +191 -0
- package/dist/studio/web/asset-model.d.ts +29 -0
- package/dist/studio/web/asset-model.js +51 -0
- package/dist/studio/web/controller.d.ts +113 -0
- package/dist/studio/web/controller.js +401 -0
- package/dist/studio/web/messages.d.ts +2 -0
- package/dist/studio/web/messages.js +74 -0
- package/dist/studio/web/model.d.ts +75 -0
- package/dist/studio/web/model.js +182 -0
- package/dist/studio/web/save-controller.d.ts +85 -0
- package/dist/studio/web/save-controller.js +495 -0
- package/dist/studio/web/save-model.d.ts +63 -0
- package/dist/studio/web/save-model.js +64 -0
- package/dist/studio/web/save-queue.d.ts +7 -0
- package/dist/studio/web/save-queue.js +62 -0
- package/dist/studio/web/save-view.d.ts +9 -0
- package/dist/studio/web/save-view.js +19 -0
- package/dist/studio/writer-lock.d.ts +5 -0
- package/dist/studio/writer-lock.js +88 -0
- package/package.json +11 -5
- package/dist/cli/copied.d.ts.map +0 -1
- package/dist/cli/copied.js.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/render.d.ts.map +0 -1
- package/dist/cli/render.js.map +0 -1
- package/dist/core/atomic-config-file.d.ts.map +0 -1
- package/dist/core/atomic-config-file.js.map +0 -1
- package/dist/core/chat.d.ts.map +0 -1
- package/dist/core/chat.js.map +0 -1
- package/dist/core/client.d.ts.map +0 -1
- package/dist/core/client.js.map +0 -1
- package/dist/core/config-dir.d.ts.map +0 -1
- package/dist/core/config-dir.js.map +0 -1
- package/dist/core/config-error.d.ts.map +0 -1
- package/dist/core/config-error.js.map +0 -1
- package/dist/core/config.d.ts.map +0 -1
- package/dist/core/config.js.map +0 -1
- package/dist/core/credit-cap.d.ts.map +0 -1
- package/dist/core/credit-cap.js.map +0 -1
- package/dist/core/download.d.ts.map +0 -1
- package/dist/core/download.js.map +0 -1
- package/dist/core/error-result.d.ts.map +0 -1
- package/dist/core/error-result.js.map +0 -1
- package/dist/core/explain.d.ts.map +0 -1
- package/dist/core/explain.js.map +0 -1
- package/dist/core/generation-input.d.ts.map +0 -1
- package/dist/core/generation-input.js.map +0 -1
- package/dist/core/generation-result.d.ts.map +0 -1
- package/dist/core/generation-result.js.map +0 -1
- package/dist/core/generation.d.ts.map +0 -1
- package/dist/core/generation.js.map +0 -1
- package/dist/core/job-display.d.ts.map +0 -1
- package/dist/core/job-display.js.map +0 -1
- package/dist/core/locale.d.ts.map +0 -1
- package/dist/core/locale.js.map +0 -1
- package/dist/core/origin.d.ts.map +0 -1
- package/dist/core/origin.js.map +0 -1
- package/dist/core/outcome.d.ts.map +0 -1
- package/dist/core/outcome.js.map +0 -1
- package/dist/core/output-path.d.ts.map +0 -1
- package/dist/core/output-path.js.map +0 -1
- package/dist/core/params.d.ts.map +0 -1
- package/dist/core/params.js.map +0 -1
- package/dist/core/poller.d.ts.map +0 -1
- package/dist/core/poller.js.map +0 -1
- package/dist/core/profiles.d.ts.map +0 -1
- package/dist/core/profiles.js.map +0 -1
- package/dist/core/set-values.d.ts.map +0 -1
- package/dist/core/set-values.js.map +0 -1
- package/dist/core/submission-journal.d.ts.map +0 -1
- package/dist/core/submission-journal.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/version.d.ts.map +0 -1
- package/dist/core/version.js.map +0 -1
- package/dist/core/wait.d.ts.map +0 -1
- package/dist/core/wait.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp/render.d.ts.map +0 -1
- package/dist/mcp/render.js.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js.map +0 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, lstat, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { assertRegularFile, assertStudioRoot } from './paths.js';
|
|
6
|
+
import { prepareDocumentAuthority } from './document-authority.js';
|
|
7
|
+
import { hasFsCode, StudioError } from './errors.js';
|
|
8
|
+
import { saveOperation, legacySaveOperation, saveAcceptInput, saveMutable, integrityFact } from './output-save-contract.js';
|
|
9
|
+
import { isVerifiedHashResult } from './asset-integrity-hash.js';
|
|
10
|
+
import { uuid, isoDate } from './run-journal.js';
|
|
11
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
12
|
+
const observationValue = z.object({ file: integrityFact, checkedAt: isoDate.nullable(), integrityCheck: z.enum(['IDLE', 'PENDING']), signature: z.string().min(1).max(500).optional(), errorCode: z.enum(['SAVE_FILE_MISSING', 'SAVE_FILE_MODIFIED', 'SAVE_FILE_UNSAFE', 'SAVE_FILE_READ_FAILED']).optional() }).strict().refine(v => (v.integrityCheck !== 'PENDING' || (v.file === 'NOT_CHECKED' && !v.signature && !v.errorCode)) && (v.file !== 'VERIFIED' || (v.checkedAt !== null && v.integrityCheck === 'IDLE' && !!v.signature && !v.errorCode)));
|
|
13
|
+
const observation = z.object({ assetId: uuid, revision: z.number().int().positive().safe(), value: observationValue }).transform(v => ({ assetId: v.assetId, revision: v.revision, ...v.value }));
|
|
14
|
+
const persistedObservation = z.object({ assetId: uuid, revision: z.number().int().positive().safe() }).merge(observationValue.innerType()).strict().refine(v => {
|
|
15
|
+
const { assetId: _id, revision: _rev, ...value } = v;
|
|
16
|
+
return observationValue.safeParse(value).success;
|
|
17
|
+
});
|
|
18
|
+
const schema = z.object({ schemaVersion: z.literal(2), revision: z.number().int().nonnegative().safe(), rootId: z.string().uuid(),
|
|
19
|
+
canonicalAssetsPath: z.string().min(1),
|
|
20
|
+
connections: z.array(z.object({ connectionId: z.string().uuid(), bindingDigestRef: digest }).strict()).max(10000),
|
|
21
|
+
operations: z.array(saveOperation).max(10000),
|
|
22
|
+
integrityObservations: z.array(persistedObservation).max(100000),
|
|
23
|
+
}).strict().refine(v => {
|
|
24
|
+
const observations = new Map(v.integrityObservations.map(o => [o.assetId, o]));
|
|
25
|
+
return new Set(v.connections.map(c => c.connectionId)).size === v.connections.length && new Set(v.connections.map(c => c.bindingDigestRef)).size === v.connections.length &&
|
|
26
|
+
new Set(v.integrityObservations.map(o => o.assetId)).size === v.integrityObservations.length &&
|
|
27
|
+
new Set(v.operations.map(o => o.receipt.operationId)).size === v.operations.length && new Set(v.operations.map(o => o.assetId)).size === v.operations.length &&
|
|
28
|
+
new Set(v.operations.map(o => `${o.connectionId}:${o.receipt.localOutputId}`)).size === v.operations.length &&
|
|
29
|
+
new Set(v.operations.flatMap(o => o.requestIds)).size === v.operations.reduce((n, o) => n + o.requestIds.length, 0) &&
|
|
30
|
+
v.operations.every(o => v.connections.some(c => c.connectionId === o.connectionId)) &&
|
|
31
|
+
v.operations.every(o => {
|
|
32
|
+
const r = o.receipt, observed = observations.get(o.assetId);
|
|
33
|
+
if (r.asset && observed)
|
|
34
|
+
return r.facts.file === observed.file && r.checkedAt === observed.checkedAt && r.integrityCheck === observed.integrityCheck;
|
|
35
|
+
return r.checkedAt === null && r.integrityCheck === 'IDLE' && !['VERIFIED', 'MODIFIED'].includes(r.facts.file);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
const legacySchema = schema.innerType().omit({ integrityObservations: true }).extend({ schemaVersion: z.literal(1), operations: z.array(legacySaveOperation).max(10000) }).strict();
|
|
39
|
+
const LIMIT = 16 * 1024 * 1024;
|
|
40
|
+
/** Internal control flow, never a business failure or a new public error code. */
|
|
41
|
+
export class SaveIntegrityPending extends StudioError {
|
|
42
|
+
constructor() { super('SAVE_RETRY_NOT_ALLOWED', 409); }
|
|
43
|
+
}
|
|
44
|
+
/** Private authority; the asset-store owns the sole writer and shared lifecycle queue. */
|
|
45
|
+
export function createSaveAuthority(root, serial, hooks = {}) {
|
|
46
|
+
const file = path.join(root.managedDir, 'output-saves.json');
|
|
47
|
+
let current;
|
|
48
|
+
let coldReady = false;
|
|
49
|
+
let legacy = false;
|
|
50
|
+
async function disk() {
|
|
51
|
+
await assertStudioRoot(root);
|
|
52
|
+
try {
|
|
53
|
+
await assertRegularFile(file);
|
|
54
|
+
const handle = await open(file, 'r');
|
|
55
|
+
try {
|
|
56
|
+
if ((await handle.stat()).size > LIMIT)
|
|
57
|
+
throw new Error();
|
|
58
|
+
const raw = JSON.parse(await handle.readFile('utf8'));
|
|
59
|
+
const old = z.object({ schemaVersion: z.literal(1) }).passthrough().safeParse(raw);
|
|
60
|
+
const value = schema.parse(old.success ? { ...legacySchema.parse(raw), schemaVersion: 2, integrityObservations: [] } : raw);
|
|
61
|
+
legacy = old.success || JSON.stringify(raw) !== JSON.stringify(value);
|
|
62
|
+
if (value.canonicalAssetsPath !== root.assetsDir)
|
|
63
|
+
throw new Error();
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (hasFsCode(error, 'ENOENT'))
|
|
72
|
+
return undefined;
|
|
73
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function commit(next, initial = false) {
|
|
77
|
+
const canonical = schema.parse(next);
|
|
78
|
+
const text = JSON.stringify(canonical);
|
|
79
|
+
if (Buffer.byteLength(text) > LIMIT)
|
|
80
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
81
|
+
await assertStudioRoot(root);
|
|
82
|
+
const temp = path.join(root.managedDir, `output-saves.${randomUUID()}.tmp`);
|
|
83
|
+
try {
|
|
84
|
+
const h = await open(temp, 'wx', 0o600);
|
|
85
|
+
try {
|
|
86
|
+
await h.writeFile(text);
|
|
87
|
+
await h.sync();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await h.close();
|
|
91
|
+
}
|
|
92
|
+
await hooks.beforeCommit?.();
|
|
93
|
+
await assertStudioRoot(root);
|
|
94
|
+
if (initial) {
|
|
95
|
+
await link(temp, file);
|
|
96
|
+
await unlink(temp);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
await assertRegularFile(file);
|
|
100
|
+
await rename(temp, file);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
try {
|
|
105
|
+
await unlink(temp);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (!hasFsCode(error, 'ENOENT'))
|
|
109
|
+
throw new StudioError('SAVE_AUTHORITY_UNAVAILABLE', 503);
|
|
110
|
+
}
|
|
111
|
+
throw new StudioError('SAVE_AUTHORITY_UNAVAILABLE', 503);
|
|
112
|
+
}
|
|
113
|
+
current = canonical;
|
|
114
|
+
}
|
|
115
|
+
function applyObservation(operation, value) {
|
|
116
|
+
if (!operation.receipt.asset)
|
|
117
|
+
return operation;
|
|
118
|
+
const previous = operation.receipt;
|
|
119
|
+
const integrityError = previous.error?.type === 'integrity' && previous.error.code.startsWith('SAVE_FILE_');
|
|
120
|
+
const error = previous.error && !integrityError ? previous.error : value.errorCode ? { code: value.errorCode, message: new StudioError(value.errorCode).message, type: 'integrity', httpStatus: 409, retryable: false, mayAlreadyBeSaved: true } : null;
|
|
121
|
+
const receipt = { ...previous, facts: { ...previous.facts, file: value.file }, checkedAt: value.checkedAt, integrityCheck: value.integrityCheck, safeRetry: value.integrityCheck === 'PENDING' ? false : previous.safeRetry, error };
|
|
122
|
+
if (JSON.stringify(receipt) === JSON.stringify(previous))
|
|
123
|
+
return operation;
|
|
124
|
+
return saveOperation.parse({ ...operation, receipt: { ...receipt, revision: previous.revision + 1, updatedAt: new Date().toISOString() } });
|
|
125
|
+
}
|
|
126
|
+
async function authority(initialize) {
|
|
127
|
+
const loaded = await disk();
|
|
128
|
+
if (loaded)
|
|
129
|
+
await prepareDocumentAuthority(root, 'output-saves', true, 'SAVE_AUTHORITY_INVALID');
|
|
130
|
+
if (current && (!loaded || JSON.stringify(current) !== JSON.stringify(loaded)))
|
|
131
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
132
|
+
if (loaded) {
|
|
133
|
+
if (!coldReady) {
|
|
134
|
+
const entries = loaded.integrityObservations.map(o => ({ assetId: o.assetId, revision: o.revision + 1, file: 'NOT_CHECKED', checkedAt: o.checkedAt, integrityCheck: 'IDLE' }));
|
|
135
|
+
const changed = loaded.integrityObservations.some(o => o.file !== 'NOT_CHECKED' || o.integrityCheck !== 'IDLE' || o.signature !== undefined || o.errorCode !== undefined);
|
|
136
|
+
const reset = changed ? { ...loaded, revision: loaded.revision + 1, integrityObservations: entries, operations: loaded.operations.map(op => { const entry = entries.find(o => o.assetId === op.assetId); return entry ? applyObservation(op, entry) : op; }) } : loaded;
|
|
137
|
+
if (legacy || changed)
|
|
138
|
+
await commit(reset);
|
|
139
|
+
current = reset;
|
|
140
|
+
coldReady = true;
|
|
141
|
+
return reset;
|
|
142
|
+
}
|
|
143
|
+
current = loaded;
|
|
144
|
+
return loaded;
|
|
145
|
+
}
|
|
146
|
+
if (!initialize)
|
|
147
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
148
|
+
if ((await readdir(root.managedDir)).some(n => /^output-saves\..+\.tmp$/.test(n)))
|
|
149
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
150
|
+
await prepareDocumentAuthority(root, 'output-saves', false, 'SAVE_AUTHORITY_INVALID');
|
|
151
|
+
const empty = { schemaVersion: 2, revision: 0, rootId: randomUUID(), canonicalAssetsPath: root.assetsDir, connections: [], operations: [], integrityObservations: [] };
|
|
152
|
+
await commit(empty, true);
|
|
153
|
+
coldReady = true;
|
|
154
|
+
return empty;
|
|
155
|
+
}
|
|
156
|
+
async function updateOperation(operationId, connectionId, expectedRevision, patch, transfer, executionRevision) {
|
|
157
|
+
const before = await authority(false);
|
|
158
|
+
const operation = before.operations.find(o => o.receipt.operationId === operationId);
|
|
159
|
+
if (!operation)
|
|
160
|
+
throw new StudioError('SAVE_OPERATION_NOT_FOUND', 404);
|
|
161
|
+
if (operation.connectionId !== connectionId)
|
|
162
|
+
throw new StudioError('SAVE_BINDING_MISMATCH', 403);
|
|
163
|
+
if (executionRevision === undefined ? operation.receipt.revision !== expectedRevision : operation.executionRevision !== executionRevision)
|
|
164
|
+
throw Object.assign(new StudioError('SAVE_REVISION_CONFLICT', 409), { operation: structuredClone(operation) });
|
|
165
|
+
const checked = saveMutable.partial().safeParse(patch);
|
|
166
|
+
if (!checked.success)
|
|
167
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
168
|
+
if (operation.receipt.integrityCheck === 'PENDING' && checked.data.state === 'SAVING' && operation.receipt.state !== 'SAVING')
|
|
169
|
+
throw new SaveIntegrityPending();
|
|
170
|
+
const receipt = { ...operation.receipt, ...checked.data, revision: operation.receipt.revision + 1, updatedAt: new Date().toISOString() };
|
|
171
|
+
const edges = { ACCEPTED: ['ACCEPTED', 'SAVING', 'RECOVERY_REQUIRED', 'BLOCKED', 'FAILED'], SAVING: ['SAVING', 'SAVED', 'FAILED', 'RECOVERY_REQUIRED', 'BLOCKED'], SAVED: ['SAVED'], FAILED: ['FAILED', 'SAVING', 'RECOVERY_REQUIRED', 'BLOCKED'], RECOVERY_REQUIRED: ['RECOVERY_REQUIRED', 'SAVING', 'BLOCKED'], BLOCKED: ['BLOCKED'] };
|
|
172
|
+
if (operation.receipt.state === 'FAILED' && receipt.state === 'RECOVERY_REQUIRED') {
|
|
173
|
+
const pending = operation.transfer?.completed ? (operation.receipt.facts.registration === 'ABSENT' ? 'SAVE_REGISTRATION_PENDING' : operation.receipt.facts.registration === 'PRESENT' && operation.receipt.facts.relation === 'ABSENT' ? 'SAVE_RELATION_PENDING' : undefined) : undefined;
|
|
174
|
+
if (!pending || receipt.error?.code !== pending)
|
|
175
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
176
|
+
}
|
|
177
|
+
if (!edges[operation.receipt.state].includes(receipt.state) || (receipt.state === 'SAVING' && operation.receipt.state !== 'SAVING' && (receipt.attempt !== operation.receipt.attempt + 1 || (operation.receipt.state !== 'ACCEPTED' && !operation.receipt.safeRetry))) || (receipt.state === 'SAVING' && operation.receipt.state === 'SAVING' && receipt.attempt !== operation.receipt.attempt))
|
|
178
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
179
|
+
let observed = before.integrityObservations.find(o => o.assetId === operation.assetId);
|
|
180
|
+
let integrityObservations = before.integrityObservations;
|
|
181
|
+
// A negative filesystem finding may invalidate a completed observation; it
|
|
182
|
+
// cannot mint VERIFIED. Do not cancel/replace a running hash's observation.
|
|
183
|
+
if (observed?.integrityCheck === 'IDLE' && checked.data.facts && ['UNSAFE', 'UNKNOWN', 'MISSING'].includes(checked.data.facts.file)) {
|
|
184
|
+
const file = checked.data.facts.file;
|
|
185
|
+
observed = { assetId: operation.assetId, revision: observed.revision + 1, file, checkedAt: observed.checkedAt, integrityCheck: 'IDLE', errorCode: { UNSAFE: 'SAVE_FILE_UNSAFE', UNKNOWN: 'SAVE_FILE_READ_FAILED', MISSING: 'SAVE_FILE_MISSING' }[file] };
|
|
186
|
+
integrityObservations = before.integrityObservations.map(o => o.assetId === operation.assetId ? observed : o);
|
|
187
|
+
}
|
|
188
|
+
const candidate = { ...operation, executionRevision: operation.executionRevision + 1, ...(transfer ? { transfer } : {}), receipt };
|
|
189
|
+
const projected = observed ? applyObservation(candidate, observed) : candidate;
|
|
190
|
+
const parsed = saveOperation.safeParse({ ...projected, receipt: { ...projected.receipt, revision: receipt.revision } });
|
|
191
|
+
if (!parsed.success)
|
|
192
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
193
|
+
await commit({ ...before, revision: before.revision + 1, integrityObservations, operations: before.operations.map(o => o === operation ? parsed.data : o) });
|
|
194
|
+
return structuredClone(parsed.data);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
readIntegrity: () => serial(async () => {
|
|
198
|
+
const loaded = await disk();
|
|
199
|
+
if (!loaded) {
|
|
200
|
+
try {
|
|
201
|
+
await lstat(path.join(root.managedDir, '.output-saves.initialized'));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (hasFsCode(error, 'ENOENT') && !current)
|
|
205
|
+
return undefined;
|
|
206
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
207
|
+
}
|
|
208
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
209
|
+
}
|
|
210
|
+
return structuredClone(await authority(false));
|
|
211
|
+
}),
|
|
212
|
+
observeIntegrity: (assetId, expectedRevision, value, proof) => serial(async () => {
|
|
213
|
+
if (!uuid.safeParse(assetId).success || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0)
|
|
214
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
215
|
+
const checked = observationValue.safeParse(value);
|
|
216
|
+
if (!checked.success)
|
|
217
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
218
|
+
if (value.file === 'VERIFIED' && (!isVerifiedHashResult(assetId, proof) || proof.checkedAt !== value.checkedAt || proof.signature !== value.signature))
|
|
219
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
220
|
+
const before = await authority(true);
|
|
221
|
+
const previous = before.integrityObservations.find(o => o.assetId === assetId);
|
|
222
|
+
if ((previous?.revision ?? 0) !== expectedRevision)
|
|
223
|
+
throw new StudioError('SAVE_REVISION_CONFLICT', 409);
|
|
224
|
+
if (value.file !== 'VERIFIED' && value.checkedAt !== (previous?.checkedAt ?? null))
|
|
225
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
226
|
+
const next = observation.parse({ assetId, revision: expectedRevision + 1, value: checked.data });
|
|
227
|
+
await commit({ ...before, revision: before.revision + 1, integrityObservations: [...before.integrityObservations.filter(o => o.assetId !== assetId), next], operations: before.operations.map(o => o.assetId === assetId ? applyObservation(o, value) : o) });
|
|
228
|
+
return structuredClone(next);
|
|
229
|
+
}),
|
|
230
|
+
accept: input => serial(async () => {
|
|
231
|
+
const parsed = saveAcceptInput.safeParse(input);
|
|
232
|
+
if (!parsed.success)
|
|
233
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
234
|
+
const req = parsed.data;
|
|
235
|
+
const before = await authority(false);
|
|
236
|
+
if (!before.connections.some(c => c.connectionId === req.connectionId))
|
|
237
|
+
throw new StudioError('SAVE_BINDING_MISMATCH', 403);
|
|
238
|
+
const replay = before.operations.find(o => o.requestIds.includes(req.requestId));
|
|
239
|
+
if (replay && (replay.connectionId !== req.connectionId || replay.receipt.localOutputId !== req.localOutputId || replay.receipt.runId !== req.runId || replay.receipt.jobId !== req.jobId || replay.receipt.gatewayAssetId !== req.gatewayAssetId))
|
|
240
|
+
throw new StudioError('SAVE_REQUEST_CONFLICT', 409);
|
|
241
|
+
const existing = before.operations.find(o => o.connectionId === req.connectionId && o.receipt.localOutputId === req.localOutputId);
|
|
242
|
+
if (existing) {
|
|
243
|
+
if (existing.receipt.runId !== req.runId || existing.receipt.jobId !== req.jobId || existing.receipt.gatewayAssetId !== req.gatewayAssetId)
|
|
244
|
+
throw new StudioError('SAVE_REQUEST_CONFLICT', 409);
|
|
245
|
+
if (!existing.requestIds.includes(req.requestId)) {
|
|
246
|
+
if (existing.requestIds.length >= 1000)
|
|
247
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
248
|
+
const next = { ...existing, requestIds: [...existing.requestIds, req.requestId] };
|
|
249
|
+
await commit({ ...before, revision: before.revision + 1, operations: before.operations.map(o => o === existing ? next : o) });
|
|
250
|
+
return structuredClone(next);
|
|
251
|
+
}
|
|
252
|
+
return structuredClone(existing);
|
|
253
|
+
}
|
|
254
|
+
if (before.operations.length >= 10000)
|
|
255
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
256
|
+
const now = new Date().toISOString();
|
|
257
|
+
const operation = { connectionId: req.connectionId, requestIds: [req.requestId], assetId: randomUUID(), executionRevision: 1, receipt: {
|
|
258
|
+
operationId: randomUUID(), revision: 1, localOutputId: req.localOutputId, runId: req.runId, jobId: req.jobId, gatewayAssetId: req.gatewayAssetId,
|
|
259
|
+
state: 'ACCEPTED', stage: 'QUEUED', attempt: 0, facts: { file: 'NOT_CHECKED', registration: 'ABSENT', relation: 'ABSENT' }, asset: null, error: null,
|
|
260
|
+
retryable: false, safeRetry: false, mayAlreadyBeSaved: false, checkedAt: null, integrityCheck: 'IDLE', createdAt: now, updatedAt: now
|
|
261
|
+
} };
|
|
262
|
+
await commit({ ...before, revision: before.revision + 1, operations: [...before.operations, operation] });
|
|
263
|
+
return structuredClone(operation);
|
|
264
|
+
}),
|
|
265
|
+
update: (...args) => serial(() => updateOperation(...args)),
|
|
266
|
+
advanceWorker: (expected, patch, transfer) => serial(() => {
|
|
267
|
+
const checked = saveOperation.safeParse(expected);
|
|
268
|
+
if (!checked.success)
|
|
269
|
+
throw new StudioError('SAVE_TRANSITION_INVALID', 409);
|
|
270
|
+
return updateOperation(expected.receipt.operationId, expected.connectionId, expected.receipt.revision, patch, transfer, checked.data.executionRevision);
|
|
271
|
+
}),
|
|
272
|
+
read: () => serial(async () => structuredClone(await authority(false))),
|
|
273
|
+
bind: bindingDigestRef => serial(async () => {
|
|
274
|
+
if (!digest.safeParse(bindingDigestRef).success)
|
|
275
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
276
|
+
const before = await authority(true);
|
|
277
|
+
if (before.connections.some(c => c.bindingDigestRef === bindingDigestRef))
|
|
278
|
+
return structuredClone(before);
|
|
279
|
+
if (before.connections.length >= 10000)
|
|
280
|
+
throw new StudioError('SAVE_AUTHORITY_INVALID', 409);
|
|
281
|
+
const next = { ...before, revision: before.revision + 1, connections: [...before.connections, { connectionId: randomUUID(), bindingDigestRef }] };
|
|
282
|
+
await commit(next);
|
|
283
|
+
return structuredClone(next);
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=output-save-journal.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SaveOperation } from './output-save-contract.js';
|
|
2
|
+
/** A partial is disposable only after its exclusive-open inode was durably
|
|
3
|
+
* recorded. Pre-open names alone never confer ownership of a user file. */
|
|
4
|
+
export declare function reconcilePartialSaveFile(outputDir: string, value: SaveOperation): Promise<void>;
|
|
5
|
+
/** Local-only ownership recovery, not phase-3 integrity observation or network
|
|
6
|
+
* retry. Does not register, overwrite, or delete any unproved file. */
|
|
7
|
+
export declare function reconcilePublishedSaveFile(outputDir: string, value: SaveOperation, maxBytes: number, afterLinkBeforeUnlink?: () => void | Promise<void>, inspectOnly?: boolean): Promise<{
|
|
8
|
+
absolutePath: string;
|
|
9
|
+
completed: {
|
|
10
|
+
sha256: string;
|
|
11
|
+
byteLength: number;
|
|
12
|
+
mediaType: "image/png" | "image/jpeg" | "image/webp" | "video/mp4" | "video/webm";
|
|
13
|
+
relativePath: string;
|
|
14
|
+
};
|
|
15
|
+
repairedOwnedPair: boolean;
|
|
16
|
+
publishedFromTemporary: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
//# sourceMappingURL=output-save-recovery.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, open, unlink, readdir, link } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { saveOperation } from './output-save-contract.js';
|
|
5
|
+
import { assertStudioRoot } from './paths.js';
|
|
6
|
+
import { detectStudioMediaType } from './media.js';
|
|
7
|
+
import { hasFsCode, StudioError } from './errors.js';
|
|
8
|
+
/** A partial is disposable only after its exclusive-open inode was durably
|
|
9
|
+
* recorded. Pre-open names alone never confer ownership of a user file. */
|
|
10
|
+
export async function reconcilePartialSaveFile(outputDir, value) {
|
|
11
|
+
const parsed = saveOperation.safeParse(value);
|
|
12
|
+
if (!parsed.success)
|
|
13
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
14
|
+
const op = parsed.data;
|
|
15
|
+
if (op.transfer?.completed)
|
|
16
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
17
|
+
const canonicalOutputDir = path.resolve(outputDir);
|
|
18
|
+
const managedDir = path.join(canonicalOutputDir, 'LumenKey');
|
|
19
|
+
const root = { canonicalOutputDir, managedDir, assetsDir: path.join(managedDir, 'assets') };
|
|
20
|
+
await assertStudioRoot(root);
|
|
21
|
+
const names = (await readdir(root.assetsDir)).filter(n => n.startsWith(`${op.assetId}.`));
|
|
22
|
+
if (names.some(n => n !== op.transfer?.temporaryName))
|
|
23
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
24
|
+
if (!names.length)
|
|
25
|
+
return;
|
|
26
|
+
const transfer = op.transfer;
|
|
27
|
+
if (!transfer?.owner)
|
|
28
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
29
|
+
const file = path.join(root.assetsDir, transfer.temporaryName);
|
|
30
|
+
const matches = (s) => s.isFile() && !s.isSymbolicLink() && s.nlink === 1 && String(s.dev) === transfer.owner.dev && String(s.ino) === transfer.owner.ino && s.birthtimeMs === transfer.owner.birthtimeMs;
|
|
31
|
+
if (!matches(await lstat(file)))
|
|
32
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
33
|
+
const h = await open(file, 'r');
|
|
34
|
+
try {
|
|
35
|
+
const before = await h.stat();
|
|
36
|
+
const current = await lstat(file);
|
|
37
|
+
if (!matches(before) || !matches(current) || before.size !== current.size || before.mtimeMs !== current.mtimeMs)
|
|
38
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
39
|
+
await assertStudioRoot(root);
|
|
40
|
+
const last = await lstat(file);
|
|
41
|
+
if (!matches(last) || last.size !== before.size || last.mtimeMs !== before.mtimeMs)
|
|
42
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
43
|
+
await unlink(file);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await h.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Local-only ownership recovery, not phase-3 integrity observation or network
|
|
50
|
+
* retry. Does not register, overwrite, or delete any unproved file. */
|
|
51
|
+
export async function reconcilePublishedSaveFile(outputDir, value, maxBytes, afterLinkBeforeUnlink, inspectOnly = false) {
|
|
52
|
+
const parsed = saveOperation.safeParse(value);
|
|
53
|
+
if (!parsed.success || !parsed.data.transfer?.completed)
|
|
54
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
55
|
+
const op = parsed.data;
|
|
56
|
+
const transfer = op.transfer;
|
|
57
|
+
const completed = transfer.completed;
|
|
58
|
+
const limit = Math.min(128 * 1024 * 1024, maxBytes);
|
|
59
|
+
if (!Number.isSafeInteger(limit) || completed.byteLength > limit)
|
|
60
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
61
|
+
const canonicalOutputDir = path.resolve(outputDir);
|
|
62
|
+
const root = { canonicalOutputDir, managedDir: path.join(canonicalOutputDir, 'LumenKey'), assetsDir: path.join(canonicalOutputDir, 'LumenKey', 'assets') };
|
|
63
|
+
await assertStudioRoot(root);
|
|
64
|
+
const absolutePath = path.join(root.managedDir, completed.relativePath);
|
|
65
|
+
const temporaryPath = path.join(root.assetsDir, transfer.temporaryName);
|
|
66
|
+
let unpublished = false;
|
|
67
|
+
let candidate = absolutePath;
|
|
68
|
+
let finalStat;
|
|
69
|
+
try {
|
|
70
|
+
finalStat = await lstat(absolutePath);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!hasFsCode(error, 'ENOENT'))
|
|
74
|
+
throw error;
|
|
75
|
+
if (inspectOnly)
|
|
76
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
77
|
+
unpublished = true;
|
|
78
|
+
candidate = temporaryPath;
|
|
79
|
+
try {
|
|
80
|
+
finalStat = await lstat(temporaryPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!finalStat.isFile() || finalStat.isSymbolicLink() || ![1, 2].includes(finalStat.nlink))
|
|
87
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
88
|
+
if (transfer.owner && (String(finalStat.dev) !== transfer.owner.dev || String(finalStat.ino) !== transfer.owner.ino || finalStat.birthtimeMs !== transfer.owner.birthtimeMs))
|
|
89
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
90
|
+
if (unpublished && finalStat.nlink !== 1)
|
|
91
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
92
|
+
let temporaryStat;
|
|
93
|
+
try {
|
|
94
|
+
temporaryStat = await lstat(temporaryPath);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!hasFsCode(error, 'ENOENT'))
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
const same = (a, b) => a.ino !== 0 && a.dev === b.dev && a.ino === b.ino;
|
|
101
|
+
const pair = finalStat.nlink === 2 && temporaryStat?.isFile() && !temporaryStat.isSymbolicLink() && temporaryStat.nlink === 2 && same(finalStat, temporaryStat);
|
|
102
|
+
if (!unpublished && ((finalStat.nlink === 2 && !pair) || (temporaryStat && !pair)))
|
|
103
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
104
|
+
const h = await open(candidate, 'r');
|
|
105
|
+
try {
|
|
106
|
+
const before = await h.stat();
|
|
107
|
+
if (!same(before, finalStat) || before.size !== completed.byteLength || before.nlink !== finalStat.nlink)
|
|
108
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
109
|
+
const hash = createHash('sha256');
|
|
110
|
+
const buffer = Buffer.alloc(256 * 1024);
|
|
111
|
+
let offset = 0;
|
|
112
|
+
let prefix = Buffer.alloc(0);
|
|
113
|
+
for (;;) {
|
|
114
|
+
const { bytesRead } = await h.read(buffer, 0, Math.min(buffer.length, limit + 1 - offset), offset);
|
|
115
|
+
if (!bytesRead)
|
|
116
|
+
break;
|
|
117
|
+
offset += bytesRead;
|
|
118
|
+
if (offset > limit || offset > completed.byteLength)
|
|
119
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
120
|
+
if (!prefix.length)
|
|
121
|
+
prefix = Buffer.from(buffer.subarray(0, Math.min(4096, bytesRead)));
|
|
122
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
123
|
+
}
|
|
124
|
+
if (offset !== completed.byteLength || hash.digest('hex') !== completed.sha256 || detectStudioMediaType(prefix) !== completed.mediaType)
|
|
125
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
126
|
+
const after = await h.stat();
|
|
127
|
+
const current = await lstat(candidate);
|
|
128
|
+
const stable = (s) => same(before, s) && s.size === before.size && s.mtimeMs === before.mtimeMs && s.ctimeMs === before.ctimeMs && s.nlink === before.nlink && s.isFile() && !s.isSymbolicLink();
|
|
129
|
+
if (!stable(after) || !stable(current))
|
|
130
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
131
|
+
await assertStudioRoot(root);
|
|
132
|
+
if (!inspectOnly && (pair || unpublished)) {
|
|
133
|
+
const tempNow = await lstat(temporaryPath);
|
|
134
|
+
if (!stable(tempNow))
|
|
135
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
136
|
+
if (unpublished) {
|
|
137
|
+
// Exclusive publication only after transport checkpoint/hash/type proof.
|
|
138
|
+
try {
|
|
139
|
+
await link(temporaryPath, absolutePath);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (hasFsCode(error, 'EEXIST'))
|
|
143
|
+
throw new StudioError('SAVE_TARGET_CONFLICT', 409);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
await afterLinkBeforeUnlink?.();
|
|
147
|
+
}
|
|
148
|
+
await unlink(temporaryPath);
|
|
149
|
+
const single = await lstat(absolutePath);
|
|
150
|
+
const handleSingle = await h.stat();
|
|
151
|
+
if (!same(before, single) || !same(before, handleSingle) || single.nlink !== 1 || handleSingle.nlink !== 1 || single.size !== before.size || single.mtimeMs !== before.mtimeMs)
|
|
152
|
+
throw new StudioError('SAVE_PATH_UNSAFE', 409);
|
|
153
|
+
}
|
|
154
|
+
// No checkedAt / VERIFIED fact is returned by this repair primitive.
|
|
155
|
+
return { absolutePath, completed: structuredClone(completed), repairedOwnedPair: !!pair, publishedFromTemporary: unpublished };
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await h.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=output-save-recovery.js.map
|