memorix 1.3.3 → 1.4.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.
- package/CHANGELOG.md +23 -0
- package/dist/cli/index.js +1162 -1098
- package/dist/index.d.ts +513 -0
- package/dist/index.js +14751 -14044
- package/dist/maintenance-runner.js +105 -52
- package/dist/media-video-runner.js +105 -52
- package/dist/memcode-runtime/CHANGELOG.md +23 -0
- package/dist/sdk.d.ts +5 -513
- package/dist/sdk.js +186 -75
- package/dist/vector-backfill-runner.js +105 -52
- package/docs/1.3.3-MULTIMODAL-MEDIA-AND-MCP-COMPAT.md +13 -13
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -3
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/media.ts +10 -2
- package/src/cli/commands/serve-http.ts +7 -38
- package/src/cli/commands/serve-shared.ts +18 -46
- package/src/cli/commands/serve.ts +3 -41
- package/src/cli/commands/setup.ts +24 -21
- package/src/cli/mcp-root-path.ts +36 -0
- package/src/index.ts +37 -55
- package/src/media/asset-store.ts +74 -6
- package/src/media/attachment.ts +12 -9
- package/src/media/media-store.ts +38 -4
- package/src/media/video-jobs.ts +39 -14
- package/src/memory/observations.ts +21 -2
- package/src/orchestrate/adapters/spawn-helper.ts +2 -0
- package/src/server/tool-profile.ts +7 -4
- package/src/server.ts +6 -2
package/src/media/media-store.ts
CHANGED
|
@@ -242,6 +242,13 @@ export class MediaStore {
|
|
|
242
242
|
`).run(projectId, assetId).changes ?? 0);
|
|
243
243
|
this.db.prepare('DELETE FROM media_derivations WHERE project_id = ? AND asset_id = ?').run(projectId, assetId);
|
|
244
244
|
this.db.prepare('DELETE FROM media_embeddings WHERE project_id = ? AND asset_id = ?').run(projectId, assetId);
|
|
245
|
+
// Assets are soft-deleted so SQLite's ON DELETE SET NULL does not fire.
|
|
246
|
+
// Do not leave completed jobs advertising an asset that `media show` can
|
|
247
|
+
// no longer retrieve.
|
|
248
|
+
this.db.prepare(`
|
|
249
|
+
UPDATE media_jobs SET asset_id = NULL, updated_at = ?
|
|
250
|
+
WHERE project_id = ? AND asset_id = ?
|
|
251
|
+
`).run(Date.now(), projectId, assetId);
|
|
245
252
|
const result = this.db.prepare(`
|
|
246
253
|
UPDATE media_assets SET deleted_at = ?, updated_at = ?
|
|
247
254
|
WHERE project_id = ? AND id = ? AND deleted_at IS NULL
|
|
@@ -431,16 +438,42 @@ export class MediaStore {
|
|
|
431
438
|
lastError?: string;
|
|
432
439
|
incrementAttempts?: boolean;
|
|
433
440
|
}): MediaJob {
|
|
441
|
+
const updated = this.updateJobInternal(projectId, id, input, false);
|
|
442
|
+
if (!updated) throw new Error(`Media job not found: ${id}`);
|
|
443
|
+
return updated;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Atomically preserve a user cancellation against a worker that was already
|
|
448
|
+
* downloading or attaching media when the cancellation arrived.
|
|
449
|
+
*/
|
|
450
|
+
updateJobIfNotCancelled(projectId: string, id: string, input: {
|
|
451
|
+
status: MediaJobStatus;
|
|
452
|
+
providerTaskId?: string;
|
|
453
|
+
assetId?: string;
|
|
454
|
+
lastError?: string;
|
|
455
|
+
incrementAttempts?: boolean;
|
|
456
|
+
}): MediaJob | undefined {
|
|
457
|
+
return this.updateJobInternal(projectId, id, input, true);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private updateJobInternal(projectId: string, id: string, input: {
|
|
461
|
+
status: MediaJobStatus;
|
|
462
|
+
providerTaskId?: string;
|
|
463
|
+
assetId?: string;
|
|
464
|
+
lastError?: string;
|
|
465
|
+
incrementAttempts?: boolean;
|
|
466
|
+
}, rejectCancelled: boolean): MediaJob | undefined {
|
|
434
467
|
const existing = this.getJob(projectId, id);
|
|
435
|
-
if (!existing)
|
|
468
|
+
if (!existing) return undefined;
|
|
436
469
|
const now = Date.now();
|
|
437
470
|
const attempts = existing.attempts + (input.incrementAttempts ? 1 : 0);
|
|
438
471
|
const completedAt = input.status === 'completed' ? now : null;
|
|
439
|
-
this.db.prepare(`
|
|
472
|
+
const result = this.db.prepare(`
|
|
440
473
|
UPDATE media_jobs
|
|
441
474
|
SET status = ?, provider_task_id = COALESCE(?, provider_task_id), asset_id = COALESCE(?, asset_id),
|
|
442
475
|
last_error = ?, attempts = ?, updated_at = ?, completed_at = ?
|
|
443
|
-
WHERE project_id = ? AND id = ?
|
|
476
|
+
WHERE project_id = ? AND id = ? ${rejectCancelled ? "AND status <> 'cancelled'" : ''}
|
|
444
477
|
`).run(
|
|
445
478
|
input.status,
|
|
446
479
|
input.providerTaskId ?? null,
|
|
@@ -452,7 +485,8 @@ export class MediaStore {
|
|
|
452
485
|
projectId,
|
|
453
486
|
id,
|
|
454
487
|
);
|
|
455
|
-
|
|
488
|
+
if (Number(result.changes ?? 0) !== 1) return undefined;
|
|
489
|
+
return this.getJob(projectId, id);
|
|
456
490
|
}
|
|
457
491
|
|
|
458
492
|
cancelJob(projectId: string, id: string): MediaJob {
|
package/src/media/video-jobs.ts
CHANGED
|
@@ -139,12 +139,19 @@ async function finishWithAsset(input: {
|
|
|
139
139
|
dependencies: MediaVideoGenerationDependencies;
|
|
140
140
|
}): Promise<MaintenanceJobRunResult> {
|
|
141
141
|
const { dataDir, projectId, store, mediaJob, request, providerTask, dependencies } = input;
|
|
142
|
-
let
|
|
143
|
-
|
|
142
|
+
let activeJob = store.getJob(projectId, mediaJob.id);
|
|
143
|
+
if (!activeJob || terminal(activeJob)) return { action: 'complete' };
|
|
144
|
+
let asset: MediaAsset | undefined = activeJob.assetId
|
|
145
|
+
? store.getAsset(projectId, activeJob.assetId)
|
|
144
146
|
: undefined;
|
|
145
147
|
if (!asset) {
|
|
146
148
|
if (!providerTask.downloadUrl) throw new Error('Succeeded MiniMax video task did not include a download URL');
|
|
147
|
-
store.
|
|
149
|
+
const downloading = store.updateJobIfNotCancelled(projectId, activeJob.id, {
|
|
150
|
+
status: 'downloading',
|
|
151
|
+
providerTaskId: providerTask.taskId,
|
|
152
|
+
});
|
|
153
|
+
if (!downloading) return { action: 'complete' };
|
|
154
|
+
activeJob = downloading;
|
|
148
155
|
const bytes = await (dependencies.download ?? ((downloadInput) => downloadProviderMedia(downloadInput)))({
|
|
149
156
|
url: providerTask.downloadUrl,
|
|
150
157
|
maxBytes: request.maxBytes,
|
|
@@ -161,9 +168,20 @@ async function finishWithAsset(input: {
|
|
|
161
168
|
maxBytes: request.maxBytes,
|
|
162
169
|
});
|
|
163
170
|
asset = imported.asset;
|
|
171
|
+
// Persist the local result before optional attachment. A later retry can
|
|
172
|
+
// resume attachment even if the provider's signed URL has expired.
|
|
173
|
+
const persistedAsset = store.updateJobIfNotCancelled(projectId, activeJob.id, {
|
|
174
|
+
status: 'downloading',
|
|
175
|
+
providerTaskId: providerTask.taskId,
|
|
176
|
+
assetId: asset.id,
|
|
177
|
+
});
|
|
178
|
+
if (!persistedAsset) return { action: 'complete' };
|
|
179
|
+
activeJob = persistedAsset;
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
if (
|
|
182
|
+
if (activeJob.attachOnComplete) {
|
|
183
|
+
activeJob = store.getJob(projectId, activeJob.id);
|
|
184
|
+
if (!activeJob || terminal(activeJob)) return { action: 'complete' };
|
|
167
185
|
const hasAttachment = store.listLinks(projectId, asset.id)
|
|
168
186
|
.some((link) => link.role === 'attachment' && link.observationId !== undefined);
|
|
169
187
|
if (!hasAttachment) {
|
|
@@ -171,13 +189,13 @@ async function finishWithAsset(input: {
|
|
|
171
189
|
dataDir,
|
|
172
190
|
projectId,
|
|
173
191
|
asset,
|
|
174
|
-
title:
|
|
192
|
+
title: activeJob.observationTitle ?? `MiniMax video: ${asset.sourceLabel ?? asset.id}`,
|
|
175
193
|
narrative: `Generated with ${request.provider}/${request.model}. Prompt: ${request.prompt}`,
|
|
176
194
|
concepts: ['generated-video', 'minimax', request.model],
|
|
177
195
|
});
|
|
178
196
|
}
|
|
179
197
|
}
|
|
180
|
-
store.
|
|
198
|
+
store.updateJobIfNotCancelled(projectId, activeJob.id, {
|
|
181
199
|
status: 'completed',
|
|
182
200
|
providerTaskId: providerTask.taskId,
|
|
183
201
|
assetId: asset.id,
|
|
@@ -194,10 +212,12 @@ function retryAfterProviderError(
|
|
|
194
212
|
): MaintenanceJobRunResult {
|
|
195
213
|
const message = safeError(error);
|
|
196
214
|
if (maintenanceJob.attempts >= maintenanceJob.maxAttempts) {
|
|
197
|
-
store.
|
|
215
|
+
store.updateJobIfNotCancelled(projectId, mediaJob.id, { status: 'failed', lastError: message, incrementAttempts: true });
|
|
216
|
+
return { action: 'complete' };
|
|
217
|
+
}
|
|
218
|
+
if (!store.updateJobIfNotCancelled(projectId, mediaJob.id, { status: 'retry', lastError: message, incrementAttempts: true })) {
|
|
198
219
|
return { action: 'complete' };
|
|
199
220
|
}
|
|
200
|
-
store.updateJob(projectId, mediaJob.id, { status: 'retry', lastError: message, incrementAttempts: true });
|
|
201
221
|
return { action: 'reschedule', delayMs: MINI_MAX_VIDEO_POLL_MS, status: 'retry', lastError: message };
|
|
202
222
|
}
|
|
203
223
|
|
|
@@ -250,24 +270,27 @@ export async function runMiniMaxVideoGenerationJob(
|
|
|
250
270
|
const request = parseStoredRequest(mediaJob.request);
|
|
251
271
|
|
|
252
272
|
if (!mediaJob.providerTaskId) {
|
|
253
|
-
store.
|
|
273
|
+
if (!store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: 'submitting', incrementAttempts: true })) {
|
|
274
|
+
return { action: 'complete' };
|
|
275
|
+
}
|
|
254
276
|
let submitted: MiniMaxVideoTask;
|
|
255
277
|
try {
|
|
256
278
|
submitted = await (dependencies.createTask ?? createMiniMaxVideoTask)(toVideoRequest(request));
|
|
257
279
|
} catch (error) {
|
|
258
280
|
// A submit timeout can mean the provider accepted a billable request. Do
|
|
259
281
|
// not retry automatically without a task ID; the operator can resubmit.
|
|
260
|
-
store.
|
|
282
|
+
store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: 'failed', lastError: safeError(error) });
|
|
261
283
|
return { action: 'complete' };
|
|
262
284
|
}
|
|
263
285
|
if (submitted.status === 'failed') {
|
|
264
|
-
store.
|
|
286
|
+
store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: 'failed', providerTaskId: submitted.taskId, lastError: submitted.error });
|
|
265
287
|
return { action: 'complete' };
|
|
266
288
|
}
|
|
267
|
-
const pending = store.
|
|
289
|
+
const pending = store.updateJobIfNotCancelled(context.projectId, mediaJob.id, {
|
|
268
290
|
status: submitted.status === 'succeeded' ? 'downloading' : 'provider-pending',
|
|
269
291
|
providerTaskId: submitted.taskId,
|
|
270
292
|
});
|
|
293
|
+
if (!pending) return { action: 'complete' };
|
|
271
294
|
if (submitted.status === 'succeeded') {
|
|
272
295
|
try {
|
|
273
296
|
return await finishWithAsset({
|
|
@@ -296,11 +319,13 @@ export async function runMiniMaxVideoGenerationJob(
|
|
|
296
319
|
return retryAfterProviderError(store, context.projectId, mediaJob, maintenanceJob, error);
|
|
297
320
|
}
|
|
298
321
|
if (task.status === 'pending') {
|
|
299
|
-
store.
|
|
322
|
+
if (!store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: 'provider-pending', providerTaskId: task.taskId })) {
|
|
323
|
+
return { action: 'complete' };
|
|
324
|
+
}
|
|
300
325
|
return { action: 'reschedule', delayMs: MINI_MAX_VIDEO_POLL_MS, resetAttempts: true, clearLastError: true };
|
|
301
326
|
}
|
|
302
327
|
if (task.status === 'failed') {
|
|
303
|
-
store.
|
|
328
|
+
store.updateJobIfNotCancelled(context.projectId, mediaJob.id, { status: 'failed', providerTaskId: task.taskId, lastError: task.error, incrementAttempts: true });
|
|
304
329
|
return { action: 'complete' };
|
|
305
330
|
}
|
|
306
331
|
try {
|
|
@@ -397,6 +397,12 @@ export async function storeObservation(input: {
|
|
|
397
397
|
createdByAgentId?: string;
|
|
398
398
|
/** Optional caller-bound write scope for topic-key upserts. */
|
|
399
399
|
visibilityReader?: ObservationReader;
|
|
400
|
+
/**
|
|
401
|
+
* Internal transaction callback for a second SQLite record that must either
|
|
402
|
+
* commit with this observation or not exist at all. It is intentionally not
|
|
403
|
+
* exposed through the SDK input surface.
|
|
404
|
+
*/
|
|
405
|
+
onPersisted?: (observation: Observation) => void | Promise<void>;
|
|
400
406
|
}): Promise<{ observation: Observation; upserted: boolean }> {
|
|
401
407
|
const now = new Date().toISOString();
|
|
402
408
|
|
|
@@ -431,7 +437,9 @@ export async function storeObservation(input: {
|
|
|
431
437
|
if (input.visibilityReader && !canManageObservation(existing, input.visibilityReader)) {
|
|
432
438
|
throw new Error('Cannot update a memory outside this session\'s write scope.');
|
|
433
439
|
}
|
|
434
|
-
|
|
440
|
+
const observation = await upsertObservation(existing, input, now);
|
|
441
|
+
await input.onPersisted?.(observation);
|
|
442
|
+
return { observation, upserted: true };
|
|
435
443
|
}
|
|
436
444
|
}
|
|
437
445
|
|
|
@@ -460,6 +468,7 @@ export async function storeObservation(input: {
|
|
|
460
468
|
|
|
461
469
|
let upsertedInsideLock = false;
|
|
462
470
|
let reloadCacheAfterCommit = false;
|
|
471
|
+
let persistedCallbackInvoked = false;
|
|
463
472
|
const assignAndPersist = async () => {
|
|
464
473
|
if (projectDir) {
|
|
465
474
|
const store = getObservationStore();
|
|
@@ -528,6 +537,8 @@ export async function storeObservation(input: {
|
|
|
528
537
|
};
|
|
529
538
|
|
|
530
539
|
await tx.insert(observation);
|
|
540
|
+
await input.onPersisted?.(observation);
|
|
541
|
+
persistedCallbackInvoked = true;
|
|
531
542
|
await tx.saveIdCounter(id + 1);
|
|
532
543
|
});
|
|
533
544
|
|
|
@@ -584,6 +595,8 @@ export async function storeObservation(input: {
|
|
|
584
595
|
writeGeneration: 0,
|
|
585
596
|
};
|
|
586
597
|
observations.push(observation);
|
|
598
|
+
await input.onPersisted?.(observation);
|
|
599
|
+
persistedCallbackInvoked = true;
|
|
587
600
|
}
|
|
588
601
|
|
|
589
602
|
// Build Orama doc AFTER id is assigned
|
|
@@ -621,9 +634,15 @@ export async function storeObservation(input: {
|
|
|
621
634
|
|
|
622
635
|
// If the lock discovered a topicKey duplicate on disk, delegate to upsert
|
|
623
636
|
if (upsertedInsideLock) {
|
|
624
|
-
|
|
637
|
+
const updated = await upsertObservation(observation, input, now);
|
|
638
|
+
await input.onPersisted?.(updated);
|
|
639
|
+
return { observation: updated, upserted: true };
|
|
625
640
|
}
|
|
626
641
|
|
|
642
|
+
// The normal persistent path invokes the callback inside the SQLite
|
|
643
|
+
// transaction. Keep the in-memory fallback defensively covered too.
|
|
644
|
+
if (!persistedCallbackInvoked) await input.onPersisted?.(observation);
|
|
645
|
+
|
|
627
646
|
await bindObservationCodeRefsBestEffort(observation);
|
|
628
647
|
queueObservationQualification(observation);
|
|
629
648
|
if (resolveObservationVisibility(observation) === 'project') {
|
|
@@ -43,6 +43,7 @@ export function spawnAgent(
|
|
|
43
43
|
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
44
44
|
// Windows: use shell to resolve CLI tools on PATH
|
|
45
45
|
shell: process.platform === 'win32',
|
|
46
|
+
windowsHide: true,
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
// Write prompt via stdin to avoid shell argument escaping issues.
|
|
@@ -134,6 +135,7 @@ export function spawnAgentWithStream(
|
|
|
134
135
|
env: { ...process.env, ...opts.env },
|
|
135
136
|
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
136
137
|
shell: process.platform === 'win32',
|
|
138
|
+
windowsHide: true,
|
|
137
139
|
});
|
|
138
140
|
|
|
139
141
|
let stdinError: string | undefined;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* tools/list time causes cognitive overload.
|
|
7
7
|
*
|
|
8
8
|
* We provide four profiles:
|
|
9
|
-
* - "micro" (stdio default): Agent-ready project context +
|
|
9
|
+
* - "micro" (stdio default): Agent-ready project context + bounded recovery/media controls — 9 tools.
|
|
10
10
|
* Suitable for normal MCP clients where every tool schema costs context tokens.
|
|
11
11
|
* - "lite": Core memory CRUD, sessions, reasoning, retention, backup — 18 tools.
|
|
12
12
|
* Suitable for solo users who want the full memory surface without team tools.
|
|
@@ -35,19 +35,22 @@ export const TOOL_PROFILES: Record<string, ReadonlyArray<ToolProfile>> = Object.
|
|
|
35
35
|
memorix_context_pack: ['micro', 'lite', 'team', 'full'],
|
|
36
36
|
memorix_codegraph_status: ['micro', 'lite', 'team', 'full'],
|
|
37
37
|
memorix_resolve: ['micro', 'lite', 'team', 'full'],
|
|
38
|
+
// A fail-closed stdio server needs an explicit recovery path when a client
|
|
39
|
+
// did not launch it from the workspace. The unified media tool keeps
|
|
40
|
+
// multimodal support to one additional schema rather than enabling lite.
|
|
41
|
+
memorix_session_start: ['micro', 'lite', 'team', 'full'],
|
|
42
|
+
memorix_media: ['micro', 'lite', 'team', 'full'],
|
|
38
43
|
|
|
39
44
|
// ── lite: extended solo memory surface ────────────────────────────
|
|
40
45
|
memorix_graph_context: ['lite', 'team', 'full'],
|
|
41
46
|
memorix_timeline: ['lite', 'team', 'full'],
|
|
42
47
|
memorix_suggest_topic_key: ['lite', 'team', 'full'],
|
|
43
|
-
memorix_session_start: ['lite', 'team', 'full'],
|
|
44
48
|
memorix_session_end: ['lite', 'team', 'full'],
|
|
45
49
|
memorix_session_context: ['lite', 'team', 'full'],
|
|
46
50
|
memorix_store_reasoning: ['lite', 'team', 'full'],
|
|
47
51
|
memorix_search_reasoning: ['lite', 'team', 'full'],
|
|
48
52
|
memorix_transfer: ['lite', 'team', 'full'],
|
|
49
53
|
memorix_retention: ['lite', 'team', 'full'],
|
|
50
|
-
memorix_media: ['lite', 'team', 'full'],
|
|
51
54
|
|
|
52
55
|
// ── team: orchestration coordination surfaces — HTTP default ──────
|
|
53
56
|
memorix_dashboard: ['team', 'full'],
|
|
@@ -136,7 +139,7 @@ export function resolveToolProfile(opts: ResolveToolProfileOpts): ToolProfile {
|
|
|
136
139
|
*/
|
|
137
140
|
export function describeProfile(profile: ToolProfile): string {
|
|
138
141
|
switch (profile) {
|
|
139
|
-
case 'micro': return 'micro (agent-ready context +
|
|
142
|
+
case 'micro': return 'micro (agent-ready context + bounded recovery/media, ~9 tools)';
|
|
140
143
|
case 'lite': return 'lite (core memory + sessions, ~18 tools)';
|
|
141
144
|
case 'team': return 'team (lite + coordination tools + dashboard + knowledge workspace, ~26 tools)';
|
|
142
145
|
case 'full': return 'full (all tools including advanced / KG-compat)';
|
package/src/server.ts
CHANGED
|
@@ -4993,10 +4993,10 @@ export async function createMemorixServer(
|
|
|
4993
4993
|
description:
|
|
4994
4994
|
'Use the controlled local media library for an explicit import, attachment, inspection, or MiniMax generation request. ' +
|
|
4995
4995
|
'Assets stay outside the Git worktree and enter normal memory only when attach is explicitly true. ' +
|
|
4996
|
-
'Use the CLI for destructive removal, quota cleanup,
|
|
4996
|
+
'Use the CLI for destructive removal, quota cleanup, and direct generation. ' +
|
|
4997
4997
|
'MCP generation is disabled by default and requires MEMORIX_MCP_MEDIA_GENERATION=1 after the operator reviews provider billing.',
|
|
4998
4998
|
inputSchema: {
|
|
4999
|
-
action: z.enum(['import', 'attach', 'list', 'show', 'generate-image', 'generate-video', 'status']),
|
|
4999
|
+
action: z.enum(['import', 'attach', 'list', 'show', 'generate-image', 'generate-video', 'status', 'cancel']),
|
|
5000
5000
|
path: z.string().optional().describe('Explicit local image/audio/video/PDF path for import.'),
|
|
5001
5001
|
assetId: z.string().optional().describe('Controlled MediaAsset ID for attach/show.'),
|
|
5002
5002
|
jobId: z.string().optional().describe('Durable media job ID for status.'),
|
|
@@ -5069,6 +5069,10 @@ export async function createMemorixServer(
|
|
|
5069
5069
|
if (!job) throw new Error(`Media job not found: ${jobId}`);
|
|
5070
5070
|
return result({ action, projectId: project.id, job });
|
|
5071
5071
|
}
|
|
5072
|
+
if (action === 'cancel') {
|
|
5073
|
+
if (!jobId?.trim()) throw new Error('jobId is required for media cancel');
|
|
5074
|
+
return result({ action, projectId: project.id, job: store.cancelJob(project.id, jobId) });
|
|
5075
|
+
}
|
|
5072
5076
|
if (action === 'attach') {
|
|
5073
5077
|
if (!assetId?.trim()) throw new Error('assetId is required for media attach');
|
|
5074
5078
|
const asset = store.getAsset(project.id, assetId);
|