@mokuyoaxis/dsh-iris 0.2.1 → 0.2.3
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/.dsh/skills/iris-compose-media/SKILL.md +23 -73
- package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
- package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
- package/.dsh/skills/iris-use/SKILL.md +41 -0
- package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-use/references/media-contract.md +73 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
- package/CHANGELOG.md +48 -2
- package/README.en.md +47 -40
- package/README.md +45 -39
- package/bin/dsh-iris.js +27 -6
- package/docs/AGENT_QUICKSTART.md +9 -9
- package/docs/ARCHITECTURE.md +11 -3
- package/docs/ARTIFACT_LIBRARY.md +1 -1
- package/docs/CLI_MANAGEMENT.md +5 -3
- package/docs/COMPOSITE_VISION.md +1 -1
- package/docs/CORE_RUNTIME_CONTRACT.md +12 -2
- package/docs/DSH_CORE_MIGRATION.md +7 -3
- package/docs/FAULT_INJECTION.md +11 -1
- package/docs/HEADLESS_CLI.md +10 -4
- package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +9 -3
- package/docs/MUSIC.md +77 -0
- package/docs/OCR_MODEL.md +1 -1
- package/docs/PROMPT_OPTIMIZER.md +74 -6
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +12 -2
- package/docs/PROVIDER_HEALTH.md +1 -1
- package/docs/ROADMAP.md +15 -18
- package/docs/SECURITY.md +4 -3
- package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
- package/docs/TASK_SEMANTICS.md +5 -5
- package/docs/VISION_CLI.md +1 -1
- package/docs/VISION_MODEL.md +1 -1
- package/docs/WORKBENCH_ARTIFACTS.md +1 -1
- package/docs/releases/0.2.2.md +31 -0
- package/docs/releases/0.2.3.md +54 -0
- package/lib/actions.js +52 -19
- package/lib/adapters.js +89 -24
- package/lib/api.js +14 -5
- package/lib/bundled-skills.js +1 -1
- package/lib/capability.js +25 -16
- package/lib/client.js +374 -43
- package/lib/command-service.js +12 -3
- package/lib/config.js +39 -6
- package/lib/core-artifact-store.js +44 -6
- package/lib/core-delivery-cache.js +145 -0
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +12 -1
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +19 -5
- package/lib/core-user-projection.js +2 -2
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +14 -4
- package/lib/dsh-text-model-adapter.js +15 -2
- package/lib/generation-input.js +10 -0
- package/lib/host-runtime.js +4 -2
- package/lib/index.js +23 -4
- package/lib/models.js +23 -38
- package/lib/music-generation.js +21 -0
- package/lib/music-options.js +18 -0
- package/lib/music-transport.js +46 -0
- package/lib/prompt-optimizer-config.js +111 -30
- package/lib/prompt-optimizer-core.js +183 -12
- package/lib/prompt-optimizer.js +20 -14
- package/lib/provider-adapter.js +1 -0
- package/lib/provider-adapters.js +26 -2
- package/lib/provider-catalog.js +18 -7
- package/lib/provider-config-service.js +16 -5
- package/lib/provider-protocol.js +73 -0
- package/lib/provider-task-runner.js +86 -9
- package/package.json +4 -2
- package/user_guide.md +52 -24
package/lib/command-service.js
CHANGED
|
@@ -20,6 +20,7 @@ import { GenerationInputError, normalizeGenerationInput } from './generation-inp
|
|
|
20
20
|
import { videoTaskCandidates } from './video-input.js';
|
|
21
21
|
import { waitForCoreTask } from './task-wait.js';
|
|
22
22
|
import { planCoreDeletion, planCoreCleanup, quarantineCoreFiles, inspectCoreTransactions, restoreCoreTransaction } from './core-maintenance.js';
|
|
23
|
+
import { deliveryCacheNeedsProvider } from './core-delivery-cache.js';
|
|
23
24
|
|
|
24
25
|
export const COMMAND_CONTRACT_VERSION = 0;
|
|
25
26
|
export const CORE_COMMANDS = Object.freeze([
|
|
@@ -366,16 +367,20 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
366
367
|
});
|
|
367
368
|
}
|
|
368
369
|
// task.redeliver:只对 outcome=succeeded / deliveryState=failed 的任务重新取回
|
|
369
|
-
//
|
|
370
|
+
// 已生成产物:同步结果读私有缓存,异步结果 re-poll;绝不 submit。
|
|
370
371
|
if (name === 'task.redeliver') {
|
|
371
372
|
exactInput(input, ['task_id']);
|
|
372
373
|
return runtime.run('execute', async ({ dataRoot }) => {
|
|
373
374
|
const before = inspectCoreTask(dataRoot, input.task_id);
|
|
374
|
-
if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || !before.remoteTaskId) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
|
|
375
|
+
if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || (!before.remoteTaskId && !before.deliveryCache)) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
|
|
376
|
+
if (before.deliveryCache && !deliveryCacheNeedsProvider(dataRoot, before)) {
|
|
377
|
+
const task = await createProviderTaskRunner(runtime).redeliver(input.task_id);
|
|
378
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
|
|
379
|
+
}
|
|
375
380
|
if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.redeliver 需要宿主显式提供 Task Provider resolver');
|
|
376
381
|
const adapter = await ports.resolveTaskAdapter(before);
|
|
377
382
|
if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
|
|
378
|
-
if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
|
|
383
|
+
if (!before.deliveryCache && !hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
|
|
379
384
|
const task = await createProviderTaskRunner(runtime).redeliver(input.task_id, adapter);
|
|
380
385
|
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
|
|
381
386
|
});
|
|
@@ -415,6 +420,10 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
415
420
|
throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
|
|
416
421
|
}
|
|
417
422
|
let normalized;
|
|
423
|
+
if (before.capability === 'music' && input.provider_input &&
|
|
424
|
+
['retry_502', 'retry_delay_ms'].some(field => Object.hasOwn(input.provider_input, field))) {
|
|
425
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'task.retry 只执行一次重新生成;502重试配置请使用Music生成入口');
|
|
426
|
+
}
|
|
418
427
|
try { normalized = normalizeGenerationInput(before.capability, input.provider_input, { allowVideoPaths: before.capability === 'video' }); }
|
|
419
428
|
catch (error) {
|
|
420
429
|
if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
|
package/lib/config.js
CHANGED
|
@@ -11,7 +11,7 @@ import os from 'node:os';
|
|
|
11
11
|
import * as cap from './capability.js';
|
|
12
12
|
import * as models from './models.js';
|
|
13
13
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
14
|
-
import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
14
|
+
import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
15
15
|
import { redactProviderMessage } from './provider-contract.js';
|
|
16
16
|
import {
|
|
17
17
|
PROVIDER_HEALTH_FRESH_MS,
|
|
@@ -66,7 +66,7 @@ function normalizeStoredProvider(provider) {
|
|
|
66
66
|
const changed = provider.mediaProtocol !== selected.mediaProtocol
|
|
67
67
|
|| (provider.protocolInferred === true) !== selected.protocolInferred;
|
|
68
68
|
Object.assign(provider, selected);
|
|
69
|
-
return
|
|
69
|
+
return changed;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export function irisHome() {
|
|
@@ -196,7 +196,7 @@ export function pickFor(capability) {
|
|
|
196
196
|
m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
|
|
197
197
|
if (m) break;
|
|
198
198
|
}
|
|
199
|
-
if (!m) m = models.pickModel(pool, capability);
|
|
199
|
+
if (!m && capability !== cap.CAPABILITIES.MUSIC) m = models.pickModel(pool, capability);
|
|
200
200
|
if (!m) return null;
|
|
201
201
|
const p = providerById(m.providerId);
|
|
202
202
|
if (!p) return null;
|
|
@@ -489,6 +489,7 @@ export function upsert(provider) {
|
|
|
489
489
|
if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
|
|
490
490
|
if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
|
|
491
491
|
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
|
|
492
|
+
...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
|
|
492
493
|
...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
|
|
493
494
|
if (id) {
|
|
494
495
|
const i = c.providers.findIndex((p) => p.id === id);
|
|
@@ -541,6 +542,7 @@ export function setProviderModels(id, modelEntries) {
|
|
|
541
542
|
p.models = modelEntries.map((m) => {
|
|
542
543
|
const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
|
|
543
544
|
...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
|
|
545
|
+
...(m.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(m.musicProtocol) } : {}),
|
|
544
546
|
...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
|
|
545
547
|
const old = prev.get(entry.id);
|
|
546
548
|
if (old) {
|
|
@@ -548,6 +550,7 @@ export function setProviderModels(id, modelEntries) {
|
|
|
548
550
|
if (old.source) entry.source = old.source;
|
|
549
551
|
if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
|
|
550
552
|
if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
|
|
553
|
+
if (m?.musicProtocol === undefined && old.musicProtocol !== undefined) entry.musicProtocol = old.musicProtocol;
|
|
551
554
|
if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
|
|
552
555
|
}
|
|
553
556
|
if (m && m.verified) entry.verified = m.verified;
|
|
@@ -565,10 +568,19 @@ export function setProviderModels(id, modelEntries) {
|
|
|
565
568
|
export function mergeDiscoveredModels(id, modelEntries) {
|
|
566
569
|
const provider = load().providers.find((p) => p.id === id);
|
|
567
570
|
if (!provider) return null;
|
|
568
|
-
const merged = new Map(models.providerModels(provider).map((m) =>
|
|
571
|
+
const merged = new Map(models.providerModels(provider).map((m) => {
|
|
572
|
+
const raw = provider.models?.find((entry) => String(typeof entry === 'string' ? entry : entry?.id || '').trim() === m.id);
|
|
573
|
+
// 读取投影的 [] 不能伪造成用户明确清空;未标注条目以后仍可接收可信元数据。
|
|
574
|
+
if (!Array.isArray(raw?.capabilities) && !models.hasConfiguredModelCapabilities(provider, m.id)) {
|
|
575
|
+
const entry = { ...m };
|
|
576
|
+
delete entry.capabilities;
|
|
577
|
+
return [m.id, entry];
|
|
578
|
+
}
|
|
579
|
+
return [m.id, m];
|
|
580
|
+
}));
|
|
569
581
|
for (const model of modelEntries) {
|
|
570
582
|
const previous = merged.get(model.id);
|
|
571
|
-
if (previous
|
|
583
|
+
if (previous && models.hasConfiguredModelCapabilities(provider, model.id)) continue;
|
|
572
584
|
merged.set(model.id, { ...previous, ...model, source: 'discovered' });
|
|
573
585
|
}
|
|
574
586
|
return setProviderModels(id, [...merged.values()]);
|
|
@@ -581,6 +593,9 @@ function findModelEntry(p, modelId) {
|
|
|
581
593
|
|
|
582
594
|
function resetChangedImageProtocolHealth(previous, next) {
|
|
583
595
|
for (const model of models.providerModels(previous)) {
|
|
596
|
+
if (model.musicProtocol !== models.providerModels(next).find(item => item.id === model.id)?.musicProtocol) {
|
|
597
|
+
clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.MUSIC);
|
|
598
|
+
}
|
|
584
599
|
if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
|
|
585
600
|
clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
|
|
586
601
|
}
|
|
@@ -606,6 +621,24 @@ export function setModelImageProtocol(id, modelId, value) {
|
|
|
606
621
|
return true;
|
|
607
622
|
}
|
|
608
623
|
|
|
624
|
+
/** 音乐协议独立绑定到已配置模型,不覆盖账号生图协议。 */
|
|
625
|
+
export function setModelMusicProtocol(id, modelId, value) {
|
|
626
|
+
const protocol = normalizeMusicProtocol(value);
|
|
627
|
+
const provider = load().providers.find(item => item.id === id);
|
|
628
|
+
if (!provider) return false;
|
|
629
|
+
const entries = Array.isArray(provider.models) ? provider.models
|
|
630
|
+
: models.providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
|
|
631
|
+
const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
|
|
632
|
+
if (index < 0) return false;
|
|
633
|
+
const entry = typeof entries[index] === 'string' ? { id: entries[index] } : entries[index];
|
|
634
|
+
entries[index] = entry;
|
|
635
|
+
provider.models = entries;
|
|
636
|
+
if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, cap.CAPABILITIES.MUSIC);
|
|
637
|
+
entry.musicProtocol = protocol;
|
|
638
|
+
persist();
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
|
|
609
642
|
/** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
|
|
610
643
|
export function setModelVisionInput(id, modelId, value) {
|
|
611
644
|
const limits = normalizeVisionInputLimits(value);
|
|
@@ -631,7 +664,7 @@ export function addProviderModel(id, modelId, capabilities) {
|
|
|
631
664
|
if (!Array.isArray(p.models)) p.models = [];
|
|
632
665
|
let entry = findModelEntry(p, name);
|
|
633
666
|
if (!entry) {
|
|
634
|
-
entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities :
|
|
667
|
+
entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities : undefined, source: 'manual' };
|
|
635
668
|
p.models.push(entry);
|
|
636
669
|
} else if (Array.isArray(capabilities)) {
|
|
637
670
|
entry.capabilities = capabilities;
|
|
@@ -305,20 +305,46 @@ function finishObject(dataRoot, input) {
|
|
|
305
305
|
const relations = normalizedRelations(input.relations);
|
|
306
306
|
validateRelationTargets(dataRoot, relations);
|
|
307
307
|
const metadata = jsonClone(input.metadata, 'metadata');
|
|
308
|
-
|
|
308
|
+
// 同步交付缓存预留的 ID 可重入提交,避免多产物交付或 Task 落盘中断后重复作品。
|
|
309
|
+
const resumable = input.artifactId !== undefined;
|
|
310
|
+
const id = resumable ? input.artifactId : 'artifact_' + crypto.randomBytes(12).toString('hex');
|
|
311
|
+
if (!ID.test(id) || (resumable && (!HASH.test(input.expectedDigest)
|
|
312
|
+
|| !Number.isSafeInteger(input.expectedSize) || input.expectedSize < 1))) {
|
|
313
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', '可恢复 Artifact 身份或内容证据无效');
|
|
314
|
+
}
|
|
309
315
|
const store = ensureStore(dataRoot);
|
|
310
316
|
const objectName = id + extension;
|
|
311
317
|
const object = path.join(store.objects, objectName);
|
|
318
|
+
const exists = (file) => {
|
|
319
|
+
try { fs.lstatSync(file); return true; }
|
|
320
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
321
|
+
};
|
|
322
|
+
if (resumable && exists(recordPath(dataRoot, id))) {
|
|
323
|
+
const previous = readArtifact(dataRoot, id).manifest;
|
|
324
|
+
if (previous.size !== input.expectedSize || previous.digest.value !== input.expectedDigest
|
|
325
|
+
|| previous.kind !== kind || previous.mediaType !== mediaType
|
|
326
|
+
|| JSON.stringify(previous.metadata) !== JSON.stringify(metadata)
|
|
327
|
+
|| JSON.stringify(previous.relations) !== JSON.stringify(relations)) {
|
|
328
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', '可恢复 Artifact 与已提交内容不一致');
|
|
329
|
+
}
|
|
330
|
+
try { refreshIndex(dataRoot); } catch (_) { /* 不撤销已有提交 */ }
|
|
331
|
+
return publicArtifact(previous);
|
|
332
|
+
}
|
|
312
333
|
let stat;
|
|
313
334
|
try {
|
|
314
|
-
|
|
335
|
+
if (resumable && exists(object)) {
|
|
336
|
+
if (regularFile(object).size !== input.expectedSize || hashFile(object) !== input.expectedDigest) {
|
|
337
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', '未提交 Artifact 与恢复内容不一致');
|
|
338
|
+
}
|
|
339
|
+
} else input.writeObject(object);
|
|
315
340
|
stat = regularFile(object);
|
|
316
|
-
} catch (
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (resumable) throw error;
|
|
317
343
|
try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前随机 ID */ }
|
|
318
344
|
fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 对象无法安全写入');
|
|
319
345
|
}
|
|
320
346
|
if (!stat.size) {
|
|
321
|
-
try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
|
|
347
|
+
if (!resumable) try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
|
|
322
348
|
fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 内容不能为空');
|
|
323
349
|
}
|
|
324
350
|
const manifest = {
|
|
@@ -333,12 +359,23 @@ function finishObject(dataRoot, input) {
|
|
|
333
359
|
relations,
|
|
334
360
|
metadata
|
|
335
361
|
};
|
|
362
|
+
if (resumable && (manifest.size !== input.expectedSize || manifest.digest.value !== input.expectedDigest)) {
|
|
363
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 复制结果与恢复内容证据不一致');
|
|
364
|
+
}
|
|
365
|
+
if (resumable && exists(manifestPath(dataRoot, id))) {
|
|
366
|
+
const previous = validateManifest(jsonFile(manifestPath(dataRoot, id)), id);
|
|
367
|
+
if (JSON.stringify(previous) !== JSON.stringify(manifest)) {
|
|
368
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', '未提交 Artifact Manifest 与恢复内容不一致');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
336
371
|
try {
|
|
337
372
|
writeManifest(dataRoot, manifest);
|
|
338
373
|
commitRecord(dataRoot, id);
|
|
339
374
|
} catch (_) {
|
|
340
|
-
|
|
341
|
-
|
|
375
|
+
if (!resumable) {
|
|
376
|
+
try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前 ID */ }
|
|
377
|
+
try { fs.rmSync(manifestPath(dataRoot, id), { force: true }); } catch (_) { /* 当前 ID */ }
|
|
378
|
+
}
|
|
342
379
|
fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 无法安全提交');
|
|
343
380
|
}
|
|
344
381
|
try { refreshIndex(dataRoot); } catch (_) { /* index 是可重建缓存,不撤销已提交 Artifact */ }
|
|
@@ -366,6 +403,7 @@ export function adoptCoreArtifactFile(dataRoot, input = {}) {
|
|
|
366
403
|
return finishObject(dataRoot, {
|
|
367
404
|
...input,
|
|
368
405
|
mediaType,
|
|
406
|
+
...(input.artifactId ? { expectedSize: stat.size, expectedDigest: hashFile(source) } : {}),
|
|
369
407
|
kind: input.kind || 'adopted',
|
|
370
408
|
writeObject(object) {
|
|
371
409
|
const tmp = privateSibling(object, 'adopt');
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** 同步完成结果的私有交付证据;来源 URL/字节不进入 Task、Manifest 或日志。 */
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
|
|
8
|
+
|
|
9
|
+
const TASK_ID = /^task_[a-f0-9]{24}$/;
|
|
10
|
+
const ARTIFACT_ID = /^artifact_[a-f0-9]{24}$/;
|
|
11
|
+
const PAYLOAD_FILE = /^payload-[a-f0-9]{24}\.bin$/;
|
|
12
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
13
|
+
|
|
14
|
+
export class DeliveryCacheError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'DeliveryCacheError';
|
|
18
|
+
this.code = 'IRIS_DELIVERY_CACHE_INVALID';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function invalid() { throw new DeliveryCacheError('同步交付缓存缺失、损坏或与 Task 身份不一致;不会重新生成'); }
|
|
23
|
+
|
|
24
|
+
function directory(dataRoot, taskId, create = false) {
|
|
25
|
+
if (!TASK_ID.test(taskId)) invalid();
|
|
26
|
+
let current = dataRoot;
|
|
27
|
+
for (const part of ['delivery-cache', 'v0', taskId]) {
|
|
28
|
+
current = path.join(current, part);
|
|
29
|
+
if (create) {
|
|
30
|
+
try { fs.lstatSync(current); }
|
|
31
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; ensurePrivateDir(current); }
|
|
32
|
+
}
|
|
33
|
+
const stat = fs.lstatSync(current);
|
|
34
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
|
|
35
|
+
}
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function regular(file) {
|
|
40
|
+
const stat = fs.lstatSync(file);
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink()) invalid();
|
|
42
|
+
return stat;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fingerprint(file) {
|
|
46
|
+
const stat = regular(file);
|
|
47
|
+
if (!stat.size) invalid();
|
|
48
|
+
const hash = crypto.createHash('sha256');
|
|
49
|
+
const chunk = Buffer.alloc(1024 * 1024);
|
|
50
|
+
const fd = fs.openSync(file, 'r');
|
|
51
|
+
try {
|
|
52
|
+
let count;
|
|
53
|
+
while ((count = fs.readSync(fd, chunk, 0, chunk.length, null))) hash.update(chunk.subarray(0, count));
|
|
54
|
+
} finally { fs.closeSync(fd); }
|
|
55
|
+
return { size: stat.size, sha256: hash.digest('hex') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function save(dataRoot, cache) {
|
|
59
|
+
atomicWritePrivate(path.join(directory(dataRoot, cache.taskId), 'result.json'), JSON.stringify(cache, null, 2) + '\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createDeliveryCache(dataRoot, task, artifacts) {
|
|
63
|
+
if (!Array.isArray(artifacts) || !artifacts.length) invalid();
|
|
64
|
+
const root = directory(dataRoot, task.id, true);
|
|
65
|
+
try { fs.lstatSync(path.join(root, 'result.json')); invalid(); }
|
|
66
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
67
|
+
const cache = {
|
|
68
|
+
schemaVersion: 0, resultKind: 'completed', taskId: task.id, attemptId: task.activeAttemptId,
|
|
69
|
+
providerId: task.providerId, modelRef: task.modelRef, capability: task.capability,
|
|
70
|
+
...(task.providerBinding ? { providerBinding: task.providerBinding } : {}),
|
|
71
|
+
createdAt: new Date().toISOString(), items: []
|
|
72
|
+
};
|
|
73
|
+
for (const remote of artifacts) {
|
|
74
|
+
const item = { artifactId: 'artifact_' + crypto.randomBytes(12).toString('hex') };
|
|
75
|
+
if (remote.kind === 'remote-url') {
|
|
76
|
+
item.source = { kind: 'remote-url', url: remote.url, ...(remote.mediaType ? { mediaType: remote.mediaType } : {}) };
|
|
77
|
+
} else if (remote.kind === 'inline-base64') {
|
|
78
|
+
const bytes = Buffer.from(remote.data, 'base64');
|
|
79
|
+
if (!bytes.length || bytes.toString('base64') !== remote.data) invalid();
|
|
80
|
+
const file = 'payload-' + crypto.randomBytes(12).toString('hex') + '.bin';
|
|
81
|
+
atomicWritePrivate(path.join(root, file), bytes);
|
|
82
|
+
item.payload = { file, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
83
|
+
...(remote.mediaType ? { mediaType: remote.mediaType } : {}) };
|
|
84
|
+
} else invalid();
|
|
85
|
+
cache.items.push(item);
|
|
86
|
+
}
|
|
87
|
+
// result.json 是完整同步响应的提交证据;未发布的文件不作为可恢复结果。
|
|
88
|
+
save(dataRoot, cache);
|
|
89
|
+
return cache;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readDeliveryCache(dataRoot, task, { allowAbsent = false } = {}) {
|
|
93
|
+
let cache;
|
|
94
|
+
try {
|
|
95
|
+
const file = path.join(directory(dataRoot, task.id), 'result.json');
|
|
96
|
+
regular(file);
|
|
97
|
+
cache = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (allowAbsent && error.code === 'ENOENT') return null;
|
|
100
|
+
invalid();
|
|
101
|
+
}
|
|
102
|
+
if (!cache || cache.schemaVersion !== 0 || cache.resultKind !== 'completed'
|
|
103
|
+
|| cache.taskId !== task.id || cache.attemptId !== task.activeAttemptId
|
|
104
|
+
|| cache.providerId !== task.providerId || cache.modelRef !== task.modelRef
|
|
105
|
+
|| cache.capability !== task.capability || cache.providerBinding !== task.providerBinding
|
|
106
|
+
|| !Array.isArray(cache.items) || !cache.items.length
|
|
107
|
+
|| typeof cache.createdAt !== 'string' || !Number.isFinite(Date.parse(cache.createdAt))) invalid();
|
|
108
|
+
const ids = new Set();
|
|
109
|
+
for (const item of cache.items) {
|
|
110
|
+
if (!item || !ARTIFACT_ID.test(item.artifactId) || ids.has(item.artifactId)) invalid();
|
|
111
|
+
ids.add(item.artifactId);
|
|
112
|
+
if (item.source && (item.source.kind !== 'remote-url' || typeof item.source.url !== 'string' || !item.source.url.trim()
|
|
113
|
+
|| (item.source.mediaType !== undefined && typeof item.source.mediaType !== 'string'))) invalid();
|
|
114
|
+
if (item.payload && (!PAYLOAD_FILE.test(item.payload.file) || !HASH.test(item.payload.sha256)
|
|
115
|
+
|| !Number.isSafeInteger(item.payload.size) || item.payload.size < 1
|
|
116
|
+
|| (item.payload.mediaType !== undefined && typeof item.payload.mediaType !== 'string'))) invalid();
|
|
117
|
+
if (!item.payload && !item.source) invalid();
|
|
118
|
+
}
|
|
119
|
+
return cache;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function deliveryCacheNeedsProvider(dataRoot, task) {
|
|
123
|
+
return readDeliveryCache(dataRoot, task).items.some(item => !item.payload);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function newDeliveryPayloadPath(dataRoot, cache) {
|
|
127
|
+
return path.join(directory(dataRoot, cache.taskId), 'payload-' + crypto.randomBytes(12).toString('hex') + '.bin');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function recordDeliveryPayload(dataRoot, cache, index, file, mediaType) {
|
|
131
|
+
const root = directory(dataRoot, cache.taskId);
|
|
132
|
+
if (path.dirname(file) !== root || !PAYLOAD_FILE.test(path.basename(file))) invalid();
|
|
133
|
+
cache.items[index].payload = { file: path.basename(file), ...fingerprint(file), mediaType };
|
|
134
|
+
delete cache.items[index].source;
|
|
135
|
+
save(dataRoot, cache);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function readDeliveryPayload(dataRoot, cache, index) {
|
|
139
|
+
const payload = cache.items[index].payload;
|
|
140
|
+
const file = path.join(directory(dataRoot, cache.taskId), payload.file);
|
|
141
|
+
let actual;
|
|
142
|
+
try { actual = fingerprint(file); } catch (_) { invalid(); }
|
|
143
|
+
if (actual.size !== payload.size || actual.sha256 !== payload.sha256) invalid();
|
|
144
|
+
return file;
|
|
145
|
+
}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { CoreContractError, normalizeCoreOptions } from './core-contract.js';
|
|
7
|
+
import { newWriterOwner, publishLeaseRecord, readWriterLease, recoveryClaimFile, removeWriterIntent,
|
|
8
|
+
retireLeaseDirectory, withdrawLeaseRecord,
|
|
9
|
+
writerOwnerStatus } from './core-writer-lease.js';
|
|
8
10
|
|
|
9
|
-
const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
|
|
10
|
-
const WRITER_OWNER_FILE = 'owner.json';
|
|
11
11
|
const RECOVERY_AUDIT_DIR = '.iris-runtime-recovery-audit-v0';
|
|
12
12
|
|
|
13
13
|
function fail(code, message) {
|
|
@@ -27,34 +27,6 @@ function physicalDataRoot(dataRoot) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function readLease(root) {
|
|
31
|
-
const directory = path.join(root, WRITER_LEASE_DIR);
|
|
32
|
-
const stat = fs.lstatSync(directory);
|
|
33
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid lease directory');
|
|
34
|
-
const entries = fs.readdirSync(directory);
|
|
35
|
-
if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('invalid lease contents');
|
|
36
|
-
const ownerFile = path.join(directory, WRITER_OWNER_FILE);
|
|
37
|
-
const ownerStat = fs.lstatSync(ownerFile);
|
|
38
|
-
if (!ownerStat.isFile() || ownerStat.isSymbolicLink() || ownerStat.size > 16384) throw new Error('invalid owner');
|
|
39
|
-
const serialized = fs.readFileSync(ownerFile, 'utf8');
|
|
40
|
-
const owner = JSON.parse(serialized);
|
|
41
|
-
const created = Date.parse(owner?.createdAt);
|
|
42
|
-
if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
|
|
43
|
-
|| !/^[a-f0-9-]{36}$/.test(String(owner.instanceId || '')) || !Number.isFinite(created)) {
|
|
44
|
-
throw new Error('incomplete owner');
|
|
45
|
-
}
|
|
46
|
-
return { directory, ownerFile, owner, serialized, created };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function ownerStatus(pid) {
|
|
50
|
-
try {
|
|
51
|
-
process.kill(pid, 0);
|
|
52
|
-
return 'alive';
|
|
53
|
-
} catch (error) {
|
|
54
|
-
return error?.code === 'ESRCH' ? 'missing' : 'unknown';
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
30
|
function auditDirectory(root) {
|
|
59
31
|
const directory = path.join(root, RECOVERY_AUDIT_DIR);
|
|
60
32
|
try {
|
|
@@ -68,15 +40,12 @@ function auditDirectory(root) {
|
|
|
68
40
|
return directory;
|
|
69
41
|
}
|
|
70
42
|
|
|
71
|
-
function
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (actual.instanceId !== lease.owner.instanceId) throw new Error('recovery owner changed');
|
|
78
|
-
fs.unlinkSync(lease.ownerFile);
|
|
79
|
-
fs.rmdirSync(lease.directory);
|
|
43
|
+
function requireMissingOwners(lease) {
|
|
44
|
+
for (const owner of lease.owners) {
|
|
45
|
+
const status = writerOwnerStatus(owner.pid);
|
|
46
|
+
if (status === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
|
|
47
|
+
if (status !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
|
|
48
|
+
}
|
|
80
49
|
}
|
|
81
50
|
|
|
82
51
|
/**
|
|
@@ -85,41 +54,45 @@ function releaseRecoveryLease(lease) {
|
|
|
85
54
|
*/
|
|
86
55
|
export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
|
|
87
56
|
const root = physicalDataRoot(dataRoot);
|
|
88
|
-
const leaseDirectory = path.join(root, WRITER_LEASE_DIR);
|
|
89
|
-
try { fs.lstatSync(leaseDirectory); }
|
|
90
|
-
catch (error) {
|
|
91
|
-
if (error?.code === 'ENOENT') fail('IRIS_CORE_LEASE_NOT_FOUND', '未发现可恢复的 Iris 写者租约');
|
|
92
|
-
fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据无法读取;已保留现场');
|
|
93
|
-
}
|
|
94
57
|
let lease;
|
|
95
|
-
try { lease =
|
|
58
|
+
try { lease = readWriterLease(root); }
|
|
96
59
|
catch (_) { fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据不完整;已保留现场'); }
|
|
60
|
+
if (!lease) fail('IRIS_CORE_LEASE_NOT_FOUND', '未发现可恢复的 Iris 写者租约');
|
|
97
61
|
if (!Number.isSafeInteger(confirmStalePid) || confirmStalePid <= 0 || confirmStalePid !== lease.owner.pid) {
|
|
98
62
|
fail('IRIS_CORE_LEASE_CONFIRMATION_MISMATCH', '确认的 owner PID 与租约证据不一致;未执行恢复');
|
|
99
63
|
}
|
|
100
|
-
|
|
101
|
-
if (processStatus === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
|
|
102
|
-
if (processStatus !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
|
|
64
|
+
requireMissingOwners(lease);
|
|
103
65
|
|
|
104
66
|
const auditId = 'lease_recovery_' + crypto.randomBytes(12).toString('hex');
|
|
105
|
-
const instanceId = crypto.randomUUID();
|
|
106
67
|
const recoveredAt = new Date().toISOString();
|
|
107
|
-
const recoveryOwner =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
});
|
|
113
|
-
const recoverySerialized = JSON.stringify(recoveryOwner, null, 2) + '\n';
|
|
114
|
-
const staleOwnerFile = path.join(lease.directory, `.owner.stale-${instanceId}.json`);
|
|
115
|
-
let tookLease = false;
|
|
68
|
+
const recoveryOwner = newWriterOwner();
|
|
69
|
+
const claimFile = recoveryClaimFile(root, lease.owner);
|
|
70
|
+
const claim = { schemaVersion: 1, previousInstanceId: lease.owner.instanceId, owner: recoveryOwner };
|
|
71
|
+
let published = false;
|
|
72
|
+
let retired = false;
|
|
116
73
|
let auditFile = null;
|
|
117
74
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
75
|
+
if (readWriterLease(root)?.fingerprint !== lease.fingerprint) throw new Error('lease changed');
|
|
76
|
+
if (!lease.intent) {
|
|
77
|
+
// 兼容旧 owner 目录;固定意图屏障使目录退役后的中断也可诊断和续办。
|
|
78
|
+
fs.linkSync(path.join(lease.directory, 'owner.json'), lease.intentFile);
|
|
79
|
+
lease = readWriterLease(root);
|
|
80
|
+
requireMissingOwners(lease);
|
|
81
|
+
}
|
|
82
|
+
if (!lease.stat) {
|
|
83
|
+
// 已原子发布意图,但在 mkdir 前被杀;建立占位后仍只允许显式恢复。
|
|
84
|
+
fs.mkdirSync(lease.directory, { mode: 0o700 });
|
|
85
|
+
lease = readWriterLease(root);
|
|
86
|
+
}
|
|
87
|
+
// 每一步以父 owner UUID 命名且独占发布;并发恢复只有一个能取得下一步。
|
|
88
|
+
// 前驱不删除,中断后可确认最近恢复 PID 并继续,证据不会消失。
|
|
89
|
+
publishLeaseRecord(root, claimFile, claim);
|
|
90
|
+
published = true;
|
|
91
|
+
const claimed = readWriterLease(root);
|
|
92
|
+
if (claimed?.owner.instanceId !== recoveryOwner.instanceId
|
|
93
|
+
|| claimed.stat?.dev !== lease.stat.dev || claimed.stat?.ino !== lease.stat.ino
|
|
94
|
+
|| claimed.intent?.serialized !== lease.intent.serialized) throw new Error('lease changed');
|
|
95
|
+
requireMissingOwners(lease);
|
|
123
96
|
|
|
124
97
|
const directory = auditDirectory(root);
|
|
125
98
|
auditFile = path.join(directory, auditId + '.json');
|
|
@@ -129,38 +102,37 @@ export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
|
|
|
129
102
|
action: 'writer-lease-recovery',
|
|
130
103
|
recoveredAt,
|
|
131
104
|
confirmation: { ownerPid: confirmStalePid },
|
|
132
|
-
staleOwner:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
105
|
+
staleOwner: lease.originalOwner,
|
|
106
|
+
recoveryOwner,
|
|
107
|
+
leasePhase: lease.phase,
|
|
108
|
+
evidenceDirectory: auditId + '.lease',
|
|
109
|
+
evidenceFingerprint: claimed.fingerprint,
|
|
110
|
+
recoveryClaims: claimed.claims.map(claim => claim.value),
|
|
111
|
+
authorization: 'retire-lease-directory'
|
|
139
112
|
};
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
113
|
+
publishLeaseRecord(root, auditFile, audit);
|
|
114
|
+
if (readWriterLease(root)?.fingerprint !== claimed.fingerprint) throw new Error('lease changed');
|
|
115
|
+
// 审计先发布,完整目录再原子退役。只在最终步骤撤下意图屏障。
|
|
116
|
+
retireLeaseDirectory(lease.directory, path.join(directory, audit.evidenceDirectory));
|
|
117
|
+
retired = true;
|
|
118
|
+
removeWriterIntent(lease);
|
|
119
|
+
// 意图屏障解除后只整理 UUID 专属旧证据,不再触碰当前租约路径。
|
|
120
|
+
for (const claim of claimed.claims) {
|
|
121
|
+
try { fs.renameSync(claim.file, path.join(directory, audit.evidenceDirectory, path.basename(claim.file))); }
|
|
122
|
+
catch (_) { /* 未整理的记录仍在根中,不阻止新 UUID 的 writer */ }
|
|
123
|
+
}
|
|
147
124
|
return Object.freeze({
|
|
148
125
|
status: 'recovered',
|
|
149
126
|
auditId,
|
|
150
|
-
staleOwner: Object.freeze({ pid: lease.
|
|
127
|
+
staleOwner: Object.freeze({ pid: lease.originalOwner.pid,
|
|
128
|
+
createdAt: new Date(Date.parse(lease.originalOwner.createdAt)).toISOString() })
|
|
151
129
|
});
|
|
152
130
|
} catch (error) {
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
if (fs.existsSync(lease.directory) && !fs.existsSync(lease.ownerFile)) {
|
|
160
|
-
if (fs.existsSync(staleOwnerFile)) fs.renameSync(staleOwnerFile, lease.ownerFile);
|
|
161
|
-
else writePrivateFile(lease.ownerFile, lease.serialized, { flag: 'wx' });
|
|
162
|
-
}
|
|
163
|
-
} catch (_) { /* 保留现场,由 Doctor 报告 invalid */ }
|
|
131
|
+
// 可捕获的错误只撤回本次最后一条 claim;原 owner 与旧恢复证据保持。
|
|
132
|
+
if (published && !retired) {
|
|
133
|
+
try { withdrawLeaseRecord(claimFile, claim); }
|
|
134
|
+
catch (_) { /* 保留可识别的恢复链供进程退出后显式重试 */ }
|
|
135
|
+
}
|
|
164
136
|
if (error instanceof CoreContractError) throw error;
|
|
165
137
|
fail('IRIS_CORE_LEASE_RECOVERY_FAILED',
|
|
166
138
|
auditFile && fs.existsSync(auditFile)
|
package/lib/core-maintenance.js
CHANGED
|
@@ -4,11 +4,12 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import { inspectCoreArtifact, refreshCoreArtifactInventory } from './core-artifacts.js';
|
|
5
5
|
import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
|
|
6
6
|
import { atomicWritePrivate } from './private-storage.js';
|
|
7
|
+
import { readDeliveryCache } from './core-delivery-cache.js';
|
|
7
8
|
|
|
8
9
|
const TASK = /^task_[a-f0-9]{24}$/;
|
|
9
10
|
const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
10
11
|
const TRANSACTION = /^delete_[a-f0-9]{24}$/;
|
|
11
|
-
const MANAGED = /^(?:task-store\/v0\/tasks\/task_[a-f0-9]{24}\.json|artifact-store\/v0\/(?:records|manifests)\/artifact_[a-f0-9]{24}\.json|artifact-store\/v0\/objects\/artifact_[a-f0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm|wav|mp3|ogg|flac|txt|json)|provider-staging\/v0\/download-[1-9][0-9]*-[a-f0-9]{16}\.part)$/;
|
|
12
|
+
const MANAGED = /^(?:task-store\/v0\/tasks\/task_[a-f0-9]{24}\.json|artifact-store\/v0\/(?:records|manifests)\/artifact_[a-f0-9]{24}\.json|artifact-store\/v0\/objects\/artifact_[a-f0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm|wav|mp3|ogg|flac|txt|json)|provider-staging\/v0\/download-[1-9][0-9]*-[a-f0-9]{16}\.part|delivery-cache\/v0\/task_[a-f0-9]{24}\/(?:result\.json|payload-[a-f0-9]{24}\.bin))$/;
|
|
12
13
|
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
13
14
|
function fileDigest(file) {
|
|
14
15
|
const hash = crypto.createHash('sha256'), chunk = Buffer.allocUnsafe(1024 * 1024), fd = fs.openSync(file, 'r');
|
|
@@ -68,6 +69,8 @@ export function planCoreDeletion(root, input) {
|
|
|
68
69
|
const task = inspectCoreTask(root, id);
|
|
69
70
|
if (task.phase !== 'terminal' || task.acceptance === 'unknown' || task.outcome === 'unknown') blockers.push({ id, reason: 'task_not_settled' });
|
|
70
71
|
files.push(managedFile(root, 'task-store/v0/tasks/' + id + '.json'));
|
|
72
|
+
const cache = 'delivery-cache/v0/' + id;
|
|
73
|
+
for (const name of names(root, cache)) files.push(managedFile(root, cache + '/' + name));
|
|
71
74
|
}
|
|
72
75
|
for (const id of artifactIds) {
|
|
73
76
|
inspectCoreArtifact(root, id);
|
|
@@ -83,6 +86,11 @@ export function planCoreDeletion(root, input) {
|
|
|
83
86
|
for (const task of existing.tasks) {
|
|
84
87
|
if (selectedTasks.has(task.id)) continue;
|
|
85
88
|
for (const id of task.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: task.id, reason: 'task_artifact_reference' });
|
|
89
|
+
if (task.deliveryCache && task.deliveryState !== 'ready') {
|
|
90
|
+
for (const item of readDeliveryCache(root, task).items) {
|
|
91
|
+
if (selectedArtifacts.has(item.artifactId)) blockers.push({ id: item.artifactId, referencedBy: task.id, reason: 'delivery_cache_reference' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
86
94
|
if (selectedArtifacts.has(task.sourceArtifactId)) blockers.push({ id: task.sourceArtifactId, referencedBy: task.id, reason: 'task_input_reference' });
|
|
87
95
|
if (selectedTasks.has(task.retriedFrom)) blockers.push({ id: task.retriedFrom, referencedBy: task.id, reason: 'retry_reference' });
|
|
88
96
|
}
|
|
@@ -109,6 +117,9 @@ export function planCoreCleanup(root, { older_than_ms = 3600000 } = {}) {
|
|
|
109
117
|
const referenced = new Set(existing.artifacts.map(artifact => artifact.id));
|
|
110
118
|
for (const artifact of existing.artifacts) for (const edge of artifact.relations) referenced.add(edge.artifactId);
|
|
111
119
|
for (const task of existing.tasks) for (const id of task.artifactIds) referenced.add(id);
|
|
120
|
+
for (const task of existing.tasks) if (task.deliveryCache && task.deliveryState !== 'ready') {
|
|
121
|
+
for (const item of readDeliveryCache(root, task).items) referenced.add(item.artifactId);
|
|
122
|
+
}
|
|
112
123
|
for (const task of existing.tasks) if (task.sourceArtifactId) referenced.add(task.sourceArtifactId);
|
|
113
124
|
for (const name of names(root, 'artifact-store/v0/manifests')) {
|
|
114
125
|
if (/^artifact_[a-f0-9]{24}\.json$/.test(name)) {
|