merge: reconcile Polish locale with current main

This commit is contained in:
Dante
2026-09-22 12:23:52 +08:00
170 changed files with 8472 additions and 1263 deletions
-3
View File
@@ -570,7 +570,6 @@ function verifyPluginAtScope(options) {
function ensurePluginAtScope(options) {
const run = options.run || runClaude;
const configuredHooks = options.hookConfiguration || hookOptions(options.hooks);
if (options.installed) {
run(
['plugin', 'update', CURRENT_PLUGIN_ID, '--scope', options.scope],
@@ -582,8 +581,6 @@ function ensurePluginAtScope(options) {
[
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', options.scope,
'--config', `hooks_enabled=${configuredHooks.hooks_enabled}`,
'--config', `hook_profile=${configuredHooks.hook_profile}`,
],
{ cwd: options.projectRoot, phase: 'plugin-install' }
);
+9 -2
View File
@@ -131,8 +131,15 @@ function removeOpenedRegularFile(filePath, opened) {
function atomicWriteJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
try {
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
} catch (error) {
// A failed write/rename must not leave a .tmp-<pid>-<timestamp> file
// beside the canonical state file; repeated failures would accumulate them.
fs.rmSync(tempPath, { force: true });
throw error;
}
}
function readState(statePath) {
@@ -26,8 +26,8 @@ function renderControlPlaneViewHtml() {
header nav { margin-left: auto; font-size: 12px; }
header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; }
header nav a:hover { color: #e6edf3; }
#wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); }
#stage { position: relative; border-right: 1px solid #1f2630; }
#wrap { display: grid; grid-template-columns: 1fr 360px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); }
#stage { position: relative; height: 100%; min-height: 0; border-right: 1px solid #1f2630; }
canvas { width: 100%; height: 100%; display: block; }
#side { padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; }
+64 -10
View File
@@ -27,11 +27,12 @@ function renderProximityVizHtml() {
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
header h1 { font-size: 15px; margin: 0; }
header .sub { color: #8b949e; font-size: 12px; }
#wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); }
#stage { position: relative; }
#wrap { display: grid; grid-template-columns: 1fr 320px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); }
#stage { position: relative; height: 100%; min-height: 0; }
canvas { width: 100%; height: 100%; display: block; }
#side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; }
#side h2:not(:first-child) { margin-top: 16px; }
.adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
.adv.resolution { border-color: #b3402f; }
.adv.advisory { border-color: #9a6700; }
@@ -41,8 +42,11 @@ function renderProximityVizHtml() {
.adv .who { color: #c9d1d9; }
.adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; }
.empty { color: #6e7681; }
.agent-row { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12px; }
.agent-row .who { color: #c9d1d9; overflow-wrap: anywhere; }
.agent-row .risk { margin-left: auto; color: #8b949e; white-space: nowrap; }
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
</style>
</head>
<body>
@@ -53,16 +57,18 @@ function renderProximityVizHtml() {
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<canvas id="c" role="img" aria-label="Agent airspace visualization. See the Agents panel for a text alternative.">Agent airspace visualization; see the Agents panel for per-agent risk.</canvas>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution (steer)</div>
<div><span class="shape" style="color:#3fb950">●</span>clear</div>
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
<div><span class="shape" style="color:#ff7b72">▲</span>resolution (steer)</div>
</div>
</div>
<div id="side">
<h2>Advisories</h2>
<div id="advisories"><div class="empty">No advisories - airspace clear.</div></div>
<h2>Agents</h2>
<div id="agents"><div class="empty">No agents.</div></div>
</div>
</div>
<script>
@@ -81,12 +87,33 @@ function renderProximityVizHtml() {
}
window.addEventListener('resize', resize);
function riskLevel(risk) {
if (risk >= 0.7) return 'resolution';
if (risk >= 0.35) return 'advisory';
return 'clear';
}
function riskColor(risk) {
if (risk >= 0.7) return '#ff7b72';
if (risk >= 0.35) return '#e3b341';
return '#3fb950';
}
function drawRiskMarker(x, y, radius, level) {
ctx.beginPath();
if (level === 'resolution') {
ctx.moveTo(x, y - radius);
ctx.lineTo(x + radius, y + radius);
ctx.lineTo(x - radius, y + radius);
ctx.closePath();
} else if (level === 'advisory') {
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
} else {
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
ctx.fill();
}
// 3D to 2D: rotate around Y, simple perspective.
function project(p, w, h) {
var x = p[0], y = p[1] || 0, z = p[2] || 0;
@@ -122,8 +149,9 @@ function renderProximityVizHtml() {
var ag = state.positions[k];
var p = pos[ag.agentId];
var radius = (6 + Math.sqrt(ag.fileCount || 1) * 3) * p[2];
ctx.fillStyle = riskColor(state.riskByAgent[ag.agentId] || 0);
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
var agentRisk = state.riskByAgent[ag.agentId] || 0;
ctx.fillStyle = riskColor(agentRisk);
drawRiskMarker(p[0], p[1], radius, riskLevel(agentRisk));
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(ag.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
@@ -155,6 +183,25 @@ function renderProximityVizHtml() {
});
}
function renderAgents() {
var box = document.getElementById('agents');
box.textContent = '';
if (!state.positions.length) {
var empty = document.createElement('div'); empty.className = 'empty';
empty.textContent = 'No agents.'; box.appendChild(empty); return;
}
state.positions.forEach(function (agent) {
var risk = state.riskByAgent[agent.agentId] || 0;
var row = document.createElement('div'); row.className = 'agent-row';
var who = document.createElement('span'); who.className = 'who';
who.textContent = String(agent.agentId); row.appendChild(who);
var riskText = document.createElement('span'); riskText.className = 'risk';
riskText.textContent = Math.round(risk * 100) + '% - ' + riskLevel(risk);
row.appendChild(riskText);
box.appendChild(row);
});
}
function applySnapshot(prox) {
state.positions = prox.positions || [];
state.links = prox.links || [];
@@ -166,13 +213,20 @@ function renderProximityVizHtml() {
});
state.riskByAgent = risk;
renderAdvisories();
renderAgents();
var c = prox.counts || {};
canvas.setAttribute('aria-label',
(c.agents || state.positions.length) + ' agents in airspace, ' +
(c.advisories || state.advisories.length) + ' advisories. See the Agents panel for per-agent risk.');
document.getElementById('status').textContent =
(c.agents || 0) + ' agents - ' + (c.advisories || 0) + ' advisories - ' + (c.resolutions || 0) + ' steering';
}
function poll() {
fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) {
fetch('/api/proximity').then(function (r) {
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
}).then(function (data) {
applySnapshot(data && data.enabled ? data : (data || {}));
}).catch(function () {
document.getElementById('status').textContent = 'offline';
+24 -3
View File
@@ -9,6 +9,8 @@ const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createInstallPlanFromRequest } = require('./install/runtime');
const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard');
const { isCodexUserConfig } = require('./install/codex-user-config');
const { getRecordedHookConsent } = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
@@ -1871,7 +1873,7 @@ function assertValidInstallStateForWrite(state, label) {
throw new Error(`Invalid install-state (${label}): ${details}`);
}
function writeRefreshedInstallState(record, statePreview) {
function writeRefreshedInstallState(record, statePreview, writtenPaths = []) {
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
const stateWithCurrentDigests = {
...trustedStatePreview,
@@ -1879,6 +1881,19 @@ function writeRefreshedInstallState(record, statePreview) {
if (!operation.destinationPath) {
return { ...operation };
}
// Refreshing a ledger is not a file write. Keep the last installed digest
// for untouched shared configs so a concurrent user edit is never claimed.
if (isCodexUserConfig(record, operation)
&& !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) {
const previousOperation = (record.state.operations || []).find(previous => (
previous.destinationPath
&& path.relative(previous.destinationPath, operation.destinationPath) === ''
));
const { contentSha256: _plannedDigest, ...operationWithoutDigest } = operation;
return previousOperation && previousOperation.contentSha256
? { ...operationWithoutDigest, contentSha256: previousOperation.contentSha256 }
: operationWithoutDigest;
}
try {
const contentSha256 = crypto.createHash('sha256')
.update(readFileNoFollow(operation.destinationPath))
@@ -1908,7 +1923,10 @@ function prepareRepairMigration(plan, record) {
installStatePath: record.installStatePath,
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
};
const migration = prepareClaudeSkillMigration(trustedPlan);
const skillMigration = prepareClaudeSkillMigration(trustedPlan);
const migration = record.adapter.id === 'codex-home'
? prepareUserOwnedFileGuard(trustedPlan, skillMigration)
: skillMigration;
return {
migration,
plan: {
@@ -2157,6 +2175,9 @@ function repairInstalledStates(options = {}) {
}
for (const operation of repairOperations) {
if (record.adapter.id === 'codex-home') {
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
}
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
@@ -2192,7 +2213,7 @@ function repairInstalledStates(options = {}) {
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
writeRefreshedInstallState(record, statePreviewToWrite);
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
return {
adapter: record.adapter,
+3 -1
View File
@@ -1,6 +1,7 @@
const path = require('path');
const {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
createInstallTargetAdapter,
createRemappedOperation,
isForeignPlatformPath,
@@ -52,6 +53,7 @@ module.exports = createInstallTargetAdapter({
kind: 'home',
rootSegments: ['.claude'],
installStatePathSegments: ['ecc', 'install-state.json'],
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
nativeRootRelativePath: '.claude-plugin',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
@@ -66,7 +68,7 @@ module.exports = createInstallTargetAdapter({
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, adapter.target))
.filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p))
.flatMap(sourceRelativePath => {
if (
module.id === 'hooks-runtime'
+17 -1
View File
@@ -9,6 +9,7 @@ const {
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
const CLAUDE_PROJECT_COMMONJS_PACKAGE = 'manifests/install-assets/claude-project-scripts-package.json';
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
@@ -65,7 +66,7 @@ module.exports = createInstallTargetAdapter({
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
const operations = paths
.filter(p => !isForeignPlatformPath(p, 'claude'))
.flatMap(sourceRelativePath => {
if (
@@ -93,6 +94,21 @@ module.exports = createInstallTargetAdapter({
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
if (module.id !== 'hooks-runtime') {
return operations;
}
return [
...['hooks', 'lib'].map(directory => createRemappedOperation(
adapter,
module.id,
CLAUDE_PROJECT_COMMONJS_PACKAGE,
path.join(adapter.resolveRoot(planningInput), 'scripts', directory, 'package.json'),
{ strategy: 'preserve-relative-path' }
)),
...operations,
];
});
},
});
+2 -1
View File
@@ -1,4 +1,4 @@
const { createInstallTargetAdapter } = require('./helpers');
const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'codex-home',
@@ -7,4 +7,5 @@ module.exports = createInstallTargetAdapter({
rootSegments: ['.codex'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.codex',
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
});
+23 -2
View File
@@ -24,6 +24,14 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.adal': 'adal',
});
// Source paths that home installs must never copy into a harness home
// directory. `.agents` is ECC's repo-local skills/plugins staging area:
// project targets such as kimi and antigravity consume it, but neither
// Claude Code nor Codex reads a `.agents` directory under ~/.claude or
// ~/.codex, so copying it there produces unread files that doctor flags as
// drift and repair keeps restoring.
const HOME_INSTALL_EXCLUDED_SOURCE_PATHS = Object.freeze(['.agents']);
function normalizeRelativePath(relativePath) {
return String(relativePath || '')
.replace(/\\/g, '/')
@@ -43,6 +51,14 @@ function isForeignPlatformPath(sourceRelativePath, adapterTarget) {
return false;
}
function isExcludedSourcePath(sourceRelativePath, excludedSourcePaths = []) {
const normalizedPath = normalizeRelativePath(sourceRelativePath);
return excludedSourcePaths.some(excluded => {
const prefix = normalizeRelativePath(excluded);
return prefix !== '' && (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
});
}
function resolveBaseRoot(scope, input = {}) {
if (scope === 'home') {
return input.homeDir || os.homedir();
@@ -351,6 +367,9 @@ function createInstallTargetAdapter(config) {
strategy: adapter.determineStrategy(normalizedSourcePath),
});
},
excludesSourcePath(sourceRelativePath) {
return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths);
},
planOperations(input = {}) {
if (typeof config.planOperations === 'function') {
return config.planOperations(input, adapter);
@@ -360,7 +379,7 @@ function createInstallTargetAdapter(config) {
return input.modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, config.target))
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
@@ -372,7 +391,7 @@ function createInstallTargetAdapter(config) {
const module = input.module || {};
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, config.target))
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
@@ -399,6 +418,8 @@ function createInstallTargetAdapter(config) {
}
module.exports = {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
isExcludedSourcePath,
buildValidationIssue,
createFlatFileOperations,
createFlatRuleOperations,
+24 -3
View File
@@ -34,6 +34,10 @@ const {
preserveUnwrittenFiles,
} = require('./ownership-guard');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
} = require('./excluded-paths-reconciliation');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareHookConsentMigration(
const migration = prepareExcludedPathsReconciliation(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
prepareHookConsentMigration(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
)
);
const appliedPlan = {
...plan,
@@ -491,7 +498,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
assertNoNewUserOwnedFile(migration, operation);
assertNoNewUserOwnedFile(migration, operation, appliedPlan);
if (
operation.kind === 'update-claude-settings'
@@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
];
}
let excludedPathsRemoved = [];
let excludedPathsWarnings = [];
try {
const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan);
excludedPathsRemoved = excludedReconciliation.removedPaths;
excludedPathsWarnings = excludedReconciliation.warnings;
} catch (error) {
excludedPathsWarnings = [
`Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`,
];
}
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
reconciledExcludedPaths: excludedPathsRemoved,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
...excludedPathsWarnings,
],
applied: true,
};
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('../path-safety');
function isCodexUserConfig(plan, operation) {
if (plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') {
return false;
}
const relativePath = path.relative(plan.targetRoot, operation.destinationPath);
const name = process.platform === 'win32' ? relativePath.toLowerCase() : relativePath;
return name === 'config.toml' || name === (process.platform === 'win32' ? 'agents.md' : 'AGENTS.md');
}
function readConfigDigest(plan, destinationPath) {
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(destinationPath, flags);
try {
const opened = fs.fstatSync(descriptor, { bigint: true });
const current = fs.lstatSync(destinationPath, { bigint: true });
if (!opened.isFile() || !current.isFile() || current.isSymbolicLink()
|| opened.ino !== current.ino || opened.dev !== current.dev) {
throw new Error(`Refusing to inspect changed Codex configuration: ${destinationPath}`);
}
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
return crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex');
} finally {
fs.closeSync(descriptor);
}
}
function hasEditedCodexUserConfig(plan, operation, previousOperation) {
if (!isCodexUserConfig(plan, operation)) {
return false;
}
let digest;
try {
digest = readConfigDigest(plan, operation.destinationPath);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // A missing scaffold can still be restored.
}
throw error;
}
// Compare with the bytes ECC actually installed, never the newest template.
// Old ledgers without a digest cannot prove that an existing file is unchanged.
const recorded = previousOperation && previousOperation.contentSha256;
return !/^[a-f0-9]{64}$/i.test(recorded || '') || digest !== recorded.toLowerCase();
}
module.exports = { hasEditedCodexUserConfig, isCodexUserConfig };
@@ -0,0 +1,230 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const { getInstallTargetAdapter } = require('../install-targets/registry');
/**
* Upgrade reconciliation for excluded source paths (issue #3116).
*
* Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude
* and Codex home targets). The exclusion stops new copy operations from being
* planned, but a home install created before the exclusion still has the
* copied files on disk and the copy operations recorded in install-state, so
* doctor keeps reporting drift and repair keeps restoring files the target
* never reads.
*
* prepareExcludedPathsReconciliation runs before the new state is written: it
* reads the previous install-state and drops the recorded managed operations
* whose source path is now excluded. completeExcludedPathsReconciliation runs
* after a successful apply: it removes the files those operations recorded,
* but only when the recorded content digest still matches, and prunes the
* emptied directories. Files the state does not own, modified files,
* symlinks, and anything outside the target root are preserved with a
* warning.
*/
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function getReconcilingAdapter(plan) {
if (!plan || typeof plan.target !== 'string') {
return null;
}
let adapter;
try {
adapter = getInstallTargetAdapter(plan.target);
} catch {
return null;
}
return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null;
}
function isRecordedExcludedManagedOperation(adapter, operation) {
return Boolean(
operation
&& operation.ownership === 'managed'
&& typeof operation.destinationPath === 'string'
&& typeof operation.sourceRelativePath === 'string'
&& adapter.excludesSourcePath(operation.sourceRelativePath)
);
}
function filterStateOperations(state, shouldDrop) {
if (!state || !Array.isArray(state.operations)) {
return state;
}
return {
...state,
operations: state.operations.filter(operation => !shouldDrop(operation)),
};
}
function prepareExcludedPathsReconciliation(plan, migration) {
const adapter = getReconcilingAdapter(plan);
if (!adapter || !fs.existsSync(plan.installStatePath)) {
return { ...migration, excludedPathCandidates: [] };
}
const previousState = readInstallState(plan.installStatePath);
const candidates = ((previousState && previousState.operations) || [])
.filter(operation => isRecordedExcludedManagedOperation(adapter, operation));
if (candidates.length === 0) {
return { ...migration, excludedPathCandidates: [] };
}
const droppedDestinations = new Set(
candidates.map(operation => comparablePath(operation.destinationPath))
);
const shouldDrop = operation => Boolean(
operation
&& typeof operation.destinationPath === 'string'
&& droppedDestinations.has(comparablePath(operation.destinationPath))
&& typeof operation.sourceRelativePath === 'string'
&& adapter.excludesSourcePath(operation.sourceRelativePath)
);
return {
...migration,
bridgeState: filterStateOperations(migration.bridgeState, shouldDrop),
finalState: filterStateOperations(migration.finalState, shouldDrop),
excludedPathCandidates: candidates,
};
}
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
&& after.dev === finalPathStat.dev
&& after.ino === finalPathStat.ino
&& after.size === finalPathStat.size;
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return crypto.createHash('sha256').update(content).digest('hex');
} finally {
fs.closeSync(descriptor);
}
}
function removeEmptyParents(startPath, targetRoot) {
let currentPath = path.dirname(startPath);
while (comparablePath(currentPath) !== comparablePath(targetRoot)) {
const safePath = assertWithinTrustedRoot(
currentPath,
targetRoot,
'reconcile excluded install paths'
);
if (!pathExists(safePath)) {
currentPath = path.dirname(safePath);
continue;
}
const stat = fs.lstatSync(safePath);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
return;
}
fs.rmdirSync(safePath);
currentPath = path.dirname(safePath);
}
}
function completeExcludedPathsReconciliation(migration, plan) {
const candidates = (migration && migration.excludedPathCandidates) || [];
const removedPaths = [];
const warnings = [];
for (const candidate of candidates) {
if (candidate.kind !== 'copy-file') {
continue;
}
let safePath;
try {
safePath = assertWithinTrustedRoot(
candidate.destinationPath,
plan.targetRoot,
'reconcile excluded install paths'
);
} catch (error) {
warnings.push(
`Preserved previously managed file ${candidate.destinationPath}: ${error.message}`
);
continue;
}
if (!pathExists(safePath)) {
continue;
}
const stat = fs.lstatSync(safePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
warnings.push(
`Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.`
);
continue;
}
if (typeof candidate.contentSha256 !== 'string') {
warnings.push(
`Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.`
);
continue;
}
let currentDigest;
try {
currentDigest = hashFileNoFollow(safePath);
} catch (error) {
warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`);
continue;
}
if (currentDigest !== candidate.contentSha256.toLowerCase()) {
warnings.push(
`Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.`
);
continue;
}
fs.unlinkSync(safePath);
removedPaths.push(safePath);
removeEmptyParents(safePath, plan.targetRoot);
}
return { removedPaths, warnings };
}
module.exports = {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
};
+24 -8
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { hasEditedCodexUserConfig } = require('./codex-user-config');
function pathExists(filePath) {
try {
@@ -60,24 +61,32 @@ function prepareUserOwnedFileGuard(plan, migration) {
);
const managedDestinations = new Set(previousManagedOperations.keys());
const appliedOperations = [];
const plannedOperations = (migration && migration.appliedOperations) || [];
const plannedDestinations = new Set(plannedOperations.map(operation => comparablePath(operation.destinationPath)));
// Selective reinstalls retain earlier modules in the ledger. Inspect those
// entries too, without turning them into additional writes in this install.
const retainedOperations = ((migration.finalState && migration.finalState.operations) || [])
.filter(operation => !plannedDestinations.has(comparablePath(operation.destinationPath))
&& previousManagedOperations.has(comparablePath(operation.destinationPath)));
const skippedOperations = [];
const warnings = [];
for (const operation of (migration && migration.appliedOperations) || []) {
for (const operation of [...plannedOperations, ...retainedOperations]) {
const previousOperation = previousManagedOperations.get(comparablePath(operation.destinationPath));
const editedConfig = previousOperation
&& hasEditedCodexUserConfig(plan, operation, previousOperation);
if (
operation
&& operation.kind === 'copy-file'
&& operation.destinationPath
&& pathExists(operation.destinationPath)
&& !managedDestinations.has(comparablePath(operation.destinationPath))
&& (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig)
) {
skippedOperations.push(operation);
warnings.push(
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
);
warnings.push(editedConfig
? `Preserved user configuration ${operation.destinationPath}: changed or unverifiable since installation. ECC no longer manages this file; apply future configuration updates manually.`
: `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`);
continue;
}
appliedOperations.push(operation);
}
if (skippedOperations.length === 0) {
@@ -87,6 +96,9 @@ function prepareUserOwnedFileGuard(plan, migration) {
const skippedDestinations = new Set(
skippedOperations.map(operation => comparablePath(operation.destinationPath))
);
const appliedOperations = plannedOperations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
));
const filterStateOperations = operations => (operations || [])
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
@@ -125,7 +137,11 @@ function prepareUserOwnedFileGuard(plan, migration) {
};
}
function assertNoNewUserOwnedFile(migration, operation) {
function assertNoNewUserOwnedFile(migration, operation, plan) {
const previousOperation = migration.previousManagedOperations.get(comparablePath(operation.destinationPath));
if (plan && hasEditedCodexUserConfig(plan, operation, previousOperation)) {
throw new Error(`Refusing to overwrite user configuration changed after planning: ${operation.destinationPath}. Rerun to preserve it.`);
}
if (operation.kind !== 'copy-file'
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|| !pathExists(operation.destinationPath)) {
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
'use strict';
/**
* Shared cross-platform browser launcher.
*
* Extracted from scripts/plan-canvas.js (which had a working but error-silent
* tri-platform branch) and scripts/control-pane.js (which had a darwin-only
* branch that silently no-op'd on Windows/Linux). This helper:
*
* 1. Dispatches `open` / `cmd /c start` / `xdg-open` based on process.platform
* 2. Wires the child's 'error' event so ENOENT / EACCES propagate to the caller
* instead of being swallowed by detached spawns
* 3. Returns a structured { opened, reason } result so CLI consumers can
* surface the truth (browser did/did not open) instead of a lying true/false
*
* The signature is intentionally small (single function, no class) so callers
* can import without picking up the rest of scripts/lib.
*
* Tests live at tests/lib/platform-launch.test.js.
*/
const { spawn } = require('child_process');
/**
* Pick the platform-appropriate opener command + args.
* Returns [cmd, args] suitable for child_process.spawn.
*
* @param {NodeJS.Platform} platform
* @param {string} url
* @returns {[string, string[]]}
*/
function openerCommandFor(platform, url) {
if (platform === 'darwin') return ['open', [url]];
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
return ['xdg-open', [url]];
}
/**
* Open a URL in the user's default browser, dispatching per-platform.
*
* Always returns a structured result so callers can:
* - show a clear error to the agent (no silent failures)
* - keep JSON CLI output truthful when browsers cannot launch
*
* @param {string} url
* @param {NodeJS.Platform} [platform] - injectable for tests; defaults to process.platform
* @returns {{ opened: boolean, reason: string }}
*/
function openBrowser(url, platform = process.platform) {
if (typeof url !== 'string' || url.length === 0) {
return { opened: false, reason: 'invalid-url' };
}
const [cmd, args] = openerCommandFor(platform, url);
let child;
try {
child = spawn(cmd, args, {
detached: true,
stdio: 'ignore',
});
} catch (err) {
return {
opened: false,
reason: `spawn-threw:${err && err.code ? err.code : 'unknown'}`,
};
}
// Listen for ENOENT/EACCES/etc that would otherwise be silently swallowed
// when the user has no `open` / `xdg-open` / `start` available.
let capturedError = null;
child.on('error', (err) => {
capturedError = err && err.code ? err.code : 'spawn-error';
});
// Best-effort: detach so we don't keep the parent alive on the launcher.
try {
child.unref();
} catch {
/* unref may throw on some platforms; safe to ignore */
}
if (capturedError) {
return { opened: false, reason: `child-error:${capturedError}` };
}
return { opened: true, reason: 'spawned' };
}
module.exports = {
openBrowser,
openerCommandFor,
};
+513
View File
@@ -0,0 +1,513 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { writeFileAtomic } = require('./atomic-write');
const { sanitizeSessionId } = require('./session-bridge');
const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1';
const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots';
const COST_LOG_FILENAME = 'costs.jsonl';
const READ_CHUNK_BYTES = 64 * 1024;
const MAX_JSONL_LINE_BYTES = 1024 * 1024;
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
const FINGERPRINT_WINDOW_BYTES = 256;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_SNAPSHOTS = 512;
const WARNING_CACHE_PREFIX = 'ecc-cost-snapshot-warnings-';
function assertSafeSessionId(sessionId) {
if (sanitizeSessionId(sessionId) !== sessionId) {
throw new Error('Cost snapshot requires a safe session ID');
}
}
function getSnapshotDirectory(metricsDir) {
return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY);
}
function getCostSnapshotPath(metricsDir, sessionId) {
assertSafeSessionId(sessionId);
return path.join(getSnapshotDirectory(metricsDir), `session-${sessionId}.json`);
}
function isValidCostRow(row, sessionId) {
return row?.session_id === sessionId
&& typeof row.estimated_cost_usd === 'number'
&& Number.isFinite(row.estimated_cost_usd)
&& row.estimated_cost_usd >= 0
&& typeof row.input_tokens === 'number'
&& Number.isFinite(row.input_tokens)
&& row.input_tokens >= 0
&& typeof row.output_tokens === 'number'
&& Number.isFinite(row.output_tokens)
&& row.output_tokens >= 0;
}
function readJsonFile(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function chooseNewerCumulativeRow(currentRow, nextRow) {
if (!currentRow) return nextRow;
const nextDominates = nextRow.input_tokens >= currentRow.input_tokens
&& nextRow.output_tokens >= currentRow.output_tokens
&& nextRow.estimated_cost_usd >= currentRow.estimated_cost_usd;
const currentDominates = currentRow.input_tokens >= nextRow.input_tokens
&& currentRow.output_tokens >= nextRow.output_tokens
&& currentRow.estimated_cost_usd >= nextRow.estimated_cost_usd;
if (nextDominates && !currentDominates) return nextRow;
if (currentDominates && !nextDominates) return currentRow;
const nextTimestamp = Date.parse(nextRow.timestamp);
const currentTimestamp = Date.parse(currentRow.timestamp);
if (Number.isFinite(nextTimestamp) && Number.isFinite(currentTimestamp)) {
return nextTimestamp >= currentTimestamp ? nextRow : currentRow;
}
return nextRow;
}
function sourceIdentity(stat) {
return `${stat.dev}:${stat.ino}`;
}
function hashWindow(descriptor, position, length) {
const buffer = Buffer.alloc(length);
if (length > 0) fs.readSync(descriptor, buffer, 0, length, position);
return crypto.createHash('sha256').update(buffer).digest('hex');
}
function fingerprintProcessedPrefix(descriptor, offset) {
const windowLength = Math.min(FINGERPRINT_WINDOW_BYTES, offset);
const middleStart = Math.max(0, Math.floor((offset - windowLength) / 2));
return {
start: hashWindow(descriptor, 0, windowLength),
middle: hashWindow(descriptor, middleStart, windowLength),
end: hashWindow(descriptor, offset - windowLength, windowLength)
};
}
function fingerprintsMatch(left, right) {
return left?.start === right?.start
&& left?.middle === right?.middle
&& left?.end === right?.end;
}
function validSnapshotBase(snapshot, descriptor, stat, sessionId) {
if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null;
if (snapshot.row !== null && !isValidCostRow(snapshot.row, sessionId)) return null;
const source = snapshot.source;
if (source?.identity !== sourceIdentity(stat)) return null;
if (!Number.isSafeInteger(source.offset_bytes) || source.offset_bytes < 0) return null;
if (source.offset_bytes > stat.size) return null;
if (source.offset_bytes === stat.size && source.mtime_ms !== stat.mtimeMs) return null;
if (!fingerprintsMatch(
source.fingerprint,
fingerprintProcessedPrefix(descriptor, source.offset_bytes)
)) return null;
return {
row: snapshot.row,
offset: source.offset_bytes,
discardingLine: source.discarding_line === true
};
}
function createScanState(initialRow) {
return {
latestRow: initialRow,
committedRow: initialRow,
malformed: 0,
invalid: 0,
malformedHasher: crypto.createHash('sha256'),
invalidHasher: crypto.createHash('sha256')
};
}
function processCostLine(state, line, sessionId, committed = true) {
if (!line.trim()) return state;
try {
const row = JSON.parse(line);
if (row.session_id !== sessionId) return state;
if (!isValidCostRow(row, sessionId)) {
if (!committed) return state;
return {
...state,
invalid: state.invalid + 1,
invalidHasher: state.invalidHasher.copy().update(line).update('\0')
};
}
return {
...state,
latestRow: chooseNewerCumulativeRow(state.latestRow, row),
committedRow: committed
? chooseNewerCumulativeRow(state.committedRow, row)
: state.committedRow
};
} catch {
if (!committed) return state;
return {
...state,
malformed: state.malformed + 1,
malformedHasher: state.malformedHasher.copy().update(line).update('\0')
};
}
}
function markOversizedLine(state, pendingChunks, segment) {
const hasher = state.malformedHasher.copy();
for (const chunk of pendingChunks) hasher.update(chunk);
const remaining = Math.max(0, MAX_JSONL_LINE_BYTES - pendingChunks.reduce(
(total, chunk) => total + chunk.length,
0
));
hasher.update(segment.subarray(0, remaining)).update('\0<oversized>');
return { ...state, malformed: state.malformed + 1, malformedHasher: hasher };
}
function consumeLineSegment(scan, segment, terminated, sessionId) {
if (scan.discardingLine) {
return { ...scan, discardingLine: !terminated };
}
if (scan.pendingBytes + segment.length > MAX_JSONL_LINE_BYTES) {
return {
state: markOversizedLine(scan.state, scan.pendingChunks, segment),
pendingChunks: [],
pendingBytes: 0,
discardingLine: !terminated
};
}
const pendingChunks = segment.length > 0
? [...scan.pendingChunks, Buffer.from(segment)]
: scan.pendingChunks;
const pendingBytes = scan.pendingBytes + segment.length;
if (!terminated) return { ...scan, pendingChunks, pendingBytes };
const line = Buffer.concat(pendingChunks, pendingBytes).toString('utf8');
return {
state: processCostLine(scan.state, line, sessionId),
pendingChunks: [],
pendingBytes: 0,
discardingLine: false
};
}
function consumeJsonlChunk(scan, chunk, sessionId, absoluteStart, processedOffset) {
let nextScan = scan;
let nextOffset = processedOffset;
let segmentStart = 0;
for (;;) {
const newlineIndex = chunk.indexOf(0x0a, segmentStart);
if (newlineIndex < 0) break;
nextScan = consumeLineSegment(
nextScan, chunk.subarray(segmentStart, newlineIndex), true, sessionId
);
nextOffset = absoluteStart + newlineIndex + 1;
segmentStart = newlineIndex + 1;
}
nextScan = consumeLineSegment(
nextScan, chunk.subarray(segmentStart), false, sessionId
);
if (nextScan.discardingLine) nextOffset = absoluteStart + chunk.length;
return { scan: nextScan, processedOffset: nextOffset };
}
function scanJsonlRange(descriptor, start, end, sessionId, initialRow, initialDiscard = false) {
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let lineScan = {
state: createScanState(initialRow),
pendingChunks: [],
pendingBytes: 0,
discardingLine: initialDiscard
};
let position = start;
let processedOffset = start;
while (position < end) {
const bytesRead = fs.readSync(
descriptor,
buffer,
0,
Math.min(buffer.length, end - position),
position
);
if (bytesRead === 0) break;
const consumed = consumeJsonlChunk(
lineScan, buffer.subarray(0, bytesRead), sessionId, position, processedOffset
);
lineScan = consumed.scan;
processedOffset = consumed.processedOffset;
position += bytesRead;
}
if (lineScan.pendingBytes > 0) {
const line = Buffer.concat(lineScan.pendingChunks, lineScan.pendingBytes).toString('utf8');
lineScan = {
...lineScan,
state: processCostLine(lineScan.state, line, sessionId, false)
};
}
const { state } = lineScan;
return {
row: state.latestRow,
committedRow: state.committedRow,
processedOffset,
malformed: state.malformed,
invalid: state.invalid,
malformedSignature: state.malformed > 0
? state.malformedHasher.digest('hex').slice(0, 16)
: null,
invalidSignature: state.invalid > 0
? state.invalidHasher.digest('hex').slice(0, 16)
: null,
discardingLine: lineScan.discardingLine
};
}
function writeSnapshotAtOffset(
metricsDir, sessionId, row, descriptor, stat, offset, discardingLine = false
) {
if (row !== null && !isValidCostRow(row, sessionId)) return false;
const snapshot = {
schema_version: COST_SNAPSHOT_SCHEMA_VERSION,
source: {
identity: sourceIdentity(stat),
offset_bytes: offset,
mtime_ms: stat.mtimeMs,
discarding_line: discardingLine,
fingerprint: fingerprintProcessedPrefix(descriptor, offset)
},
row
};
writeFileAtomic(
getCostSnapshotPath(metricsDir, sessionId),
JSON.stringify(snapshot),
{
beforeRename() {
const current = fs.fstatSync(descriptor);
if (current.size < offset) {
throw new Error('Cost log was truncated during snapshot publication');
}
if (current.size === offset && current.mtimeMs !== snapshot.source.mtime_ms) {
throw new Error('Cost log changed during snapshot publication');
}
if (!fingerprintsMatch(
fingerprintProcessedPrefix(descriptor, offset),
snapshot.source.fingerprint
)) {
throw new Error('Cost log prefix changed during snapshot publication');
}
}
}
);
return true;
}
function emptySnapshotResult(row) {
return {
row,
scannedBytes: 0,
malformed: 0,
invalid: 0,
malformedSignature: null,
invalidSignature: null,
snapshotError: null
};
}
function publishScanSnapshot(metricsDir, sessionId, scan, descriptor, stat) {
if (!scan.committedRow && scan.processedOffset === 0) return null;
try {
writeSnapshotAtOffset(
metricsDir,
sessionId,
scan.committedRow,
descriptor,
stat,
scan.processedOffset,
scan.discardingLine
);
return null;
} catch (error) {
return error;
}
}
function refreshSessionCostSnapshot(metricsDir, sessionId) {
assertSafeSessionId(sessionId);
const costsPath = path.join(metricsDir, COST_LOG_FILENAME);
const descriptor = fs.openSync(costsPath, 'r');
try {
const stat = fs.fstatSync(descriptor);
const snapshot = readJsonFile(getCostSnapshotPath(metricsDir, sessionId));
const base = validSnapshotBase(snapshot, descriptor, stat, sessionId);
if (base?.offset === stat.size) return emptySnapshotResult(base.row);
const scanEnd = Math.min(stat.size, (base?.offset || 0) + MAX_SCAN_BYTES);
const scan = scanJsonlRange(
descriptor,
base?.offset || 0,
scanEnd,
sessionId,
base?.row || null,
base?.discardingLine || false
);
const snapshotError = publishScanSnapshot(
metricsDir, sessionId, scan, descriptor, stat
);
return {
row: scan.row,
scannedBytes: scan.processedOffset - (base?.offset || 0),
malformed: scan.malformed,
invalid: scan.invalid,
malformedSignature: scan.malformedSignature,
invalidSignature: scan.invalidSignature,
snapshotError
};
} finally {
fs.closeSync(descriptor);
}
}
function costLogNeedsSeparator(metricsDir) {
const costsPath = path.join(metricsDir, COST_LOG_FILENAME);
let descriptor;
try {
descriptor = fs.openSync(costsPath, 'r');
const stat = fs.fstatSync(descriptor);
if (stat.size === 0) return false;
const lastByte = Buffer.alloc(1);
return fs.readSync(descriptor, lastByte, 0, 1, stat.size - 1) === 1
&& lastByte[0] !== 0x0a;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function appendSessionCostRow(metricsDir, sessionId, row) {
assertSafeSessionId(sessionId);
if (!isValidCostRow(row, sessionId)) {
throw new Error('Cost snapshot requires valid non-negative numeric totals for its session');
}
const prefix = costLogNeedsSeparator(metricsDir) ? '\n' : '';
fs.appendFileSync(
path.join(metricsDir, COST_LOG_FILENAME),
`${prefix}${JSON.stringify(row)}\n`,
'utf8'
);
const result = refreshSessionCostSnapshot(metricsDir, sessionId);
if (result.snapshotError) throw result.snapshotError;
try {
maybePruneSessionCostSnapshots(metricsDir);
} catch (error) {
// Retention is opportunistic and retried by a later update, but a
// persistent failure remains visible without rolling back the log append.
warnSessionCostSnapshotFailure('retention', metricsDir, sessionId, error);
}
return JSON.stringify(result.row) === JSON.stringify(row);
}
function readSessionCostSnapshot(metricsDir, sessionId) {
try {
return refreshSessionCostSnapshot(metricsDir, sessionId);
} catch (error) {
if (error.code === 'ENOENT') {
return { row: null, scannedBytes: 0, malformed: 0, invalid: 0, snapshotError: null };
}
throw error;
}
}
function maybePruneSessionCostSnapshots(metricsDir, options = {}) {
const snapshotDir = getSnapshotDirectory(metricsDir);
const now = Number.isFinite(options.now) ? options.now : Date.now();
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : SNAPSHOT_MAX_AGE_MS;
const maxSnapshots = Number.isSafeInteger(options.maxSnapshots)
? Math.max(0, options.maxSnapshots)
: MAX_SNAPSHOTS;
const markerPath = path.join(snapshotDir, '.last-prune');
fs.mkdirSync(snapshotDir, { recursive: true });
const snapshotEntries = fs.readdirSync(snapshotDir, { withFileTypes: true })
.filter(entry => entry.isFile() && /^session-.+\.json$/.test(entry.name));
if (!options.force) {
try {
const intervalIsFresh = now - fs.statSync(markerPath).mtimeMs < PRUNE_INTERVAL_MS;
if (intervalIsFresh && snapshotEntries.length <= maxSnapshots) return 0;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
const snapshots = snapshotEntries
.map(entry => {
const filePath = path.join(snapshotDir, entry.name);
return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
})
.sort((left, right) => right.mtimeMs - left.mtimeMs);
const removals = snapshots.filter((entry, index) => (
index >= maxSnapshots || now - entry.mtimeMs > maxAgeMs
));
let removed = 0;
for (const entry of removals) {
try {
if (fs.statSync(entry.filePath).mtimeMs <= entry.mtimeMs) {
fs.rmSync(entry.filePath, { force: true });
removed += 1;
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
fs.writeFileSync(markerPath, String(now), { encoding: 'utf8', mode: 0o600 });
return removed;
}
function warningClaimPath(kind, metricsDir, sessionId, signature) {
const key = crypto.createHash('sha256')
.update(`${path.resolve(metricsDir)}\0${sessionId}\0${kind}\0${signature}`)
.digest('hex')
.slice(0, 16);
return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${key}.claim`);
}
function warnSessionCostSnapshotFailure(kind, metricsDir, sessionId, error) {
const targetPath = getCostSnapshotPath(metricsDir, sessionId);
const errorCode = error?.code || error?.name || 'error';
const signature = `${kind}:${targetPath}:${errorCode}`;
const claimPath = warningClaimPath(kind, metricsDir, sessionId, signature);
let claimDescriptor;
try {
claimDescriptor = fs.openSync(claimPath, 'wx', 0o600);
fs.closeSync(claimDescriptor);
claimDescriptor = undefined;
} catch (claimError) {
if (claimDescriptor !== undefined) fs.closeSync(claimDescriptor);
if (claimError.code === 'EEXIST') return;
// Warning persistence is best effort. If the claim cannot be created,
// still surface the underlying snapshot failure.
}
process.stderr.write(
`[cost-snapshot] ${kind} failed for session ${sessionId}: ${error?.message || String(error)}\n`
);
}
module.exports = {
COST_SNAPSHOT_SCHEMA_VERSION,
COST_SNAPSHOT_DIRECTORY,
COST_LOG_FILENAME,
MAX_SCAN_BYTES,
getCostSnapshotPath,
isValidCostRow,
chooseNewerCumulativeRow,
appendSessionCostRow,
readSessionCostSnapshot,
refreshSessionCostSnapshot,
costLogNeedsSeparator,
maybePruneSessionCostSnapshots,
warnSessionCostSnapshotFailure
};
+71
View File
@@ -135,6 +135,74 @@ function getGitRepoName() {
return path.basename(result.output);
}
/**
* Get the repository identity for a directory: the canonical (real) path of
* the repository's common git dir, which is the main worktree's .git
* directory. Every linked worktree of one repository resolves to the same
* identity, while unrelated repositories never share one.
*
* @param {string} [dir] - Directory to resolve from (defaults to process.cwd()).
* @returns {string|null} The canonical common git dir, or null when dir is
* not inside a git repository or does not exist.
*/
function getRepoIdentity(dir, runCmd = runCommand) {
const target = dir || process.cwd();
const result = runCmd('git rev-parse --git-common-dir', { cwd: target });
if (!result.success || !result.output) return null;
const commonDir = path.resolve(target, result.output);
try {
return fs.realpathSync(commonDir);
} catch {
return commonDir;
}
}
/**
* Normalize a repository identity path for comparison: canonical (real) form
* when it exists, forward slashes, no trailing slash, and lowercase on
* Windows where the filesystem is case-insensitive. The platform argument
* exists so Windows-shaped git output can be tested on any OS.
*
* @param {string} p - Path to normalize.
* @param {string} [platform] - Platform override (defaults to process.platform).
* @returns {string} The normalized path, or '' for empty input.
*/
function normalizeRepoPath(p, platform = process.platform) {
if (!p) return '';
let resolved;
try {
resolved = fs.realpathSync(p);
} catch {
resolved = path.resolve(p);
}
const slashed = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
return platform === 'win32' ? slashed.toLowerCase() : slashed;
}
/**
* Compare two repository identity paths. String normalization alone is not
* enough on Windows CI runners, where TEMP commonly uses an 8.3 short name
* (RUNNER~1): Node's realpath keeps the short form while git reports the
* long form for the same directory. When the strings differ, fall back to
* filesystem identity (device + inode), which is immune to 8.3 names, case
* and separators. Fails closed when either path cannot be statted.
*
* @param {string} a - First identity path.
* @param {string} b - Second identity path.
* @returns {boolean} True when both paths name the same directory.
*/
function sameRepoIdentity(a, b) {
if (!a || !b) return false;
if (normalizeRepoPath(a) === normalizeRepoPath(b)) return true;
try {
const sa = fs.statSync(a);
const sb = fs.statSync(b);
return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
} catch {
return false;
}
}
/**
* Get project name from git repo or current directory
*/
@@ -642,6 +710,9 @@ module.exports = {
sanitizeSessionId,
getSessionIdShort,
getGitRepoName,
getRepoIdentity,
normalizeRepoPath,
sameRepoIdentity,
getProjectName,
// File operations