mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 04:05:29 +02:00
fix: carry verified storage capability and deterministic Canvas checks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json
|
||||
- **失敗パターン** — 水平バーチャート付きのクラスタ化された失敗理由
|
||||
- **保留中の修正案** — レビュー待ちの修正提案
|
||||
- **バージョン履歴** — スキルごとのバージョンスナップショットのタイムライン
|
||||
|
||||
## スナップショットの保存先
|
||||
|
||||
スナップショットの作成とロールバックには、ハードリンクを作成できる書き込み可能な保存先が必要です。バージョン管理 API は履歴を作成または変更する前にこの機能を確認し、未対応の保存先ではコピーへのフォールバックを行わず拒否します。新しいスナップショットにはハードリンク対応の保存先を使用してください。既存のヘルス情報やバージョン履歴の読み取りでは、ハードリンクの作成は不要です。
|
||||
|
||||
@@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json
|
||||
* **故障模式** — 聚类故障原因并显示水平条形图
|
||||
* **待处理修订** — 等待审查的修订提案
|
||||
* **版本历史** — 每个技能的版本快照时间线
|
||||
|
||||
## 快照存储
|
||||
|
||||
创建快照和回滚需要支持硬链接的可写存储。版本管理 API 会在创建或修改版本历史前检查该能力;不支持时会拒绝操作,不会回退到复制文件。新快照应存放在支持硬链接的位置。读取现有健康信息和版本历史不需要创建硬链接。
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user