From bded8387ebe8cce539b198d3fce7bfbdd1a16579 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:34:15 -0400 Subject: [PATCH 1/2] fix: preflight and document snapshot storage capability --- commands/skill-health.md | 4 ++ docs/ja-JP/commands/skill-health.md | 4 ++ docs/zh-CN/commands/skill-health.md | 4 ++ scripts/lib/skill-evolution/versioning.js | 49 ++++++++++++++++++++++- tests/lib/skill-evolution-storage.test.js | 28 +++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/commands/skill-health.md b/commands/skill-health.md index 67c27c795..266a0ce6a 100644 --- a/commands/skill-health.md +++ b/commands/skill-health.md @@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json - **Failure Patterns** — Clustered failure reasons with horizontal bar chart - **Pending Amendments** — Amendment proposals awaiting review - **Version History** — Timeline of version snapshots per skill + +## Snapshot storage + +Snapshot creation and rollback require writable storage with hardlink support. The versioning API checks that capability before creating or changing version history and refuses unsupported storage without a copy fallback. Use a hardlink-capable location for new snapshots. Reading existing health and version history does not require creating hard links. diff --git a/docs/ja-JP/commands/skill-health.md b/docs/ja-JP/commands/skill-health.md index 876ac5247..566b6a1dd 100644 --- a/docs/ja-JP/commands/skill-health.md +++ b/docs/ja-JP/commands/skill-health.md @@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json - **失敗パターン** — 水平バーチャート付きのクラスタ化された失敗理由 - **保留中の修正案** — レビュー待ちの修正提案 - **バージョン履歴** — スキルごとのバージョンスナップショットのタイムライン + +## スナップショットの保存先 + +スナップショットの作成とロールバックには、ハードリンクを作成できる書き込み可能な保存先が必要です。バージョン管理 API は履歴を作成または変更する前にこの機能を確認し、未対応の保存先ではコピーへのフォールバックを行わず拒否します。新しいスナップショットにはハードリンク対応の保存先を使用してください。既存のヘルス情報やバージョン履歴の読み取りでは、ハードリンクの作成は不要です。 diff --git a/docs/zh-CN/commands/skill-health.md b/docs/zh-CN/commands/skill-health.md index 6471108aa..c7b14696a 100644 --- a/docs/zh-CN/commands/skill-health.md +++ b/docs/zh-CN/commands/skill-health.md @@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json * **故障模式** — 聚类故障原因并显示水平条形图 * **待处理修订** — 等待审查的修订提案 * **版本历史** — 每个技能的版本快照时间线 + +## 快照存储 + +创建快照和回滚需要支持硬链接的可写存储。版本管理 API 会在创建或修改版本历史前检查该能力;不支持时会拒绝操作,不会回退到复制文件。新快照应存放在支持硬链接的位置。读取现有健康信息和版本历史不需要创建硬链接。 diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 05205c12d..349b49b1b 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -231,8 +231,8 @@ function removeOwnedStaging(staging, contentIdentity) { fs.rmdirSync(staging.path); } -function prepareStaging(versionsDir) { - const stagingDir = fs.mkdtempSync(path.join(versionsDir, '.snapshot-')); +function prepareStaging(versionsDir, prefix = '.snapshot-') { + const stagingDir = fs.mkdtempSync(path.join(versionsDir, prefix)); try { return { path: stagingDir, contentPath: path.join(stagingDir, 'content.md'), identity: fs.lstatSync(stagingDir) }; } catch (primaryError) { @@ -244,10 +244,54 @@ function prepareStaging(versionsDir) { } } +function verifySnapshotStorage(skillPath) { + const versionsDir = getVersionsDir(skillPath); + const base = fs.existsSync(versionsDir) ? versionsDir : normalizeSkillDir(skillPath); + const staging = prepareStaging(base, '.snapshot-storage-'); + const linkPath = path.join(staging.path, 'hardlink-check.md'); + let identity; + let linked = false; + let linking = false; + try { + const descriptor = fs.openSync(staging.contentPath, 'wx'); + try { + identity = fs.fstatSync(descriptor); + fs.writeFileSync(descriptor, 'ECC snapshot storage check\n', 'utf8'); + } finally { + fs.closeSync(descriptor); + } + linking = true; + fs.linkSync(staging.contentPath, linkPath); + linked = true; + linking = false; + removeOwnedSnapshot({ path: linkPath, identity }); + linked = false; + removeOwnedStaging(staging, identity); + } catch (primaryError) { + try { + if (linked) removeOwnedSnapshot({ path: linkPath, identity }); + removeOwnedStaging(staging, identity); + } catch (recoveryError) { + const error = new Error(`${primaryError.message}; storage-check cleanup failed: ${recoveryError.message}; recovery is pending`, { cause: primaryError }); + error.primaryError = primaryError; + error.recoveryError = recoveryError; + throw error; + } + if (linking && ['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EPERM', 'EXDEV', 'EINVAL'].includes(primaryError.code)) { + const error = new Error(`Skill versioning requires hardlink-capable storage for snapshot creation and rollback; capability check failed before history initialization (${primaryError.code})`, { cause: primaryError }); + error.code = primaryError.code; + error.primaryError = primaryError; + throw error; + } + throw primaryError; + } +} + function publishVersion(skillPath, skillContent, options) { ensureSkillExists(skillPath); const nextVersion = allocateSnapshotVersion(skillPath); const versionsDir = getVersionsDir(skillPath); + verifySnapshotStorage(skillPath); ensureSkillVersioning(skillPath); const staging = prepareStaging(versionsDir); const stagedPath = staging.contentPath; @@ -294,6 +338,7 @@ function recordSnapshot(skillPath, snapshot, options) { return { version: snapshot.version, path: snapshot.path, created_at: snapshot.created_at }; } +/** Snapshot creation requires hardlink-capable storage, checked before history initialization. */ function createVersion(skillPath, options = {}) { const skillFilePath = ensureSkillExists(skillPath); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 6b5bc09d9..9706ca2c2 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -350,6 +350,34 @@ test(`unverified staging directory identity preserves ${foreign ? 'foreign conte } +for (const rollback of [false, true]) { + test(`${rollback ? 'rollback' : 'snapshot'} rejects unsupported hardlink storage before initializing history`, fixture => { + const { skillDir, versionsDir } = fixture; + if (rollback) { + fs.mkdirSync(versionsDir); + fs.writeFileSync(path.join(versionsDir, 'v1.md'), '# Saved target\n'); + } + const linkSync = fs.linkSync; + const primary = Object.assign(new Error('Injected unsupported hard links'), { code: 'EOPNOTSUPP' }); + fs.linkSync = () => { throw primary; }; + try { + assert.throws(() => rollback ? versioning.rollbackTo(skillDir, 1) : versioning.createVersion(skillDir), error => + /requires hardlink-capable storage/.test(error.message) && error.primaryError === primary); + } finally { + fs.linkSync = linkSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); + if (rollback) { + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Saved target\n'); + } else assert.equal(fs.existsSync(versionsDir), false); + assert.equal(fs.readdirSync(skillDir).filter(name => name.startsWith('.snapshot-')).length, 0); + const snapshot = versioning.createVersion(skillDir); + assert.equal(snapshot.version, rollback ? 2 : 1); + }); +} + test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { const { skillDir } = fixture; versioning.createVersion(skillDir); From 33372e99703ee4182de72584538ce5bd655f86ad Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:45:43 -0400 Subject: [PATCH 2/2] test: make presence expiry controls deterministic --- tests/scripts/plan-canvas.test.js | 39 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 1ee9036a2..c214ee150 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -1267,20 +1267,33 @@ async function main(suite = createTestRunner()) { const bound = await staleCanvas.listen(0); const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } })); - await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); - await new Promise(resolve => setTimeout(resolve, 60)); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'waiting'); + // Keep real HTTP transitions, but advance their activity clock explicitly: + // loaded runners can exceed the expiry between two HTTP round trips. + const originalNow = Date.now; + let now = originalNow(); + Date.now = () => now; + try { + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); + now += 19; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); + now += 1; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'waiting'); - // An abandoned agent decays to queued so the human is never told a - // stalled session is still being worked on. - await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); - await request(bound.port, 'POST', `/api/session/${opened.key}/feedback`, { - body: { items: [{ kind: 'chat', text: 'still there?' }] } - }); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); - await new Promise(resolve => setTimeout(resolve, 60)); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued'); + // An abandoned agent decays to queued so the human is never told a + // stalled session is still being worked on. + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); + await request(bound.port, 'POST', `/api/session/${opened.key}/feedback`, { + body: { items: [{ kind: 'chat', text: 'still there?' }] } + }); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); + now += 39; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); + now += 1; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued'); + } finally { + Date.now = originalNow; + } await closeStaleCanvas(); });