@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2
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 +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +69 -2
- package/README.en.md +67 -31
- package/README.md +67 -31
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +34 -19
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +183 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +21 -2
- package/docs/HEADLESS_CLI.md +15 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +21 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +77 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +37 -19
- package/docs/SECURITY.md +5 -5
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/docs/releases/0.2.2.md +31 -0
- package/lib/actions.js +199 -286
- package/lib/adapters.js +216 -41
- package/lib/api.js +49 -7
- package/lib/capability.js +23 -16
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +933 -117
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +102 -14
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +205 -175
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +29 -42
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +21 -10
- package/lib/prompt-optimizer-core.js +247 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +87 -10
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +235 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +84 -0
- package/lib/provider-task-runner.js +63 -19
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +74 -30
|
@@ -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)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { inspectCoreArtifact, refreshCoreArtifactInventory } from './core-artifacts.js';
|
|
5
|
+
import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
|
|
6
|
+
import { atomicWritePrivate } from './private-storage.js';
|
|
7
|
+
|
|
8
|
+
const TASK = /^task_[a-f0-9]{24}$/;
|
|
9
|
+
const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
10
|
+
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 fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
13
|
+
function fileDigest(file) {
|
|
14
|
+
const hash = crypto.createHash('sha256'), chunk = Buffer.allocUnsafe(1024 * 1024), fd = fs.openSync(file, 'r');
|
|
15
|
+
try { let size; while ((size = fs.readSync(fd, chunk, 0, chunk.length, null))) hash.update(chunk.subarray(0, size)); }
|
|
16
|
+
finally { fs.closeSync(fd); }
|
|
17
|
+
return hash.digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function directory(root, relative, create = false) {
|
|
21
|
+
let current = root;
|
|
22
|
+
for (const segment of relative.split('/')) {
|
|
23
|
+
current = path.join(current, segment);
|
|
24
|
+
if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
|
|
25
|
+
const stat = fs.lstatSync(current);
|
|
26
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_CORE_MAINTENANCE_UNSAFE', 'Core 管理目录不安全');
|
|
27
|
+
}
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function managedFile(root, relative) {
|
|
32
|
+
if (!MANAGED.test(relative)) fail('IRIS_CORE_MAINTENANCE_UNSAFE', '不是可管理的 Core 文件');
|
|
33
|
+
directory(root, path.posix.dirname(relative));
|
|
34
|
+
const file = path.join(root, relative);
|
|
35
|
+
const stat = fs.lstatSync(file);
|
|
36
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('IRIS_CORE_MAINTENANCE_UNSAFE', 'Core 文件不是普通文件');
|
|
37
|
+
return { path: relative, size: stat.size, digest: fileDigest(file) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function names(root, relative) {
|
|
41
|
+
if (!fs.existsSync(path.join(root, relative))) return [];
|
|
42
|
+
return fs.readdirSync(directory(root, relative));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inventory(root) {
|
|
46
|
+
const tasks = [];
|
|
47
|
+
for (let offset = 0; ; offset += 200) {
|
|
48
|
+
const page = listCoreTasks(root, { offset, limit: 200 }); tasks.push(...page.tasks);
|
|
49
|
+
if (offset + 200 >= page.total) break;
|
|
50
|
+
}
|
|
51
|
+
const artifacts = names(root, 'artifact-store/v0/records').filter(name => ARTIFACT.test(name.slice(0, -5)) && name.endsWith('.json'))
|
|
52
|
+
.map(name => inspectCoreArtifact(root, name.slice(0, -5)));
|
|
53
|
+
return { tasks, artifacts };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ids(value, pattern, label) {
|
|
57
|
+
if (value === undefined) return [];
|
|
58
|
+
if (!Array.isArray(value) || value.length > 200 || !value.every(id => typeof id === 'string' && pattern.test(id)) || new Set(value).size !== value.length) fail('IRIS_COMMAND_INPUT_INVALID', label + ' 必须是不重复的 Core ID 数组,最多 200 条');
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function planCoreDeletion(root, input) {
|
|
63
|
+
const taskIds = ids(input.task_ids, TASK, 'task_ids'), artifactIds = ids(input.artifact_ids, ARTIFACT, 'artifact_ids');
|
|
64
|
+
if (!taskIds.length && !artifactIds.length) fail('IRIS_COMMAND_INPUT_INVALID', '请明确指定 Task 或 Artifact ID');
|
|
65
|
+
const existing = inventory(root), selectedTasks = new Set(taskIds), selectedArtifacts = new Set(artifactIds);
|
|
66
|
+
const blockers = [], files = [];
|
|
67
|
+
for (const id of taskIds) {
|
|
68
|
+
const task = inspectCoreTask(root, id);
|
|
69
|
+
if (task.phase !== 'terminal' || task.acceptance === 'unknown' || task.outcome === 'unknown') blockers.push({ id, reason: 'task_not_settled' });
|
|
70
|
+
files.push(managedFile(root, 'task-store/v0/tasks/' + id + '.json'));
|
|
71
|
+
}
|
|
72
|
+
for (const id of artifactIds) {
|
|
73
|
+
inspectCoreArtifact(root, id);
|
|
74
|
+
const recordPath = 'artifact-store/v0/records/' + id + '.json';
|
|
75
|
+
const recordFile = managedFile(root, recordPath);
|
|
76
|
+
const record = JSON.parse(fs.readFileSync(path.join(root, recordPath)));
|
|
77
|
+
const manifestPath = 'artifact-store/v0/manifests/' + id + '.json';
|
|
78
|
+
const manifest = record.schemaVersion === 1 ? JSON.parse(fs.readFileSync(path.join(root, manifestPath))) : record;
|
|
79
|
+
files.push(recordFile);
|
|
80
|
+
if (record.schemaVersion === 1) files.push(managedFile(root, manifestPath));
|
|
81
|
+
files.push(managedFile(root, 'artifact-store/v0/objects/' + manifest.object));
|
|
82
|
+
}
|
|
83
|
+
for (const task of existing.tasks) {
|
|
84
|
+
if (selectedTasks.has(task.id)) continue;
|
|
85
|
+
for (const id of task.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: task.id, reason: 'task_artifact_reference' });
|
|
86
|
+
if (selectedArtifacts.has(task.sourceArtifactId)) blockers.push({ id: task.sourceArtifactId, referencedBy: task.id, reason: 'task_input_reference' });
|
|
87
|
+
if (selectedTasks.has(task.retriedFrom)) blockers.push({ id: task.retriedFrom, referencedBy: task.id, reason: 'retry_reference' });
|
|
88
|
+
}
|
|
89
|
+
for (const artifact of existing.artifacts) {
|
|
90
|
+
if (selectedArtifacts.has(artifact.id)) continue;
|
|
91
|
+
if (selectedTasks.has(artifact.metadata.taskId)) blockers.push({ id: artifact.metadata.taskId, referencedBy: artifact.id, reason: 'artifact_task_reference' });
|
|
92
|
+
for (const edge of artifact.relations) if (selectedArtifacts.has(edge.artifactId)) blockers.push({ id: edge.artifactId, referencedBy: artifact.id, reason: 'artifact_relation' });
|
|
93
|
+
}
|
|
94
|
+
// 未提交 Manifest 也可能仍引用目标;未知或损坏的条目保留并阻止误判。
|
|
95
|
+
const committed = new Set(existing.artifacts.map(artifact => artifact.id));
|
|
96
|
+
for (const name of names(root, 'artifact-store/v0/manifests')) {
|
|
97
|
+
if (!/^artifact_[a-f0-9]{24}\.json$/.test(name) || committed.has(name.slice(0, -5))) continue;
|
|
98
|
+
const relative = 'artifact-store/v0/manifests/' + name; managedFile(root, relative);
|
|
99
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, relative)));
|
|
100
|
+
for (const edge of manifest.relations || []) if (selectedArtifacts.has(edge.artifactId)) blockers.push({ id: edge.artifactId, referencedBy: name.slice(0, -5), reason: 'uncommitted_relation' });
|
|
101
|
+
}
|
|
102
|
+
return { operation: 'delete', task_ids: taskIds, artifact_ids: artifactIds, allowed: !blockers.length, blockers,
|
|
103
|
+
entries: files, bytes: files.reduce((sum, entry) => sum + entry.size, 0) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function planCoreCleanup(root, { older_than_ms = 3600000 } = {}) {
|
|
107
|
+
if (!Number.isSafeInteger(older_than_ms) || older_than_ms < 60000) fail('IRIS_COMMAND_INPUT_INVALID', 'older_than_ms 至少为 60000');
|
|
108
|
+
const existing = inventory(root);
|
|
109
|
+
const referenced = new Set(existing.artifacts.map(artifact => artifact.id));
|
|
110
|
+
for (const artifact of existing.artifacts) for (const edge of artifact.relations) referenced.add(edge.artifactId);
|
|
111
|
+
for (const task of existing.tasks) for (const id of task.artifactIds) referenced.add(id);
|
|
112
|
+
for (const task of existing.tasks) if (task.sourceArtifactId) referenced.add(task.sourceArtifactId);
|
|
113
|
+
for (const name of names(root, 'artifact-store/v0/manifests')) {
|
|
114
|
+
if (/^artifact_[a-f0-9]{24}\.json$/.test(name)) {
|
|
115
|
+
referenced.add(name.slice(0, -5));
|
|
116
|
+
const relative = 'artifact-store/v0/manifests/' + name; managedFile(root, relative);
|
|
117
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, relative)));
|
|
118
|
+
for (const edge of manifest.relations || []) referenced.add(edge.artifactId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const candidates = [];
|
|
122
|
+
for (const relativeDirectory of ['provider-staging/v0', 'artifact-store/v0/objects']) {
|
|
123
|
+
for (const name of names(root, relativeDirectory)) {
|
|
124
|
+
const relative = relativeDirectory + '/' + name;
|
|
125
|
+
if (!MANAGED.test(relative)) continue;
|
|
126
|
+
const stat = fs.lstatSync(path.join(root, relative));
|
|
127
|
+
if (!stat.isFile() || stat.isSymbolicLink() || Date.now() - stat.mtimeMs < older_than_ms) continue;
|
|
128
|
+
if (relativeDirectory === 'provider-staging/v0') {
|
|
129
|
+
const pid = Number(name.split('-')[1]);
|
|
130
|
+
try { process.kill(pid, 0); continue; } catch (error) { if (error.code !== 'ESRCH') continue; }
|
|
131
|
+
} else if (referenced.has(name.slice(0, name.lastIndexOf('.')))) continue;
|
|
132
|
+
candidates.push(managedFile(root, relative));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { operation: 'cleanup', candidates, bytes: candidates.reduce((sum, entry) => sum + entry.size, 0) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const transactionDirectory = (root, id, create = false) => {
|
|
139
|
+
if (!TRANSACTION.test(id)) fail('IRIS_COMMAND_INPUT_INVALID', '隔离事务 ID 无效');
|
|
140
|
+
return directory(root, 'maintenance/v0/quarantine/' + id, create);
|
|
141
|
+
};
|
|
142
|
+
const refresh = root => { if (fs.existsSync(path.join(root, 'artifact-store/v0'))) refreshCoreArtifactInventory(root); };
|
|
143
|
+
|
|
144
|
+
/** 写前清单 + rename;失败时补偿,保留事务用于检查/恢复,绝不永久 unlink 媒体。 */
|
|
145
|
+
export function quarantineCoreFiles(root, plan) {
|
|
146
|
+
if (!plan.allowed || !plan.entries.length) fail('IRIS_CORE_DELETE_BLOCKED', '删除计划为空或仍被引用,不能执行');
|
|
147
|
+
const id = 'delete_' + crypto.randomBytes(12).toString('hex'), base = transactionDirectory(root, id, true);
|
|
148
|
+
const journal = { schemaVersion: 0, id, state: 'prepared', createdAt: new Date().toISOString(), operation: plan.operation, entries: plan.entries };
|
|
149
|
+
const journalFile = path.join(base, 'transaction.json');
|
|
150
|
+
atomicWritePrivate(journalFile, JSON.stringify(journal, null, 2) + '\n');
|
|
151
|
+
const moved = [];
|
|
152
|
+
try {
|
|
153
|
+
for (const entry of journal.entries) {
|
|
154
|
+
const actual = managedFile(root, entry.path);
|
|
155
|
+
if (actual.digest !== entry.digest || actual.size !== entry.size) fail('IRIS_CORE_MAINTENANCE_CHANGED', '预览后文件发生变化');
|
|
156
|
+
directory(base, path.posix.dirname(entry.path), true);
|
|
157
|
+
fs.renameSync(path.join(root, entry.path), path.join(base, entry.path)); moved.push(entry);
|
|
158
|
+
}
|
|
159
|
+
refresh(root);
|
|
160
|
+
journal.state = 'committed'; atomicWritePrivate(journalFile, JSON.stringify(journal, null, 2) + '\n');
|
|
161
|
+
return { transactionId: id, state: journal.state, files: moved.length, bytes: moved.reduce((sum, entry) => sum + entry.size, 0), recoverable: true };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
try {
|
|
164
|
+
for (const entry of [...moved].reverse()) fs.renameSync(path.join(base, entry.path), path.join(root, entry.path));
|
|
165
|
+
refresh(root); journal.state = 'rolled-back';
|
|
166
|
+
} catch (_) { journal.state = 'interrupted'; }
|
|
167
|
+
atomicWritePrivate(journalFile, JSON.stringify(journal, null, 2) + '\n');
|
|
168
|
+
throw Object.assign(new Error('Core 隔离操作未完成,已保留恢复清单:' + id + '(' + journal.state + ')'), { code: 'IRIS_CORE_MAINTENANCE_FAILED', transactionId: id, state: journal.state, cause: error });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function inspectCoreTransactions(root) {
|
|
173
|
+
return names(root, 'maintenance/v0/quarantine').filter(id => TRANSACTION.test(id)).map(id => {
|
|
174
|
+
const base = transactionDirectory(root, id);
|
|
175
|
+
const file = path.join(base, 'transaction.json'), stat = fs.lstatSync(file);
|
|
176
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('IRIS_CORE_MAINTENANCE_UNSAFE', '隔离清单不是普通文件');
|
|
177
|
+
const journal = JSON.parse(fs.readFileSync(file));
|
|
178
|
+
return { transactionId: id, state: journal.state, operation: journal.operation, createdAt: journal.createdAt, files: journal.entries.length };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function restoreCoreTransaction(root, id) {
|
|
183
|
+
const base = transactionDirectory(root, id), journalFile = path.join(base, 'transaction.json');
|
|
184
|
+
const stat = fs.lstatSync(journalFile);
|
|
185
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('IRIS_CORE_MAINTENANCE_UNSAFE', '隔离清单不安全');
|
|
186
|
+
const journal = JSON.parse(fs.readFileSync(journalFile));
|
|
187
|
+
if (journal.schemaVersion !== 0 || journal.id !== id || !Array.isArray(journal.entries)
|
|
188
|
+
|| !['prepared', 'committed', 'interrupted'].includes(journal.state)) fail('IRIS_CORE_RESTORE_INVALID', '事务不处于可恢复状态');
|
|
189
|
+
const restoring = [];
|
|
190
|
+
for (const entry of journal.entries) {
|
|
191
|
+
if (!MANAGED.test(entry.path)) fail('IRIS_CORE_MAINTENANCE_UNSAFE', '隔离清单包含不支持的路径');
|
|
192
|
+
if (fs.existsSync(path.join(base, entry.path))) {
|
|
193
|
+
const actual = managedFile(base, entry.path);
|
|
194
|
+
if (actual.digest !== entry.digest || actual.size !== entry.size) fail('IRIS_CORE_MAINTENANCE_CHANGED', '隔离文件哈希或大小改变');
|
|
195
|
+
if (fs.existsSync(path.join(root, entry.path))) fail('IRIS_CORE_RESTORE_CONFLICT', '恢复目标已存在,不覆盖已有数据');
|
|
196
|
+
directory(root, path.posix.dirname(entry.path)); restoring.push(entry);
|
|
197
|
+
} else {
|
|
198
|
+
const actual = managedFile(root, entry.path);
|
|
199
|
+
if (actual.digest !== entry.digest) fail('IRIS_CORE_MAINTENANCE_CHANGED', '事务原位置文件改变');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const moved = [];
|
|
203
|
+
try {
|
|
204
|
+
for (const entry of restoring) { fs.renameSync(path.join(base, entry.path), path.join(root, entry.path)); moved.push(entry); }
|
|
205
|
+
refresh(root);
|
|
206
|
+
journal.state = 'restored'; atomicWritePrivate(journalFile, JSON.stringify(journal, null, 2) + '\n');
|
|
207
|
+
return { transactionId: id, state: 'restored', files: moved.length };
|
|
208
|
+
} catch (_) {
|
|
209
|
+
for (const entry of [...moved].reverse()) fs.renameSync(path.join(root, entry.path), path.join(base, entry.path));
|
|
210
|
+
refresh(root);
|
|
211
|
+
fail('IRIS_CORE_RESTORE_FAILED', '恢复失败,文件已回到隔离目录');
|
|
212
|
+
}
|
|
213
|
+
}
|
package/lib/core-runtime.js
CHANGED
|
@@ -12,36 +12,20 @@ import {
|
|
|
12
12
|
normalizeCoreOptions,
|
|
13
13
|
transitionCoreLifecycle
|
|
14
14
|
} from './core-contract.js';
|
|
15
|
-
import { ensurePrivateDir
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const WRITER_OWNER_FILE = 'owner.json';
|
|
15
|
+
import { ensurePrivateDir } from './private-storage.js';
|
|
16
|
+
import { WRITER_LEASE_DIR, WRITER_INTENT_FILE, publishLeaseRecord, readWriterLease,
|
|
17
|
+
removeWriterIntent, retireLeaseDirectory, writerOwnerStatus } from './core-writer-lease.js';
|
|
19
18
|
|
|
20
19
|
/** 只读租约证据;PID 缺失不等于已经获准恢复,绝不自动夺取租约。 */
|
|
21
20
|
export function inspectCoreWriterLease(dataRoot) {
|
|
22
21
|
const options = normalizeCoreOptions({ dataRoot, mode: 'reader' });
|
|
23
|
-
const directory = path.join(options.dataRoot, WRITER_LEASE_DIR);
|
|
24
|
-
let stat;
|
|
25
|
-
try { stat = fs.lstatSync(directory); }
|
|
26
|
-
catch (error) { return { status: error?.code === 'ENOENT' ? 'absent' : 'invalid' }; }
|
|
27
22
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const ownerStat = fs.lstatSync(ownerFile);
|
|
33
|
-
if (!ownerStat.isFile() || ownerStat.isSymbolicLink() || ownerStat.size > 16384) throw new Error('invalid owner');
|
|
34
|
-
const owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
|
|
35
|
-
const created = Date.parse(owner?.createdAt);
|
|
36
|
-
if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
|
|
37
|
-
|| !/^[a-f0-9-]{36}$/.test(String(owner.instanceId || '')) || !Number.isFinite(created)) {
|
|
38
|
-
throw new Error('incomplete owner');
|
|
39
|
-
}
|
|
40
|
-
let ownerStatus = 'alive';
|
|
41
|
-
try { process.kill(owner.pid, 0); }
|
|
42
|
-
catch (error) { ownerStatus = error?.code === 'ESRCH' ? 'missing' : 'unknown'; }
|
|
23
|
+
const lease = readWriterLease(options.dataRoot);
|
|
24
|
+
if (!lease) return { status: 'absent' };
|
|
25
|
+
const owner = lease.owner;
|
|
26
|
+
const created = Date.parse(owner.createdAt);
|
|
43
27
|
return {
|
|
44
|
-
status: 'present', ownerStatus, pid: owner.pid,
|
|
28
|
+
status: 'present', ownerStatus: writerOwnerStatus(owner.pid), pid: owner.pid, phase: lease.phase,
|
|
45
29
|
createdAt: new Date(created).toISOString(), ageMs: Math.max(0, Date.now() - created)
|
|
46
30
|
};
|
|
47
31
|
} catch (_) { return { status: 'invalid' }; }
|
|
@@ -77,56 +61,70 @@ function physicalDataRoot(options) {
|
|
|
77
61
|
|
|
78
62
|
function acquireWriterLease(root, instanceId) {
|
|
79
63
|
const directory = path.join(root, WRITER_LEASE_DIR);
|
|
80
|
-
const ownerFile = path.join(directory,
|
|
64
|
+
const ownerFile = path.join(directory, 'owner.json');
|
|
65
|
+
const intentFile = path.join(root, WRITER_INTENT_FILE);
|
|
81
66
|
try {
|
|
82
|
-
fs.
|
|
67
|
+
fs.lstatSync(directory);
|
|
68
|
+
throw coreDataRootBusyError();
|
|
83
69
|
} catch (error) {
|
|
84
|
-
if (error
|
|
85
|
-
|
|
70
|
+
if (error?.code !== 'ENOENT') {
|
|
71
|
+
if (error instanceof CoreContractError) throw error;
|
|
72
|
+
throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法检查 Iris 数据根写者租约');
|
|
73
|
+
}
|
|
86
74
|
}
|
|
87
|
-
|
|
88
75
|
const owner = Object.freeze({
|
|
89
76
|
contractVersion: CORE_CONTRACT_VERSION,
|
|
90
77
|
instanceId,
|
|
91
78
|
pid: process.pid,
|
|
92
79
|
createdAt: new Date().toISOString()
|
|
93
80
|
});
|
|
94
|
-
const serialized = JSON.stringify(owner, null, 2) + '\n';
|
|
95
81
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
82
|
+
// 创建目录之前已有完整 owner;SIGKILL 不会留下无法确认 PID 的新空锁。
|
|
83
|
+
publishLeaseRecord(root, intentFile, owner);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error?.code === 'EEXIST') throw coreDataRootBusyError();
|
|
100
86
|
throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法记录 Iris 数据根写者租约');
|
|
101
87
|
}
|
|
102
|
-
|
|
88
|
+
let madeDirectory = false;
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
91
|
+
madeDirectory = true;
|
|
92
|
+
fs.linkSync(intentFile, ownerFile);
|
|
93
|
+
return Object.freeze({ root, directory, ownerFile, owner, evidence: readWriterLease(root) });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// 仅撤回本次创建的空目录和意图;不碰其他 writer 或未知目录内容。
|
|
96
|
+
let withdrawn = !madeDirectory;
|
|
97
|
+
if (madeDirectory) {
|
|
98
|
+
try { fs.rmdirSync(directory); withdrawn = true; } catch (_) { /* owner/目录证据保留 */ }
|
|
99
|
+
}
|
|
100
|
+
if (withdrawn) { try { fs.unlinkSync(intentFile); } catch (_) { /* 保留 owner 供显式恢复 */ } }
|
|
101
|
+
if (error?.code === 'EEXIST') throw coreDataRootBusyError();
|
|
102
|
+
throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法取得 Iris 数据根写者租约');
|
|
103
|
+
}
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
function releaseWriterLease(lease) {
|
|
106
107
|
let actual;
|
|
107
108
|
try {
|
|
108
|
-
|
|
109
|
-
if (!
|
|
110
|
-
const entries = fs.readdirSync(lease.directory);
|
|
111
|
-
if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('unexpected lease contents');
|
|
112
|
-
actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
|
|
109
|
+
actual = readWriterLease(lease.root);
|
|
110
|
+
if (!actual || actual.fingerprint !== lease.evidence.fingerprint || actual.phase !== 'owned') throw new Error('lease changed');
|
|
113
111
|
} catch (_) {
|
|
114
112
|
throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约无法安全释放;已保留现场');
|
|
115
113
|
}
|
|
116
|
-
if (actual.instanceId !== lease.owner.instanceId) {
|
|
114
|
+
if (actual.owner.instanceId !== lease.owner.instanceId) {
|
|
117
115
|
throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约所有者不匹配;已保留现场');
|
|
118
116
|
}
|
|
119
117
|
|
|
118
|
+
const retired = path.join(lease.root, WRITER_LEASE_DIR + '.released-' + lease.owner.instanceId);
|
|
120
119
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
// 整体移开非空目录;owner 从不在占位目录中先被删除。
|
|
121
|
+
retireLeaseDirectory(lease.directory, retired);
|
|
122
|
+
removeWriterIntent(actual);
|
|
123
123
|
} catch (_) {
|
|
124
|
-
// 若目录删除失败,尽力恢复 owner 证据;绝不把仍不确定的根宣布为可写。
|
|
125
|
-
if (fs.existsSync(lease.directory) && !fs.existsSync(lease.ownerFile)) {
|
|
126
|
-
try { writePrivateFile(lease.ownerFile, lease.serialized, { flag: 'wx' }); } catch (_) { /* 保持失败 */ }
|
|
127
|
-
}
|
|
128
124
|
throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约释放失败;已保留现场');
|
|
129
125
|
}
|
|
126
|
+
// 退役目录已不占有租约。中断或清理失败只留下可读证据,不阻止后续 writer。
|
|
127
|
+
try { fs.unlinkSync(path.join(retired, 'owner.json')); fs.rmdirSync(retired); } catch (_) { /* 保留退役现场 */ }
|
|
130
128
|
}
|
|
131
129
|
|
|
132
130
|
function runtimeSnapshot(options, lifecycle, hasLease, inFlight) {
|
package/lib/core-tasks.js
CHANGED
|
@@ -11,6 +11,7 @@ import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
|
|
|
11
11
|
const STORE_DIR = 'task-store';
|
|
12
12
|
const STORE_VERSION = 'v0';
|
|
13
13
|
const TASK_ID = /^task_[a-f0-9]{24}$/;
|
|
14
|
+
const ARTIFACT_ID = /^artifact_[a-f0-9]{24}$/;
|
|
14
15
|
const ATTEMPT_ID = /^attempt_[a-f0-9]{24}$/;
|
|
15
16
|
const PROVIDER_BINDING = /^sha256:[a-f0-9]{64}$/;
|
|
16
17
|
const CAPABILITIES = new Set(['image', 'video', 'tts', 'transcribe']);
|
|
@@ -106,7 +107,8 @@ function validateRecord(record, expectedId) {
|
|
|
106
107
|
|| !Number.isSafeInteger(record.revision) || record.revision < 1
|
|
107
108
|
|| typeof record.createdAt !== 'string' || typeof record.updatedAt !== 'string'
|
|
108
109
|
|| (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
|
|
109
|
-
|| (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
|
|
110
|
+
|| (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
|
|
111
|
+
|| (record.sourceArtifactId !== undefined && (record.capability !== 'image' || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
|
|
110
112
|
fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
|
|
111
113
|
}
|
|
112
114
|
if (record.attempts.some((attempt, index) => !attempt || !ATTEMPT_ID.test(attempt.id)
|
|
@@ -170,6 +172,8 @@ export function createCoreTask(dataRoot, input = {}) {
|
|
|
170
172
|
if (!CAPABILITIES.has(capability)) fail('IRIS_TASK_INPUT_INVALID', 'Task capability 无效');
|
|
171
173
|
const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
|
|
172
174
|
if (retriedFrom !== undefined && !TASK_ID.test(retriedFrom)) fail('IRIS_TASK_INPUT_INVALID', 'retriedFrom 必须是合法 Task ID');
|
|
175
|
+
const sourceArtifactId = input.sourceArtifactId;
|
|
176
|
+
if (sourceArtifactId !== undefined && (capability !== 'image' || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '改图 sourceArtifactId 必须是合法 Artifact ID');
|
|
173
177
|
const now = new Date().toISOString();
|
|
174
178
|
return writeRecord(dataRoot, {
|
|
175
179
|
schemaVersion: 0,
|
|
@@ -189,7 +193,8 @@ export function createCoreTask(dataRoot, input = {}) {
|
|
|
189
193
|
createdAt: now,
|
|
190
194
|
updatedAt: now,
|
|
191
195
|
// D4 retry as new task:显式单向关系(新任务指向旧任务),绝不复制 Prompt。
|
|
192
|
-
...(retriedFrom !== undefined ? { retriedFrom } : {})
|
|
196
|
+
...(retriedFrom !== undefined ? { retriedFrom } : {}),
|
|
197
|
+
...(sourceArtifactId !== undefined ? { sourceArtifactId } : {})
|
|
193
198
|
});
|
|
194
199
|
}
|
|
195
200
|
|
|
@@ -202,7 +207,7 @@ export function inspectCoreTask(dataRoot, id) {
|
|
|
202
207
|
* skipInvalid 只服务用户侧只读投影:单条损坏记录跳过并计入 dropped,
|
|
203
208
|
* 其余记录照常返回;默认严格模式不变,损坏记录仍使整体读取失败。
|
|
204
209
|
*/
|
|
205
|
-
export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid = false } = {}) {
|
|
210
|
+
export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid = false, status, capability, provider_id, model_ref, outcome, delivery_state } = {}) {
|
|
206
211
|
const start = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
207
212
|
const size = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
|
|
208
213
|
const directory = taskDirectory(dataRoot);
|
|
@@ -236,9 +241,11 @@ export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid =
|
|
|
236
241
|
}
|
|
237
242
|
}
|
|
238
243
|
records.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
244
|
+
const matches = records.filter(record => Object.entries({ status, capability, providerId: provider_id, modelRef: model_ref, outcome, deliveryState: delivery_state })
|
|
245
|
+
.every(([field, value]) => value === undefined || record[field] === value));
|
|
239
246
|
return Object.freeze({
|
|
240
|
-
schemaVersion: 0, total:
|
|
241
|
-
tasks: Object.freeze(
|
|
247
|
+
schemaVersion: 0, total: matches.length, offset: start, limit: size, dropped,
|
|
248
|
+
tasks: Object.freeze(matches.slice(start, start + size).map(publicTask))
|
|
242
249
|
});
|
|
243
250
|
}
|
|
244
251
|
|
|
@@ -150,6 +150,7 @@ export function projectCoreTaskUserRow(record, artifacts = []) {
|
|
|
150
150
|
updatedAt: String(record && record.updatedAt || ''),
|
|
151
151
|
revision: Number.isSafeInteger(record && record.revision) ? record.revision : 0,
|
|
152
152
|
artifactIds: Object.freeze(artifactIds),
|
|
153
|
+
...(record && record.sourceArtifactId ? { sourceArtifactId: record.sourceArtifactId } : {}),
|
|
153
154
|
mediaReady,
|
|
154
155
|
observable: coreTaskObservable(record),
|
|
155
156
|
redeliverable: coreTaskRedeliverable(record),
|