@alchemint/fuse-cli 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,257 @@
1
+ import path from 'node:path';
2
+ import { CliError, need, id } from './core.mjs';
3
+ import { uploadWithReceipt } from './assets.mjs';
4
+
5
+ // DEV-2053 / DEV-2058: a character's, scene's or prop's images live on the
6
+ // library Asset it points to — one 角色設定圖/主圖 (versions, one current)
7
+ // plus 其他圖片; every Creative that points at the Asset sees all of them
8
+ // (DEV-2077 retired the per-Creative picks).
9
+ const ROLES = ['setting', 'other'];
10
+
11
+ const NOUNS = {
12
+ character: {
13
+ flag: '--character',
14
+ option: 'character',
15
+ idKey: 'character_id',
16
+ segment: 'characters',
17
+ readList: 'characters',
18
+ setting: '角色設定圖',
19
+ elementsOf: (elements) =>
20
+ elements.characters.map((row) => ({ id: row.id, name: row.name })),
21
+ },
22
+ art: {
23
+ flag: '--element',
24
+ option: 'element',
25
+ idKey: 'element_id',
26
+ segment: 'art-elements',
27
+ readList: 'art_elements',
28
+ setting: '主圖',
29
+ elementsOf: (elements) => [
30
+ ...elements.scene_settings.map((row) => ({
31
+ id: row.id,
32
+ name: row.name,
33
+ type: 'scene',
34
+ })),
35
+ ...elements.props.map((row) => ({
36
+ id: row.id,
37
+ name: row.name,
38
+ type: 'prop',
39
+ })),
40
+ ],
41
+ },
42
+ };
43
+
44
+ const elementImages = (client, creativeId) =>
45
+ client.get(`/storyboards/${id(creativeId, '--creative')}/image-slots`);
46
+ const librarySummary = (asset) =>
47
+ asset && {
48
+ asset_id: asset.asset_id,
49
+ name: asset.name,
50
+ usable: asset.usable,
51
+ ...(asset.usable
52
+ ? { setting_result_id: asset.representation_result_id }
53
+ : { reason: asset.reason }),
54
+ };
55
+ const imagesSummary = (images) => ({
56
+ ready: images.ready,
57
+ library_asset: librarySummary(images.library_asset),
58
+ setting: {
59
+ current_result_id: images.setting.current_version_id,
60
+ versions: images.setting.versions.map((version) => ({
61
+ result_id: version.id,
62
+ source: version.source,
63
+ status: version.status,
64
+ upload_name: version.upload_name,
65
+ })),
66
+ },
67
+ other_images: images.other_images.map((image) => ({
68
+ result_id: image.id,
69
+ name: image.name,
70
+ })),
71
+ });
72
+ // Every image of the element as it reads now: setting versions and other images.
73
+ const imageIdsOf = (images) => [
74
+ ...images.setting.versions.map((version) => version.id),
75
+ ...images.other_images.map((image) => image.id),
76
+ ];
77
+
78
+ function command(nounKey) {
79
+ const noun = NOUNS[nounKey];
80
+ const imagesOf = (read, elementId) =>
81
+ read[noun.readList].find((item) => item[noun.idKey] === elementId);
82
+ const endpoint = (creativeId, elementId) =>
83
+ `/storyboards/${id(creativeId, '--creative')}/story-elements/${noun.segment}/${id(elementId, noun.flag)}`;
84
+
85
+ // image-uploads: setting → a candidate 角色設定圖/主圖 version on the
86
+ // element's Asset; other → a 其他圖片 on the Asset.
87
+ // The first upload creates the library Asset. The receipt keeps the images
88
+ // from before the upload, so the new one identifies where it landed.
89
+ const upload = (options, context) => ({
90
+ kind: `${nounKey}_image_upload`,
91
+ command: `${nounKey} upload`,
92
+ async metadata(media) {
93
+ const role = need(options.role, '--role (setting|other)');
94
+ if (!ROLES.includes(role))
95
+ throw new CliError(2, 'INVALID_ROLE', 'Use --role setting or other.');
96
+ return {
97
+ creative_id: need(options.creative, '--creative'),
98
+ [noun.idKey]: need(options[noun.option], noun.flag),
99
+ role,
100
+ name: (options.name || path.basename(media)).slice(0, 100),
101
+ };
102
+ },
103
+ async accept(receipt, save) {
104
+ const { creative_id, role, name } = receipt.metadata;
105
+ const elementId = receipt.metadata[noun.idKey];
106
+ if (!receipt.images_before) {
107
+ const before = imagesOf(
108
+ await elementImages(context.client, creative_id),
109
+ elementId,
110
+ );
111
+ receipt.images_before = imageIdsOf(before);
112
+ await save();
113
+ }
114
+ const after = imagesOf(
115
+ await context.client.post(
116
+ `${endpoint(creative_id, elementId)}/image-uploads`,
117
+ { receipt_id: receipt.ready_receipt_id, role, name },
118
+ ),
119
+ elementId,
120
+ );
121
+ const added = imageIdsOf(after).filter(
122
+ (imageId) => !receipt.images_before.includes(imageId),
123
+ );
124
+ if (added.length !== 1)
125
+ throw new CliError(
126
+ 5,
127
+ 'UPLOAD_IMAGE_UNKNOWN',
128
+ `The upload was accepted, but the image is not identifiable; read fuse ${nounKey} list.`,
129
+ );
130
+ receipt.result_id = added[0];
131
+ receipt.asset_id = after.library_asset.asset_id;
132
+ },
133
+ async result(receipt, file) {
134
+ const { creative_id, role } = receipt.metadata;
135
+ const elementId = receipt.metadata[noun.idKey];
136
+ const images = imagesOf(
137
+ await elementImages(context.client, creative_id),
138
+ elementId,
139
+ );
140
+ const landed =
141
+ role === 'setting'
142
+ ? images.setting.versions.some(
143
+ (version) => version.id === receipt.result_id,
144
+ )
145
+ : images.other_images.some((image) => image.id === receipt.result_id);
146
+ if (!landed || images.library_asset?.asset_id !== receipt.asset_id)
147
+ throw new CliError(
148
+ 4,
149
+ 'UPLOAD_READBACK_DIFFERS',
150
+ `The uploaded image does not read back on the ${nounKey}.`,
151
+ );
152
+ return {
153
+ creative_id,
154
+ [noun.idKey]: elementId,
155
+ role,
156
+ asset_id: receipt.asset_id,
157
+ result_id: receipt.result_id,
158
+ current: images.setting.current_version_id === receipt.result_id,
159
+ receipt: file,
160
+ upload_session_id: receipt.session_id,
161
+ ready_receipt_id: receipt.ready_receipt_id,
162
+ };
163
+ },
164
+ });
165
+
166
+ return async (action, positional, options, context) => {
167
+ if (action === 'list') {
168
+ const creativeId = need(options.creative, '--creative');
169
+ const [elements, read] = await Promise.all([
170
+ context.client.get(`/storyboards/${id(creativeId)}/story-elements`),
171
+ elementImages(context.client, creativeId),
172
+ ]);
173
+ if (options.full) return { story_elements: elements, images: read };
174
+ return {
175
+ creative_id: creativeId,
176
+ [noun.readList]: noun.elementsOf(elements).map((row) => ({
177
+ [noun.idKey]: row.id,
178
+ name: row.name,
179
+ ...(row.type ? { type: row.type } : {}),
180
+ ...imagesSummary(imagesOf(read, row.id)),
181
+ })),
182
+ };
183
+ }
184
+ if (action === 'upload')
185
+ return uploadWithReceipt(options, context, upload(options, context));
186
+ const creativeId = need(options.creative, '--creative');
187
+ const elementId = need(options[noun.option], noun.flag);
188
+ const base = endpoint(creativeId, elementId);
189
+ const done = (read, expected, code, message) => {
190
+ const images = imagesOf(read, elementId);
191
+ if (!expected(images)) throw new CliError(4, code, message);
192
+ return {
193
+ creative_id: creativeId,
194
+ [noun.idKey]: elementId,
195
+ ...imagesSummary(images),
196
+ };
197
+ };
198
+ if (action === 'adopt') {
199
+ const resultId = need(options.result, '--result');
200
+ return done(
201
+ await context.client.post(`${base}/setting-image`, {
202
+ result_id: resultId,
203
+ }),
204
+ (images) => images.setting.current_version_id === resultId,
205
+ 'ADOPTION_READBACK_DIFFERS',
206
+ `The ${nounKey} does not read back the adopted ${noun.setting}.`,
207
+ );
208
+ }
209
+ if (action === 'link') {
210
+ const assetId = need(options.asset, '--asset');
211
+ return done(
212
+ await context.client.request('PUT', `${base}/library-asset`, {
213
+ asset_id: assetId,
214
+ }),
215
+ (images) => images.library_asset?.asset_id === assetId,
216
+ 'LINK_READBACK_DIFFERS',
217
+ `The ${nounKey} does not read back the requested library Asset.`,
218
+ );
219
+ }
220
+ // image delete (DEV-2176): the image lives on the element's library Asset,
221
+ // so the delete goes to the Asset. The element read before it names the
222
+ // Asset and proves the image is one of this element's images now; the read
223
+ // after it proves the image is gone.
224
+ if (action === 'image' && positional === 'delete') {
225
+ const resultId = need(options.result, '--result');
226
+ const before = imagesOf(
227
+ await elementImages(context.client, creativeId),
228
+ elementId,
229
+ );
230
+ if (!before.library_asset || !imageIdsOf(before).includes(resultId))
231
+ throw new CliError(
232
+ 4,
233
+ 'IMAGE_NOT_FOUND',
234
+ `The ${nounKey} has no image ${resultId}; read fuse ${nounKey} list.`,
235
+ );
236
+ await context.client.request(
237
+ 'DELETE',
238
+ `/assets/${id(before.library_asset.asset_id, 'asset')}/images/${id(resultId, '--result')}`,
239
+ );
240
+ const after = done(
241
+ await elementImages(context.client, creativeId),
242
+ (images) => !imageIdsOf(images).includes(resultId),
243
+ 'DELETE_READBACK_DIFFERS',
244
+ `The ${nounKey} still reads back image ${resultId}.`,
245
+ );
246
+ return { ...after, deleted_result_id: resultId };
247
+ }
248
+ throw new CliError(
249
+ 2,
250
+ 'UNKNOWN_COMMAND',
251
+ `Use ${nounKey} list, upload, adopt, link, or image delete.`,
252
+ );
253
+ };
254
+ }
255
+
256
+ export const character = command('character');
257
+ export const art = command('art');
package/src/main.mjs ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import {
4
+ apiBase,
5
+ Client,
6
+ CliError,
7
+ loadCredential,
8
+ errorData,
9
+ project,
10
+ } from './core.mjs';
11
+ import { auth } from './auth.mjs';
12
+ import { models, videoSubmit, job, cancel } from './video.mjs';
13
+ import { download } from './download.mjs';
14
+ import { batch } from './batch.mjs';
15
+ import { assets } from './assets.mjs';
16
+ import { art, character } from './element-images.mjs';
17
+ import { creative, script, shot, take } from './creative.mjs';
18
+ import { prompt } from './prompt.mjs';
19
+ import { version } from './versions.mjs';
20
+
21
+ const stringFlags = [
22
+ 'api-url',
23
+ 'fields',
24
+ 'device-name',
25
+ 'timeout',
26
+ 'stage',
27
+ 'key',
28
+ 'request-file',
29
+ 'receipt',
30
+ 'creative',
31
+ 'generation-target',
32
+ 'entry',
33
+ 'shot',
34
+ 'job',
35
+ 'result',
36
+ 'asset',
37
+ 'out',
38
+ 'state',
39
+ 'limit',
40
+ 'idempotency-key',
41
+ 'file',
42
+ 'version-id',
43
+ 'episode',
44
+ 'name',
45
+ 'seed-file',
46
+ 'page',
47
+ 'cursor',
48
+ 'type',
49
+ 'content-type',
50
+ 'pricing-key',
51
+ 'character',
52
+ 'element',
53
+ 'kind',
54
+ 'number',
55
+ 'from',
56
+ 'to',
57
+ ];
58
+ const booleanFlags = [
59
+ 'json',
60
+ 'help',
61
+ 'version',
62
+ 'no-browser',
63
+ 'full',
64
+ 'resume',
65
+ 'retry-rejected',
66
+ ];
67
+ const help = `FUSE CLI 0.1.0 — Node 22; normal FUSE API, no GPU or database access.
68
+
69
+ fuse auth login [--no-browser] [--device-name NAME] # browser approval, independent session
70
+ fuse auth status | auth logout # read identity / revoke this session
71
+ fuse models list --stage video | models show --key EXACT_PRICING_KEY
72
+ fuse video submit --creative UUID --request-file request.json --receipt take.json
73
+ fuse video submit --receipt take.json --resume # reconcile exact saved body/key
74
+ fuse video submit --receipt take.json --resume --retry-rejected # explicitly retry an unaccepted rejection
75
+ fuse job show --receipt take.json
76
+ fuse job watch --receipt take.json --timeout 30m # read only; exiting does not cancel
77
+ fuse job cancel --receipt take.json | job cancel --job UUID # queued in FUSE (refunds the owner), or an open-source take already on its machine (no charge; the machine is told to stop)
78
+ fuse download take --receipt take.json --out take.mp4
79
+ fuse download take --creative UUID --shot N [--result UUID] --out take.mp4 # selected take unless --result
80
+ fuse download asset --asset UUID --result UUID --out asset.png
81
+ fuse batch plan comparison.json --out comparison.plan.json # read API; freeze inputs
82
+ fuse batch run comparison.plan.json --state ./run --timeout 2h
83
+ fuse batch status ./run | batch resume ./run | batch download ./run --out ./videos
84
+ fuse batch cancel ./run # stop the batch writer first; cancels queued Jobs and open-source takes on their machine, withdraws unsubmitted items
85
+
86
+ fuse asset list [--cursor UUID] [--type character|scene|prop]
87
+ fuse asset show --asset UUID [--out asset.json]
88
+ fuse asset upload --file previz.mp4 --content-type video/mp4 --request-file asset.json --receipt upload.json
89
+ fuse asset upload --receipt upload.json --resume # same Upload session, ready then Asset adoption
90
+ fuse asset link --asset UUID --request-file links.json # {creative_ids,asset_ids}
91
+ fuse asset unlink --asset UUID --creative UUID # remove from that Creative's collection; stays in the library
92
+ fuse asset canonical --asset UUID --result UUID # make that setting-image version current
93
+ fuse asset update --asset UUID --name NAME
94
+ fuse character list --creative UUID # each character's library Asset, 角色設定圖 versions and 其他圖片
95
+ fuse character upload --creative UUID --character UUID --role setting|other --file image.png --content-type image/png --receipt upload.json [--name NAME]
96
+ # setting: candidate 角色設定圖 version; other: a 其他圖片 on the Asset
97
+ fuse character upload --receipt upload.json --resume # same Upload session and acceptance
98
+ fuse character adopt --creative UUID --character UUID --result UUID # make that 角色設定圖 version current on the Asset
99
+ fuse character link --creative UUID --character UUID --asset UUID # point the character at another library character Asset
100
+ fuse character image delete --creative UUID --character UUID --result UUID # delete an uploaded image: a candidate 角色設定圖 version or a 其他圖片
101
+ fuse art list --creative UUID # each scene/prop's library Asset, 主圖 versions and 其他圖片
102
+ fuse art upload --creative UUID --element UUID --role setting|other --file image.png --content-type image/png --receipt upload.json [--name NAME]
103
+ # setting: candidate 主圖 version; other: a 其他圖片 on the Asset
104
+ fuse art upload --receipt upload.json --resume # same Upload session and acceptance
105
+ fuse art adopt --creative UUID --element UUID --result UUID # make that 主圖 version current on the Asset
106
+ fuse art link --creative UUID --element UUID --asset UUID # point the scene/prop at another library Asset of the same type
107
+ fuse art image delete --creative UUID --element UUID --result UUID # delete an uploaded image: a candidate 主圖 version or a 其他圖片
108
+ fuse creative create --seed-file seed.txt --entry idea|script --generation-target script|video --receipt creative.json
109
+ # idea: pitch_package then Episode scripts; script: imported verbatim (video adds storyboards)
110
+ fuse creative list --page 1 --limit 20 | creative show --creative UUID
111
+ fuse creative rename --creative UUID --name NAME
112
+ fuse script get --creative UUID --version-id UUID [--out script.json] # lists each Episode: number, episode_id, title, shot_count, editing
113
+ fuse script get --creative UUID --version-id UUID --episode N|UUID --out episode.json # one Episode as a script put body
114
+ fuse script put --creative UUID --version-id UUID --episode N|UUID --file episode.json # {shots,deleted_shot_ids}: one Episode
115
+ fuse script episode add --creative UUID --version-id UUID --name TITLE # appended as the last Episode
116
+ fuse script episode rename --creative UUID --version-id UUID --episode N|UUID --name TITLE
117
+ fuse script episode move --creative UUID --version-id UUID --episode N|UUID --to N # it becomes Episode N
118
+ fuse shot update --creative UUID --shot N --request-file shot.json
119
+ fuse shot move --creative UUID --shot N --request-file position.json # {position}: playback order
120
+ fuse version list --creative UUID --shot N [--stage i2v|t2i] # every version: draft / queued / running / succeeded / failed
121
+ fuse version new --creative UUID --shot N [--stage i2v] [--from 2] [--request-file draft.json]
122
+ # adds the next number as a draft; --from copies that version's draft
123
+ fuse version draft get --creative UUID --shot N --number 4 [--stage i2v] --out draft.json
124
+ fuse version draft set --creative UUID --shot N --number 4 [--stage i2v] --request-file draft.json
125
+ fuse version submit --creative UUID --shot N --number 4 [--stage i2v] --receipt v4.json [--request-file body.json]
126
+ fuse version submit --receipt v4.json --resume [--retry-rejected] # same receipt discipline as video submit
127
+ fuse version delete --creative UUID --shot N --number 4 [--stage i2v] # drafts only; numbers are never reused
128
+ fuse prompt optimize --request-file refine.json --receipt prompt.json
129
+ fuse prompt result --job REQUEST_UUID --out suggestion.json
130
+ fuse prompt adopt --creative UUID --shot N --number 4 --job REQUEST_UUID --request-file adopted-draft.json
131
+ fuse take select --creative UUID --shot N --result UUID
132
+
133
+ Creative create acceptance unknown is preserved; it is never blindly retried.
134
+ Script writes save one Episode and stop when someone else is editing it; optimization never auto-adopts or submits video.
135
+ Global: --api-url https://fuse.alchemint.xyz/api, --json, --fields field,field,
136
+ --full (explicit detailed read), --help, --version. Pipe output defaults to JSON.
137
+ Private request/receipt files retain the complete prompt and ordered references.
138
+ The receipt path must be new, or use --resume to reconcile that exact intent.
139
+ A plan contains finite items; each has key, creative_id, request_file and optional
140
+ idempotency_key/source/receipt_file. Requests are unmodified CreateJob video JSON.
141
+ Submission exit 0 means accepted, not completed. Exit 2 input, 3 auth, 4 rejected,
142
+ 5 unknown transport/acceptance, 6 wait deadline/partial, 7 failed awaited Job.
143
+ Use FUSE_TOKEN only for a legitimately issued session; do not extract Web tokens.
144
+ `;
145
+ let options = {},
146
+ command = 'fuse',
147
+ json = !process.stdout.isTTY,
148
+ streaming = false;
149
+ const controller = new AbortController();
150
+ process.once('SIGINT', () => controller.abort());
151
+ process.once('SIGTERM', () => controller.abort());
152
+ function output(ok, data, error = null, event) {
153
+ const envelope = {
154
+ schema_version: 1,
155
+ command,
156
+ ok,
157
+ data: project(data, options.fields),
158
+ error,
159
+ ...(event ? { event } : {}),
160
+ };
161
+ if (json) process.stdout.write(`${JSON.stringify(envelope)}\n`);
162
+ else if (!ok)
163
+ process.stderr.write(
164
+ `${command}: ${error.code}: ${error.message}${error.receipt ? `\nReceipt: ${error.receipt}` : ''}\n`,
165
+ );
166
+ else if (data?.help) process.stdout.write(data.help);
167
+ else
168
+ process.stdout.write(
169
+ `${command}: ${JSON.stringify(project(data, options.fields))}\n`,
170
+ );
171
+ }
172
+ try {
173
+ const parsed = parseArgs({
174
+ allowPositionals: true,
175
+ options: {
176
+ ...Object.fromEntries(
177
+ stringFlags.map((key) => [key, { type: 'string' }]),
178
+ ),
179
+ ...Object.fromEntries(
180
+ booleanFlags.map((key) => [key, { type: 'boolean' }]),
181
+ ),
182
+ },
183
+ });
184
+ options = parsed.values;
185
+ json = options.json || !process.stdout.isTTY;
186
+ const [noun, action, positional] = parsed.positionals;
187
+ const positionalCount =
188
+ noun === 'batch' ||
189
+ (noun === 'version' && action === 'draft') ||
190
+ (noun === 'script' && action === 'episode') ||
191
+ ((noun === 'character' || noun === 'art') && action === 'image')
192
+ ? 3
193
+ : 2;
194
+ if (parsed.positionals.length > positionalCount)
195
+ throw new CliError(
196
+ 2,
197
+ 'INVALID_ARGUMENT',
198
+ 'Unexpected positional argument. Run fuse --help for the command shape.',
199
+ );
200
+ command = [noun, action].filter(Boolean).join(' ') || 'fuse';
201
+ if (options.version)
202
+ output(true, { version: '0.1.0', node: process.version });
203
+ else if (options.help || !noun) output(true, { help });
204
+ else {
205
+ const base = apiBase(options['api-url']);
206
+ const client = new Client(
207
+ base,
208
+ await loadCredential(base),
209
+ controller.signal,
210
+ );
211
+ const context = {
212
+ client,
213
+ signal: controller.signal,
214
+ progress: (message) => process.stderr.write(`${message}\n`),
215
+ event: (event, data) => output(true, data, null, event),
216
+ };
217
+ let data;
218
+ if (noun === 'auth') data = await auth(action, options, context);
219
+ else if (noun === 'models' && ['list', 'show'].includes(action))
220
+ data = await models(action, options, context);
221
+ else if (noun === 'video' && action === 'submit')
222
+ data = await videoSubmit(options, context);
223
+ else if (noun === 'job' && action === 'cancel')
224
+ data = await cancel(options, context);
225
+ else if (noun === 'job' && ['show', 'watch'].includes(action)) {
226
+ streaming = action === 'watch';
227
+ data = await job(action, options, context);
228
+ } else if (noun === 'download')
229
+ data = await download(action, options, context);
230
+ else if (noun === 'asset') data = await assets(action, options, context);
231
+ else if (noun === 'character' || noun === 'art') {
232
+ command = [noun, action, action === 'image' ? positional : null]
233
+ .filter(Boolean)
234
+ .join(' ');
235
+ data = await (noun === 'character' ? character : art)(
236
+ action,
237
+ positional,
238
+ options,
239
+ context,
240
+ );
241
+ } else if (noun === 'creative')
242
+ data = await creative(action, options, context);
243
+ else if (noun === 'script') {
244
+ command = [noun, action, action === 'episode' ? positional : null]
245
+ .filter(Boolean)
246
+ .join(' ');
247
+ data = await script(action, positional, options, context);
248
+ } else if (noun === 'shot') data = await shot(action, options, context);
249
+ else if (noun === 'prompt') data = await prompt(action, options, context);
250
+ else if (noun === 'version') {
251
+ command = [noun, action, action === 'draft' ? positional : null]
252
+ .filter(Boolean)
253
+ .join(' ');
254
+ data = await version(action, positional, options, context);
255
+ } else if (noun === 'take') data = await take(action, options, context);
256
+ else if (noun === 'batch')
257
+ data = await batch(action, positional, options, context);
258
+ else
259
+ throw new CliError(
260
+ 2,
261
+ 'UNKNOWN_COMMAND',
262
+ 'Unknown command. Run fuse --help.',
263
+ );
264
+ output(true, data, null, streaming ? 'complete' : undefined);
265
+ }
266
+ } catch (error) {
267
+ if (error.code?.startsWith('ERR_PARSE_ARGS'))
268
+ error = new CliError(2, 'INVALID_ARGUMENT', error.message);
269
+ output(
270
+ false,
271
+ error.data || null,
272
+ errorData(error),
273
+ streaming ? 'error' : undefined,
274
+ );
275
+ process.exitCode = error.exit || 2;
276
+ }
@@ -0,0 +1,125 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ CliError,
5
+ need,
6
+ subject,
7
+ sameSubject,
8
+ readJson,
9
+ writeJson,
10
+ exists,
11
+ digest,
12
+ withWriter,
13
+ errorData,
14
+ } from './core.mjs';
15
+
16
+ /** Persist one named intent. This is not a POST retry policy: only the explicit
17
+ * prompt optimization owner supplies its existing idempotency contract. */
18
+ export async function recordedMutation(
19
+ kind,
20
+ endpoint,
21
+ body,
22
+ options,
23
+ context,
24
+ summarize,
25
+ idempotent = false,
26
+ ) {
27
+ const file = path.resolve(need(options.receipt, '--receipt'));
28
+ return withWriter(`${file}.lock`, async () => {
29
+ const owner = await subject(context.client);
30
+ let receipt;
31
+ if (await exists(file)) {
32
+ receipt = await readJson(file);
33
+ sameSubject(receipt, owner);
34
+ if (
35
+ !options.resume ||
36
+ receipt.schema_version !== 1 ||
37
+ receipt.kind !== kind ||
38
+ receipt.endpoint !== endpoint ||
39
+ digest(receipt.request) !== receipt.request_sha256
40
+ )
41
+ throw new CliError(
42
+ 2,
43
+ 'RECEIPT_EXISTS',
44
+ 'Use the original receipt to inspect this intent; it cannot be replaced.',
45
+ );
46
+ if (receipt.accepted) return { ...receipt.accepted, receipt: file };
47
+ if (!idempotent || receipt.state === 'rejected')
48
+ throw new CliError(
49
+ 5,
50
+ 'MUTATION_UNRESOLVED',
51
+ 'This non-replayable intent needs reconciliation through its existing owner read. It was not resubmitted.',
52
+ { receipt: file },
53
+ );
54
+ } else {
55
+ if (options.resume)
56
+ throw new CliError(
57
+ 2,
58
+ 'RECEIPT_NOT_FOUND',
59
+ 'Resume requires the existing receipt file.',
60
+ );
61
+ receipt = {
62
+ schema_version: 1,
63
+ kind,
64
+ ...owner,
65
+ endpoint,
66
+ request: body,
67
+ request_sha256: digest(body),
68
+ state: 'prepared',
69
+ created_at: new Date().toISOString(),
70
+ ...(idempotent
71
+ ? { idempotency_key: options['idempotency-key'] || randomUUID() }
72
+ : {}),
73
+ };
74
+ await writeJson(file, receipt, true);
75
+ }
76
+ receipt.state = 'submitting';
77
+ await writeJson(file, receipt);
78
+ try {
79
+ const result = await context.client.post(
80
+ receipt.endpoint,
81
+ receipt.request,
82
+ idempotent
83
+ ? { headers: { 'X-Idempotency-Key': receipt.idempotency_key } }
84
+ : undefined,
85
+ );
86
+ const accepted = summarize(result);
87
+ if (
88
+ Object.entries(accepted).some(
89
+ ([key, value]) =>
90
+ key.endsWith('_id') &&
91
+ !(
92
+ (typeof value === 'string' && value.length) ||
93
+ (Number.isInteger(value) && value > 0)
94
+ ),
95
+ )
96
+ )
97
+ throw new CliError(
98
+ 5,
99
+ 'RESPONSE_UNKNOWN',
100
+ 'The response did not identify the accepted object. Reconcile this saved intent.',
101
+ );
102
+ receipt.accepted = accepted;
103
+ receipt.state = 'accepted';
104
+ await writeJson(file, receipt);
105
+ return { ...receipt.accepted, receipt: file };
106
+ } catch (error) {
107
+ if (!(error instanceof CliError))
108
+ error = new CliError(
109
+ 5,
110
+ 'RECEIPT_UNKNOWN',
111
+ 'Acceptance could not be recorded. Keep this intent and reconcile it.',
112
+ );
113
+ receipt.state =
114
+ error.exit === 5
115
+ ? 'unknown'
116
+ : error.exit === 3
117
+ ? 'auth_required'
118
+ : 'rejected';
119
+ receipt.error = errorData(error);
120
+ await writeJson(file, receipt);
121
+ error.receipt = file;
122
+ throw error;
123
+ }
124
+ });
125
+ }