@mokuyoaxis/dsh-iris 0.1.3 → 0.2.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.
Files changed (91) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
  2. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
  3. package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
  4. package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
  5. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
  6. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
  7. package/CHANGELOG.md +77 -2
  8. package/README.en.md +98 -19
  9. package/README.md +98 -19
  10. package/bin/dsh-iris.js +392 -13
  11. package/docs/AGENT_QUICKSTART.md +35 -0
  12. package/docs/ARCHITECTURE.md +82 -52
  13. package/docs/ARTIFACT_LIBRARY.md +24 -0
  14. package/docs/ARTIFACT_MANIFEST.md +60 -0
  15. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  16. package/docs/DSH_CORE_MIGRATION.md +106 -0
  17. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  18. package/docs/HEADLESS_CLI.md +217 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
  20. package/docs/HOST_DOCTOR.md +49 -0
  21. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  22. package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
  23. package/docs/PROVIDER_HEALTH.md +39 -0
  24. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  25. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  26. package/docs/ROADMAP.md +42 -76
  27. package/docs/SECURITY.md +10 -5
  28. package/docs/TASK_SEMANTICS.md +3 -2
  29. package/docs/assets/diagrams/iris-architecture.png +0 -0
  30. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  31. package/docs/assets/logo/README.md +55 -0
  32. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  33. package/docs/assets/logo/iris-bubble.svg +14 -0
  34. package/docs/assets/logo/iris-interim-flower.png +0 -0
  35. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  36. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  37. package/docs/assets/logo/iris-logo.svg +78 -0
  38. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  39. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  40. package/docs/assets/logo/iris-mark.svg +42 -0
  41. package/docs/releases/0.1.4.md +59 -0
  42. package/docs/releases/0.2.0.md +48 -0
  43. package/lib/actions.js +673 -525
  44. package/lib/adapters.js +58 -21
  45. package/lib/api.js +182 -16
  46. package/lib/artifacts.js +239 -0
  47. package/lib/bundled-skills.js +5 -5
  48. package/lib/client.js +1116 -86
  49. package/lib/command-service.js +420 -0
  50. package/lib/config.js +245 -49
  51. package/lib/core-artifact-store.js +598 -0
  52. package/lib/core-artifacts.js +4 -0
  53. package/lib/core-attention.js +103 -0
  54. package/lib/core-contract.js +105 -0
  55. package/lib/core-doctor.js +41 -0
  56. package/lib/core-lease-recovery.js +170 -0
  57. package/lib/core-runtime.js +232 -0
  58. package/lib/core-tasks.js +560 -0
  59. package/lib/core-user-projection.js +192 -0
  60. package/lib/doctor.js +260 -30
  61. package/lib/dsh-core-adapter.js +599 -0
  62. package/lib/dsh-host-adapter.js +240 -0
  63. package/lib/generation-input.js +95 -0
  64. package/lib/guard.js +3 -3
  65. package/lib/host-contract.js +131 -0
  66. package/lib/host-runtime.js +156 -0
  67. package/lib/index.js +255 -166
  68. package/lib/locate.js +1 -1
  69. package/lib/media.js +16 -6
  70. package/lib/model-invoker.js +218 -0
  71. package/lib/model-port-contract.js +333 -0
  72. package/lib/models.js +97 -11
  73. package/lib/prompt-optimizer.js +10 -20
  74. package/lib/provider-adapter.js +397 -0
  75. package/lib/provider-adapters.js +342 -0
  76. package/lib/provider-catalog.js +171 -0
  77. package/lib/provider-contract.js +72 -3
  78. package/lib/provider-health.js +59 -0
  79. package/lib/provider-protocol.js +51 -2
  80. package/lib/provider-task-runner.js +328 -0
  81. package/lib/render.js +43 -12
  82. package/lib/tasks.js +45 -9
  83. package/lib/vision.js +51 -56
  84. package/package.json +20 -14
  85. package/user_guide.md +67 -12
  86. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  87. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  88. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  89. package/docs/releases/0.1.1.md +0 -51
  90. package/docs/releases/0.1.2.md +0 -26
  91. package/docs/releases/0.1.3.md +0 -30
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ /**
3
+ * Core 任务的注意力处置偏好(Host 侧,绝不写入 Core 数据根)。
4
+ *
5
+ * 语义(与 legacy attentionDisposition 对齐):
6
+ * - acknowledged:「不再提醒」——attention 行静默,仍留在历史与高级诊断;
7
+ * - hidden:「移除」——从任务区/泡泡消失,Core 记录完整保留,诊断可查、可恢复;
8
+ * - 只存 Task ID 与时间戳,不存任务内容、Prompt、远端 ID 或路径。
9
+ *
10
+ * 存储:$DSH_HOME/iris/v1/core-attention.json(0600,原子写 + rename)。
11
+ * 读取纪律:文件缺失或损坏一律降级为"无偏好",绝不报错;reader 路径不写文件。
12
+ * 自动静默(重试成功)不落偏好,由快照从 retriedFrom + 后继终态实时派生。
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { irisHome } from './config.js';
17
+ import { atomicWritePrivate } from './private-storage.js';
18
+
19
+ export const CORE_ATTENTION_VERSION = 1;
20
+ const TASK_ID = /^task_[a-f0-9]{24}$/;
21
+
22
+ function attentionFile() {
23
+ return path.join(irisHome(), 'core-attention.json');
24
+ }
25
+
26
+ /** reader:缺失/损坏一律返回无偏好。绝不写文件。 */
27
+ export function readCoreAttentionPrefs() {
28
+ try {
29
+ const raw = fs.readFileSync(attentionFile(), 'utf8');
30
+ const parsed = JSON.parse(raw);
31
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
32
+ || !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
33
+ return { version: CORE_ATTENTION_VERSION, entries: {} };
34
+ }
35
+ const entries = {};
36
+ for (const [id, entry] of Object.entries(parsed.entries)) {
37
+ if (!TASK_ID.test(id) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
38
+ const cleaned = {};
39
+ for (const field of ['acknowledgedAt', 'hiddenAt', 'updatedAt']) {
40
+ if (typeof entry[field] === 'string' && entry[field]) cleaned[field] = entry[field];
41
+ }
42
+ if (Object.keys(cleaned).length) entries[id] = cleaned;
43
+ }
44
+ return { version: Number(parsed.version) || CORE_ATTENTION_VERSION, entries };
45
+ } catch (_) {
46
+ return { version: CORE_ATTENTION_VERSION, entries: {} };
47
+ }
48
+ }
49
+
50
+ /** 单条记录的处置态;hidden 优先(视觉上先消失)。 */
51
+ export function attentionDispositionOf(entry) {
52
+ if (!entry || typeof entry !== 'object') return null;
53
+ if (entry.hiddenAt) return 'hidden';
54
+ if (entry.acknowledgedAt) return 'acknowledged';
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * writer:应用一个处置动作并原子落盘。幂等——同一动作重复执行内容不变时不写盘。
60
+ * 返回 { changed, entry }。
61
+ */
62
+ export function applyCoreAttentionAction(taskId, action, { now = new Date().toISOString() } = {}) {
63
+ const id = String(taskId || '').trim();
64
+ if (!TASK_ID.test(id)) {
65
+ const error = new Error('Core Task ID 无效');
66
+ error.code = 'IRIS_DSH_TASK_INVALID';
67
+ throw error;
68
+ }
69
+ if (!['acknowledge', 'restore', 'hide', 'unhide'].includes(action)) {
70
+ const error = new Error('未知 Core 注意力处置动作:' + String(action));
71
+ error.code = 'IRIS_DSH_ATTENTION_INVALID';
72
+ throw error;
73
+ }
74
+ const prefs = readCoreAttentionPrefs();
75
+ const previous = prefs.entries[id] || null;
76
+ const entry = previous ? { ...previous } : {};
77
+ // 幂等:目标状态已达即无操作(不刷时间戳、不写盘)。
78
+ const noop = (action === 'acknowledge' && Boolean(entry.acknowledgedAt))
79
+ || (action === 'restore' && !entry.acknowledgedAt)
80
+ || (action === 'hide' && Boolean(entry.hiddenAt))
81
+ || (action === 'unhide' && !entry.hiddenAt);
82
+ if (!noop) {
83
+ if (action === 'acknowledge') entry.acknowledgedAt = now;
84
+ if (action === 'restore') delete entry.acknowledgedAt;
85
+ if (action === 'hide') entry.hiddenAt = now;
86
+ if (action === 'unhide') delete entry.hiddenAt;
87
+ // updatedAt 只随有效处置留存;两个戳都没有的条目视为空并整体移除。
88
+ if (entry.acknowledgedAt || entry.hiddenAt) entry.updatedAt = now;
89
+ }
90
+ const meaningful = Boolean(entry.acknowledgedAt || entry.hiddenAt);
91
+ const afterDisposition = attentionDispositionOf(entry);
92
+ const changed = !noop;
93
+ if (changed) {
94
+ const entries = { ...prefs.entries };
95
+ if (meaningful) entries[id] = entry;
96
+ else delete entries[id];
97
+ atomicWritePrivate(attentionFile(), JSON.stringify({
98
+ version: CORE_ATTENTION_VERSION,
99
+ entries
100
+ }, null, 2) + '\n');
101
+ }
102
+ return Object.freeze({ changed, entry: Object.freeze(entry), disposition: afterDisposition });
103
+ }
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ import path from 'node:path';
4
+
5
+ /** Iris Core Runtime 的内部候选契约;尚未作为 package export 发布。 */
6
+ export const CORE_CONTRACT_VERSION = 0;
7
+
8
+ export const CORE_ACCESS_MODES = Object.freeze(['reader', 'writer']);
9
+ export const CORE_LIFECYCLE_STATES = Object.freeze(['created', 'started', 'disposing', 'disposed']);
10
+ export const CORE_OPERATIONS = Object.freeze(['inspect', 'execute', 'recover']);
11
+
12
+ const MODE_SET = new Set(CORE_ACCESS_MODES);
13
+ const STATE_SET = new Set(CORE_LIFECYCLE_STATES);
14
+ const OPERATION_SET = new Set(CORE_OPERATIONS);
15
+
16
+ export class CoreContractError extends Error {
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.name = 'CoreContractError';
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ function fail(code, message) {
25
+ throw new CoreContractError(code, message);
26
+ }
27
+
28
+ function plainObject(value) {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ }
31
+
32
+ /**
33
+ * Core 不推断 DSH_HOME、HOME 或 cwd。默认数据根只能由具体 Host/CLI 边界选择,
34
+ * 再以绝对路径显式传入。物理路径(realpath)与写者租约在 Runtime 实现阶段完成。
35
+ */
36
+ export function normalizeCoreOptions(input) {
37
+ if (!plainObject(input)) fail('IRIS_CORE_OPTIONS_INVALID', 'Core Runtime 选项必须是对象');
38
+ const allowed = new Set(['dataRoot', 'mode']);
39
+ for (const key of Object.keys(input)) {
40
+ if (!allowed.has(key)) fail('IRIS_CORE_OPTIONS_INVALID', `Core Runtime 不允许选项:${key}`);
41
+ }
42
+
43
+ const dataRoot = typeof input.dataRoot === 'string' ? input.dataRoot.trim() : '';
44
+ if (!dataRoot || dataRoot.includes('\0') || !path.isAbsolute(dataRoot)) {
45
+ fail('IRIS_CORE_OPTIONS_INVALID', 'Core Runtime dataRoot 必须是显式绝对路径');
46
+ }
47
+ const mode = input.mode ?? 'writer';
48
+ if (!MODE_SET.has(mode)) fail('IRIS_CORE_OPTIONS_INVALID', `未知 Core Runtime 模式:${String(mode)}`);
49
+
50
+ return Object.freeze({
51
+ contractVersion: CORE_CONTRACT_VERSION,
52
+ dataRoot: path.normalize(dataRoot),
53
+ mode
54
+ });
55
+ }
56
+
57
+ export function createCoreLifecycle() {
58
+ return Object.freeze({ contractVersion: CORE_CONTRACT_VERSION, state: 'created' });
59
+ }
60
+
61
+ /** 纯状态转换;真正的资源获取、取消与释放由 Runtime 按文档顺序执行。 */
62
+ export function transitionCoreLifecycle(snapshot, event) {
63
+ const state = snapshot && snapshot.state;
64
+ if (!STATE_SET.has(state)) fail('IRIS_CORE_STATE_INVALID', 'Core Runtime 生命周期快照无效');
65
+
66
+ if (event === 'start' && state === 'created') {
67
+ return Object.freeze({ contractVersion: CORE_CONTRACT_VERSION, state: 'started' });
68
+ }
69
+ if (event === 'dispose' && (state === 'created' || state === 'started')) {
70
+ return Object.freeze({ contractVersion: CORE_CONTRACT_VERSION, state: 'disposing' });
71
+ }
72
+ if (event === 'dispose' && (state === 'disposing' || state === 'disposed')) return snapshot;
73
+ if (event === 'finish-dispose' && state === 'disposing') {
74
+ return Object.freeze({ contractVersion: CORE_CONTRACT_VERSION, state: 'disposed' });
75
+ }
76
+ fail('IRIS_CORE_STATE_INVALID', `Core Runtime 不能在 ${state} 状态执行 ${String(event)}`);
77
+ }
78
+
79
+ /**
80
+ * reader 只允许不产生持久化副作用的 inspect;初始化、修复、索引接回与观察任务
81
+ * 都属于写者行为。此函数只冻结权限真值表,不替代后续的数据根租约。
82
+ */
83
+ export function assertCoreOperation(options, lifecycle, operation) {
84
+ if (!options || options.contractVersion !== CORE_CONTRACT_VERSION || !MODE_SET.has(options.mode)) {
85
+ fail('IRIS_CORE_OPTIONS_INVALID', 'Core Runtime 选项未经过当前契约规范化');
86
+ }
87
+ if (!lifecycle || lifecycle.state !== 'started') {
88
+ fail('IRIS_CORE_STATE_INVALID', 'Core Runtime 仅能在 started 状态执行操作');
89
+ }
90
+ if (!OPERATION_SET.has(operation)) {
91
+ fail('IRIS_CORE_OPERATION_INVALID', `未知 Core Runtime 操作:${String(operation)}`);
92
+ }
93
+ if (options.mode === 'reader' && operation !== 'inspect') {
94
+ fail('IRIS_CORE_READ_ONLY', `只读 Core Runtime 不能执行 ${operation}`);
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /** 供未来数据根租约实现统一返回稳定错误;不得暴露本机绝对路径。 */
100
+ export function coreDataRootBusyError() {
101
+ return new CoreContractError(
102
+ 'IRIS_CORE_DATA_ROOT_BUSY',
103
+ '该 Iris 数据根已有写者;先停止现有写者并运行 doctor --data-root 检查,只有 stale PID 才可显式执行 runtime recover'
104
+ );
105
+ }
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { normalizeCoreOptions } from './core-contract.js';
6
+ import { inspectCoreWriterLease } from './core-runtime.js';
7
+ import { listCoreTasks } from './core-tasks.js';
8
+ import { inspectCoreArtifactStore } from './core-artifact-store.js';
9
+
10
+ /** 仅返回计数和状态,不返回 Task 内容、文件名或数据根路径。 */
11
+ export function inspectCoreStorage(dataRoot) {
12
+ const options = normalizeCoreOptions({ dataRoot, mode: 'reader' });
13
+ let root;
14
+ try {
15
+ root = fs.realpathSync.native(options.dataRoot);
16
+ if (!fs.statSync(root).isDirectory()) return { status: 'invalid' };
17
+ } catch (error) { return { status: error?.code === 'ENOENT' ? 'absent' : 'invalid' }; }
18
+ const report = { status: 'present', lease: inspectCoreWriterLease(root) };
19
+ try {
20
+ const tasks = listCoreTasks(root, { limit: 1, skipInvalid: true });
21
+ let taskStatus = 'present';
22
+ let unresolved = 0;
23
+ for (const relative of ['task-store', 'task-store/v0', 'task-store/v0/tasks']) {
24
+ try { fs.lstatSync(path.join(root, relative)); }
25
+ catch (error) {
26
+ if (error?.code !== 'ENOENT') throw error;
27
+ // Entire store absent is valid for a new root; a partial tree needs diagnosis.
28
+ taskStatus = relative === 'task-store' ? 'absent' : 'invalid';
29
+ break;
30
+ }
31
+ }
32
+ if (taskStatus === 'present') {
33
+ unresolved = fs.readdirSync(path.join(root, 'task-store/v0/tasks'))
34
+ .filter((name) => !/^task_[a-f0-9]{24}\.json$/.test(name)).length;
35
+ }
36
+ report.tasks = { status: taskStatus, total: tasks.total, invalid: tasks.dropped, unresolved };
37
+ } catch (_) { report.tasks = { status: 'invalid' }; }
38
+ try { report.artifacts = inspectCoreArtifactStore(root); }
39
+ catch (_) { report.artifacts = { status: 'invalid' }; }
40
+ return report;
41
+ }
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { CORE_CONTRACT_VERSION, CoreContractError, normalizeCoreOptions } from './core-contract.js';
7
+ import { writePrivateFile } from './private-storage.js';
8
+
9
+ const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
10
+ const WRITER_OWNER_FILE = 'owner.json';
11
+ const RECOVERY_AUDIT_DIR = '.iris-runtime-recovery-audit-v0';
12
+
13
+ function fail(code, message) {
14
+ throw new CoreContractError(code, message);
15
+ }
16
+
17
+ function physicalDataRoot(dataRoot) {
18
+ const options = normalizeCoreOptions({ dataRoot, mode: 'reader' });
19
+ try {
20
+ const root = fs.realpathSync.native(options.dataRoot);
21
+ if (!fs.statSync(root).isDirectory()) fail('IRIS_CORE_DATA_ROOT_UNAVAILABLE', 'Iris 数据根必须是目录');
22
+ return root;
23
+ } catch (error) {
24
+ if (error instanceof CoreContractError) throw error;
25
+ if (error?.code === 'ENOENT') fail('IRIS_CORE_DATA_ROOT_NOT_FOUND', 'Iris 数据根不存在;恢复不会自动创建');
26
+ fail('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法访问 Iris 数据根');
27
+ }
28
+ }
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
+ function auditDirectory(root) {
59
+ const directory = path.join(root, RECOVERY_AUDIT_DIR);
60
+ try {
61
+ const stat = fs.lstatSync(directory);
62
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid audit directory');
63
+ } catch (error) {
64
+ if (error?.code !== 'ENOENT') fail('IRIS_CORE_LEASE_RECOVERY_FAILED', '租约恢复审计目录不安全;已保留现场');
65
+ try { fs.mkdirSync(directory, { mode: 0o700 }); }
66
+ catch (_) { fail('IRIS_CORE_LEASE_RECOVERY_FAILED', '无法创建租约恢复审计目录;已保留现场'); }
67
+ }
68
+ return directory;
69
+ }
70
+
71
+ function releaseRecoveryLease(lease) {
72
+ const stat = fs.lstatSync(lease.directory);
73
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid recovery lease');
74
+ const entries = fs.readdirSync(lease.directory);
75
+ if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('unexpected recovery lease contents');
76
+ const actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
77
+ if (actual.instanceId !== lease.owner.instanceId) throw new Error('recovery owner changed');
78
+ fs.unlinkSync(lease.ownerFile);
79
+ fs.rmdirSync(lease.directory);
80
+ }
81
+
82
+ /**
83
+ * 显式接管并释放一个可证明 owner PID 已不存在的 writer 租约。
84
+ * 调用方必须回显 Doctor 报告中的 PID;活跃、未知或损坏证据一律拒绝。
85
+ */
86
+ export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
87
+ 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
+ let lease;
95
+ try { lease = readLease(root); }
96
+ catch (_) { fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据不完整;已保留现场'); }
97
+ if (!Number.isSafeInteger(confirmStalePid) || confirmStalePid <= 0 || confirmStalePid !== lease.owner.pid) {
98
+ fail('IRIS_CORE_LEASE_CONFIRMATION_MISMATCH', '确认的 owner PID 与租约证据不一致;未执行恢复');
99
+ }
100
+ const processStatus = ownerStatus(lease.owner.pid);
101
+ if (processStatus === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
102
+ if (processStatus !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
103
+
104
+ const auditId = 'lease_recovery_' + crypto.randomBytes(12).toString('hex');
105
+ const instanceId = crypto.randomUUID();
106
+ const recoveredAt = new Date().toISOString();
107
+ const recoveryOwner = Object.freeze({
108
+ contractVersion: CORE_CONTRACT_VERSION,
109
+ instanceId,
110
+ pid: process.pid,
111
+ createdAt: recoveredAt
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;
116
+ let auditFile = null;
117
+ try {
118
+ // 租约目录始终存在;普通 writer 在接管窗口内仍会 fail-fast。
119
+ fs.renameSync(lease.ownerFile, staleOwnerFile);
120
+ if (fs.readFileSync(staleOwnerFile, 'utf8') !== lease.serialized) throw new Error('lease changed');
121
+ writePrivateFile(lease.ownerFile, recoverySerialized, { flag: 'wx' });
122
+ tookLease = true;
123
+
124
+ const directory = auditDirectory(root);
125
+ auditFile = path.join(directory, auditId + '.json');
126
+ const audit = {
127
+ schemaVersion: 1,
128
+ id: auditId,
129
+ action: 'writer-lease-recovery',
130
+ recoveredAt,
131
+ confirmation: { ownerPid: confirmStalePid },
132
+ staleOwner: {
133
+ contractVersion: lease.owner.contractVersion,
134
+ instanceId: lease.owner.instanceId,
135
+ pid: lease.owner.pid,
136
+ createdAt: new Date(lease.created).toISOString()
137
+ },
138
+ recoveryOwner
139
+ };
140
+ writePrivateFile(auditFile, JSON.stringify(audit, null, 2) + '\n', { flag: 'wx' });
141
+ fs.unlinkSync(staleOwnerFile);
142
+ releaseRecoveryLease({
143
+ directory: lease.directory,
144
+ ownerFile: lease.ownerFile,
145
+ owner: recoveryOwner
146
+ });
147
+ return Object.freeze({
148
+ status: 'recovered',
149
+ auditId,
150
+ staleOwner: Object.freeze({ pid: lease.owner.pid, createdAt: new Date(lease.created).toISOString() })
151
+ });
152
+ } catch (error) {
153
+ // 恢复失败时尽力还原原 owner;绝不把不确定现场宣布为可写。
154
+ try {
155
+ if (tookLease && fs.existsSync(lease.ownerFile)) {
156
+ const actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
157
+ if (actual.instanceId === instanceId) fs.unlinkSync(lease.ownerFile);
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 */ }
164
+ if (error instanceof CoreContractError) throw error;
165
+ fail('IRIS_CORE_LEASE_RECOVERY_FAILED',
166
+ auditFile && fs.existsSync(auditFile)
167
+ ? '租约恢复未能安全完成;审计证据与现场均已保留'
168
+ : '租约恢复失败;原租约已尽力保留');
169
+ }
170
+ }
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ CORE_CONTRACT_VERSION,
8
+ CoreContractError,
9
+ assertCoreOperation,
10
+ coreDataRootBusyError,
11
+ createCoreLifecycle,
12
+ normalizeCoreOptions,
13
+ transitionCoreLifecycle
14
+ } from './core-contract.js';
15
+ import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
16
+
17
+ const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
18
+ const WRITER_OWNER_FILE = 'owner.json';
19
+
20
+ /** 只读租约证据;PID 缺失不等于已经获准恢复,绝不自动夺取租约。 */
21
+ export function inspectCoreWriterLease(dataRoot) {
22
+ 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
+ try {
28
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid lease');
29
+ const entries = fs.readdirSync(directory);
30
+ if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('invalid contents');
31
+ const ownerFile = path.join(directory, WRITER_OWNER_FILE);
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'; }
43
+ return {
44
+ status: 'present', ownerStatus, pid: owner.pid,
45
+ createdAt: new Date(created).toISOString(), ageMs: Math.max(0, Date.now() - created)
46
+ };
47
+ } catch (_) { return { status: 'invalid' }; }
48
+ }
49
+
50
+ function coreError(code, message) {
51
+ return new CoreContractError(code, message);
52
+ }
53
+
54
+ function physicalDataRoot(options) {
55
+ if (options.mode === 'writer') {
56
+ try { ensurePrivateDir(options.dataRoot); }
57
+ catch (_) { throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法创建或访问 Iris 数据根'); }
58
+ }
59
+ let root;
60
+ try { root = fs.realpathSync.native(options.dataRoot); }
61
+ catch (error) {
62
+ if (error && error.code === 'ENOENT') {
63
+ throw coreError('IRIS_CORE_DATA_ROOT_NOT_FOUND', 'Iris 数据根不存在;只读模式不会自动创建');
64
+ }
65
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法访问 Iris 数据根');
66
+ }
67
+ try {
68
+ if (!fs.statSync(root).isDirectory()) {
69
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', 'Iris 数据根必须是目录');
70
+ }
71
+ } catch (error) {
72
+ if (error instanceof CoreContractError) throw error;
73
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法检查 Iris 数据根');
74
+ }
75
+ return root;
76
+ }
77
+
78
+ function acquireWriterLease(root, instanceId) {
79
+ const directory = path.join(root, WRITER_LEASE_DIR);
80
+ const ownerFile = path.join(directory, WRITER_OWNER_FILE);
81
+ try {
82
+ fs.mkdirSync(directory, { mode: 0o700 });
83
+ } catch (error) {
84
+ if (error && error.code === 'EEXIST') throw coreDataRootBusyError();
85
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法取得 Iris 数据根写者租约');
86
+ }
87
+
88
+ const owner = Object.freeze({
89
+ contractVersion: CORE_CONTRACT_VERSION,
90
+ instanceId,
91
+ pid: process.pid,
92
+ createdAt: new Date().toISOString()
93
+ });
94
+ const serialized = JSON.stringify(owner, null, 2) + '\n';
95
+ try {
96
+ writePrivateFile(ownerFile, serialized, { flag: 'wx' });
97
+ } catch (_) {
98
+ try { fs.unlinkSync(ownerFile); } catch (_) { /* owner 文件可能尚未建立 */ }
99
+ try { fs.rmdirSync(directory); } catch (_) { /* 保留不确定租约比误删安全 */ }
100
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法记录 Iris 数据根写者租约');
101
+ }
102
+ return Object.freeze({ directory, ownerFile, owner, serialized });
103
+ }
104
+
105
+ function releaseWriterLease(lease) {
106
+ let actual;
107
+ try {
108
+ const stat = fs.lstatSync(lease.directory);
109
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('lease is not a directory');
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'));
113
+ } catch (_) {
114
+ throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约无法安全释放;已保留现场');
115
+ }
116
+ if (actual.instanceId !== lease.owner.instanceId) {
117
+ throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约所有者不匹配;已保留现场');
118
+ }
119
+
120
+ try {
121
+ fs.unlinkSync(lease.ownerFile);
122
+ fs.rmdirSync(lease.directory);
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
+ throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约释放失败;已保留现场');
129
+ }
130
+ }
131
+
132
+ function runtimeSnapshot(options, lifecycle, hasLease, inFlight) {
133
+ return Object.freeze({
134
+ contractVersion: CORE_CONTRACT_VERSION,
135
+ state: lifecycle.state,
136
+ mode: options.mode,
137
+ writerLease: Boolean(hasLease),
138
+ inFlight
139
+ });
140
+ }
141
+
142
+ /**
143
+ * 创建一个最小 Core Runtime 实例。它尚未迁移 0.1.4 的模块级存储;当前职责只有
144
+ * 显式数据根、单写者租约、操作权限、AbortSignal 与可验证释放顺序。
145
+ */
146
+ export function createCoreRuntime(input) {
147
+ const options = normalizeCoreOptions(input);
148
+ const instanceId = crypto.randomUUID();
149
+ const abortController = new AbortController();
150
+ const inFlight = new Set();
151
+ const cleanup = [];
152
+ let lifecycle = createCoreLifecycle();
153
+ let root = null;
154
+ let lease = null;
155
+ let disposePromise = null;
156
+
157
+ function snapshot() {
158
+ return runtimeSnapshot(options, lifecycle, lease, inFlight.size);
159
+ }
160
+
161
+ function start() {
162
+ if (lifecycle.state !== 'created') {
163
+ return transitionCoreLifecycle(lifecycle, 'start');
164
+ }
165
+ const candidateRoot = physicalDataRoot(options);
166
+ const candidateLease = options.mode === 'writer' ? acquireWriterLease(candidateRoot, instanceId) : null;
167
+ root = candidateRoot;
168
+ lease = candidateLease;
169
+ lifecycle = transitionCoreLifecycle(lifecycle, 'start');
170
+ return snapshot();
171
+ }
172
+
173
+ function onDispose(callback) {
174
+ if (typeof callback !== 'function') throw new TypeError('Core Runtime cleanup 必须是函数');
175
+ if (lifecycle.state === 'disposing' || lifecycle.state === 'disposed') {
176
+ throw coreError('IRIS_CORE_STATE_INVALID', 'Core Runtime 已开始释放,不能注册 cleanup');
177
+ }
178
+ cleanup.push(callback);
179
+ let active = true;
180
+ return () => {
181
+ if (!active) return false;
182
+ active = false;
183
+ const index = cleanup.indexOf(callback);
184
+ if (index >= 0) cleanup.splice(index, 1);
185
+ return index >= 0;
186
+ };
187
+ }
188
+
189
+ function run(operation, callback) {
190
+ if (typeof callback !== 'function') throw new TypeError('Core Runtime 操作必须是函数');
191
+ assertCoreOperation(options, lifecycle, operation);
192
+ const context = Object.freeze({ dataRoot: root, signal: abortController.signal });
193
+ let result;
194
+ try { result = callback(context); }
195
+ catch (error) { result = Promise.reject(error); }
196
+ const promise = Promise.resolve(result);
197
+ inFlight.add(promise);
198
+ promise.then(
199
+ () => inFlight.delete(promise),
200
+ () => inFlight.delete(promise)
201
+ );
202
+ return promise;
203
+ }
204
+
205
+ function dispose() {
206
+ if (disposePromise) return disposePromise;
207
+ lifecycle = transitionCoreLifecycle(lifecycle, 'dispose');
208
+ abortController.abort();
209
+ disposePromise = (async () => {
210
+ await Promise.allSettled([...inFlight]);
211
+ for (const callback of [...cleanup].reverse()) await callback();
212
+ cleanup.length = 0;
213
+ if (lease) {
214
+ releaseWriterLease(lease);
215
+ lease = null;
216
+ }
217
+ root = null;
218
+ lifecycle = transitionCoreLifecycle(lifecycle, 'finish-dispose');
219
+ return snapshot();
220
+ })();
221
+ return disposePromise;
222
+ }
223
+
224
+ return Object.freeze({
225
+ start,
226
+ run,
227
+ onDispose,
228
+ dispose,
229
+ snapshot,
230
+ get signal() { return abortController.signal; }
231
+ });
232
+ }