mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: record canonical session snapshots via adapters (#511)
This commit is contained in:
@@ -1,8 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const SESSION_SCHEMA_VERSION = 'ecc.session.v1';
|
||||
const SESSION_RECORDING_SCHEMA_VERSION = 'ecc.session.recording.v1';
|
||||
const DEFAULT_RECORDING_DIR = path.join(os.tmpdir(), 'ecc-session-recordings');
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value) {
|
||||
return String(value || 'unknown')
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'unknown';
|
||||
}
|
||||
|
||||
function parseContextSeedPaths(context) {
|
||||
if (typeof context !== 'string' || context.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return context
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureString(value, fieldPath) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOptionalString(value, fieldPath) {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string or null`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBoolean(value, fieldPath) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArrayOfStrings(value, fieldPath) {
|
||||
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be an array of strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInteger(value, fieldPath) {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAggregates(workers) {
|
||||
const states = workers.reduce((accumulator, worker) => {
|
||||
@@ -17,16 +73,299 @@ function buildAggregates(workers) {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRawWorkerStates(snapshot) {
|
||||
if (isObject(snapshot.workerStates)) {
|
||||
return snapshot.workerStates;
|
||||
}
|
||||
|
||||
return (snapshot.workers || []).reduce((counts, worker) => {
|
||||
const state = worker && worker.status && worker.status.state
|
||||
? worker.status.state
|
||||
: 'unknown';
|
||||
counts[state] = (counts[state] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function deriveDmuxSessionState(snapshot) {
|
||||
const workerStates = summarizeRawWorkerStates(snapshot);
|
||||
const totalWorkers = Number.isInteger(snapshot.workerCount)
|
||||
? snapshot.workerCount
|
||||
: Object.values(workerStates).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (snapshot.sessionActive) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (snapshot.workerCount > 0) {
|
||||
return 'idle';
|
||||
if (totalWorkers === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return 'missing';
|
||||
const failedCount = (workerStates.failed || 0) + (workerStates.error || 0);
|
||||
if (failedCount > 0) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const completedCount = (workerStates.completed || 0)
|
||||
+ (workerStates.succeeded || 0)
|
||||
+ (workerStates.success || 0)
|
||||
+ (workerStates.done || 0);
|
||||
if (completedCount === totalWorkers) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function validateCanonicalSnapshot(snapshot) {
|
||||
if (!isObject(snapshot)) {
|
||||
throw new Error('Canonical session snapshot must be an object');
|
||||
}
|
||||
|
||||
ensureString(snapshot.schemaVersion, 'schemaVersion');
|
||||
if (snapshot.schemaVersion !== SESSION_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported canonical session schema version: ${snapshot.schemaVersion}`);
|
||||
}
|
||||
|
||||
ensureString(snapshot.adapterId, 'adapterId');
|
||||
|
||||
if (!isObject(snapshot.session)) {
|
||||
throw new Error('Canonical session snapshot requires session to be an object');
|
||||
}
|
||||
|
||||
ensureString(snapshot.session.id, 'session.id');
|
||||
ensureString(snapshot.session.kind, 'session.kind');
|
||||
ensureString(snapshot.session.state, 'session.state');
|
||||
ensureOptionalString(snapshot.session.repoRoot, 'session.repoRoot');
|
||||
|
||||
if (!isObject(snapshot.session.sourceTarget)) {
|
||||
throw new Error('Canonical session snapshot requires session.sourceTarget to be an object');
|
||||
}
|
||||
|
||||
ensureString(snapshot.session.sourceTarget.type, 'session.sourceTarget.type');
|
||||
ensureString(snapshot.session.sourceTarget.value, 'session.sourceTarget.value');
|
||||
|
||||
if (!Array.isArray(snapshot.workers)) {
|
||||
throw new Error('Canonical session snapshot requires workers to be an array');
|
||||
}
|
||||
|
||||
snapshot.workers.forEach((worker, index) => {
|
||||
if (!isObject(worker)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}] to be an object`);
|
||||
}
|
||||
|
||||
ensureString(worker.id, `workers[${index}].id`);
|
||||
ensureString(worker.label, `workers[${index}].label`);
|
||||
ensureString(worker.state, `workers[${index}].state`);
|
||||
ensureOptionalString(worker.branch, `workers[${index}].branch`);
|
||||
ensureOptionalString(worker.worktree, `workers[${index}].worktree`);
|
||||
|
||||
if (!isObject(worker.runtime)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].runtime to be an object`);
|
||||
}
|
||||
|
||||
ensureString(worker.runtime.kind, `workers[${index}].runtime.kind`);
|
||||
ensureOptionalString(worker.runtime.command, `workers[${index}].runtime.command`);
|
||||
ensureBoolean(worker.runtime.active, `workers[${index}].runtime.active`);
|
||||
ensureBoolean(worker.runtime.dead, `workers[${index}].runtime.dead`);
|
||||
|
||||
if (!isObject(worker.intent)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].intent to be an object`);
|
||||
}
|
||||
|
||||
ensureString(worker.intent.objective, `workers[${index}].intent.objective`);
|
||||
ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`);
|
||||
|
||||
if (!isObject(worker.outputs)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].outputs to be an object`);
|
||||
}
|
||||
|
||||
ensureArrayOfStrings(worker.outputs.summary, `workers[${index}].outputs.summary`);
|
||||
ensureArrayOfStrings(worker.outputs.validation, `workers[${index}].outputs.validation`);
|
||||
ensureArrayOfStrings(worker.outputs.remainingRisks, `workers[${index}].outputs.remainingRisks`);
|
||||
|
||||
if (!isObject(worker.artifacts)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].artifacts to be an object`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isObject(snapshot.aggregates)) {
|
||||
throw new Error('Canonical session snapshot requires aggregates to be an object');
|
||||
}
|
||||
|
||||
ensureInteger(snapshot.aggregates.workerCount, 'aggregates.workerCount');
|
||||
if (snapshot.aggregates.workerCount !== snapshot.workers.length) {
|
||||
throw new Error('Canonical session snapshot requires aggregates.workerCount to match workers.length');
|
||||
}
|
||||
|
||||
if (!isObject(snapshot.aggregates.states)) {
|
||||
throw new Error('Canonical session snapshot requires aggregates.states to be an object');
|
||||
}
|
||||
|
||||
for (const [state, count] of Object.entries(snapshot.aggregates.states)) {
|
||||
ensureString(state, 'aggregates.states key');
|
||||
ensureInteger(count, `aggregates.states.${state}`);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function resolveRecordingDir(options = {}) {
|
||||
if (typeof options.recordingDir === 'string' && options.recordingDir.length > 0) {
|
||||
return path.resolve(options.recordingDir);
|
||||
}
|
||||
|
||||
if (typeof process.env.ECC_SESSION_RECORDING_DIR === 'string' && process.env.ECC_SESSION_RECORDING_DIR.length > 0) {
|
||||
return path.resolve(process.env.ECC_SESSION_RECORDING_DIR);
|
||||
}
|
||||
|
||||
return DEFAULT_RECORDING_DIR;
|
||||
}
|
||||
|
||||
function getFallbackSessionRecordingPath(snapshot, options = {}) {
|
||||
validateCanonicalSnapshot(snapshot);
|
||||
|
||||
return path.join(
|
||||
resolveRecordingDir(options),
|
||||
sanitizePathSegment(snapshot.adapterId),
|
||||
`${sanitizePathSegment(snapshot.session.id)}.json`
|
||||
);
|
||||
}
|
||||
|
||||
function readExistingRecording(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFallbackSessionRecording(snapshot, options = {}) {
|
||||
const filePath = getFallbackSessionRecordingPath(snapshot, options);
|
||||
const recordedAt = new Date().toISOString();
|
||||
const existing = readExistingRecording(filePath);
|
||||
const snapshotChanged = !existing
|
||||
|| JSON.stringify(existing.latest) !== JSON.stringify(snapshot);
|
||||
|
||||
const payload = {
|
||||
schemaVersion: SESSION_RECORDING_SCHEMA_VERSION,
|
||||
adapterId: snapshot.adapterId,
|
||||
sessionId: snapshot.session.id,
|
||||
createdAt: existing && typeof existing.createdAt === 'string'
|
||||
? existing.createdAt
|
||||
: recordedAt,
|
||||
updatedAt: recordedAt,
|
||||
latest: snapshot,
|
||||
history: Array.isArray(existing && existing.history)
|
||||
? (snapshotChanged
|
||||
? existing.history.concat([{ recordedAt, snapshot }])
|
||||
: existing.history)
|
||||
: [{ recordedAt, snapshot }]
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||
|
||||
return {
|
||||
backend: 'json-file',
|
||||
path: filePath,
|
||||
recordedAt
|
||||
};
|
||||
}
|
||||
|
||||
function loadStateStore(options = {}) {
|
||||
if (options.stateStore) {
|
||||
return options.stateStore;
|
||||
}
|
||||
|
||||
const loadStateStoreImpl = options.loadStateStoreImpl || (() => require('../state-store'));
|
||||
|
||||
try {
|
||||
return loadStateStoreImpl();
|
||||
} catch (error) {
|
||||
const missingRequestedModule = error
|
||||
&& error.code === 'MODULE_NOT_FOUND'
|
||||
&& typeof error.message === 'string'
|
||||
&& error.message.includes('../state-store');
|
||||
|
||||
if (missingRequestedModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStateStoreWriter(stateStore) {
|
||||
if (!stateStore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
{ owner: stateStore, fn: stateStore.persistCanonicalSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.recordCanonicalSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.persistSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.recordSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.writeSessionSnapshot },
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.persistCanonicalSessionSnapshot
|
||||
},
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.recordCanonicalSessionSnapshot
|
||||
},
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.persistSessionSnapshot
|
||||
},
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.recordSessionSnapshot
|
||||
}
|
||||
];
|
||||
|
||||
const writer = candidates.find(candidate => typeof candidate.fn === 'function');
|
||||
return writer ? writer.fn.bind(writer.owner) : null;
|
||||
}
|
||||
|
||||
function persistCanonicalSnapshot(snapshot, options = {}) {
|
||||
validateCanonicalSnapshot(snapshot);
|
||||
|
||||
if (options.persist === false) {
|
||||
return {
|
||||
backend: 'skipped',
|
||||
path: null,
|
||||
recordedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
const stateStore = loadStateStore(options);
|
||||
const writer = resolveStateStoreWriter(stateStore);
|
||||
|
||||
if (stateStore && !writer) {
|
||||
throw new Error('State store does not expose a supported session snapshot writer');
|
||||
}
|
||||
|
||||
if (writer) {
|
||||
writer(snapshot, {
|
||||
adapterId: snapshot.adapterId,
|
||||
schemaVersion: snapshot.schemaVersion,
|
||||
sessionId: snapshot.session.id
|
||||
});
|
||||
|
||||
return {
|
||||
backend: 'state-store',
|
||||
path: null,
|
||||
recordedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
return writeFallbackSessionRecording(snapshot, options);
|
||||
}
|
||||
|
||||
function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
||||
@@ -59,7 +398,7 @@ function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
return validateCanonicalSnapshot({
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'dmux-tmux',
|
||||
session: {
|
||||
@@ -71,7 +410,7 @@ function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
||||
},
|
||||
workers,
|
||||
aggregates: buildAggregates(workers)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deriveClaudeWorkerId(session) {
|
||||
@@ -102,7 +441,7 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
||||
objective: metadata.inProgress && metadata.inProgress.length > 0
|
||||
? metadata.inProgress[0]
|
||||
: (metadata.title || ''),
|
||||
seedPaths: []
|
||||
seedPaths: parseContextSeedPaths(metadata.context)
|
||||
},
|
||||
outputs: {
|
||||
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
|
||||
@@ -115,7 +454,7 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
return validateCanonicalSnapshot({
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'claude-history',
|
||||
session: {
|
||||
@@ -127,12 +466,15 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
||||
},
|
||||
workers: [worker],
|
||||
aggregates: buildAggregates([worker])
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SESSION_SCHEMA_VERSION,
|
||||
buildAggregates,
|
||||
getFallbackSessionRecordingPath,
|
||||
normalizeClaudeHistorySession,
|
||||
normalizeDmuxSnapshot
|
||||
normalizeDmuxSnapshot,
|
||||
persistCanonicalSnapshot,
|
||||
validateCanonicalSnapshot
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user