mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 21:53:28 +08:00
481 lines
14 KiB
JavaScript
481 lines
14 KiB
JavaScript
'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) => {
|
|
const state = worker.state || 'unknown';
|
|
accumulator[state] = (accumulator[state] || 0) + 1;
|
|
return accumulator;
|
|
}, {});
|
|
|
|
return {
|
|
workerCount: workers.length,
|
|
states
|
|
};
|
|
}
|
|
|
|
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 (totalWorkers === 0) {
|
|
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) {
|
|
const workers = (snapshot.workers || []).map(worker => ({
|
|
id: worker.workerSlug,
|
|
label: worker.workerSlug,
|
|
state: worker.status.state || 'unknown',
|
|
branch: worker.status.branch || null,
|
|
worktree: worker.status.worktree || null,
|
|
runtime: {
|
|
kind: 'tmux-pane',
|
|
command: worker.pane ? worker.pane.currentCommand || null : null,
|
|
pid: worker.pane ? worker.pane.pid || null : null,
|
|
active: worker.pane ? Boolean(worker.pane.active) : false,
|
|
dead: worker.pane ? Boolean(worker.pane.dead) : false,
|
|
},
|
|
intent: {
|
|
objective: worker.task.objective || '',
|
|
seedPaths: Array.isArray(worker.task.seedPaths) ? worker.task.seedPaths : []
|
|
},
|
|
outputs: {
|
|
summary: Array.isArray(worker.handoff.summary) ? worker.handoff.summary : [],
|
|
validation: Array.isArray(worker.handoff.validation) ? worker.handoff.validation : [],
|
|
remainingRisks: Array.isArray(worker.handoff.remainingRisks) ? worker.handoff.remainingRisks : []
|
|
},
|
|
artifacts: {
|
|
statusFile: worker.files.status,
|
|
taskFile: worker.files.task,
|
|
handoffFile: worker.files.handoff
|
|
}
|
|
}));
|
|
|
|
return validateCanonicalSnapshot({
|
|
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
adapterId: 'dmux-tmux',
|
|
session: {
|
|
id: snapshot.sessionName,
|
|
kind: 'orchestrated',
|
|
state: deriveDmuxSessionState(snapshot),
|
|
repoRoot: snapshot.repoRoot || null,
|
|
sourceTarget
|
|
},
|
|
workers,
|
|
aggregates: buildAggregates(workers)
|
|
});
|
|
}
|
|
|
|
function deriveClaudeWorkerId(session) {
|
|
if (session.shortId && session.shortId !== 'no-id') {
|
|
return session.shortId;
|
|
}
|
|
|
|
return path.basename(session.filename || session.sessionPath || 'session', '.tmp');
|
|
}
|
|
|
|
function normalizeClaudeHistorySession(session, sourceTarget) {
|
|
const metadata = session.metadata || {};
|
|
const workerId = deriveClaudeWorkerId(session);
|
|
const worker = {
|
|
id: workerId,
|
|
label: metadata.title || session.filename || workerId,
|
|
state: 'recorded',
|
|
branch: metadata.branch || null,
|
|
worktree: metadata.worktree || null,
|
|
runtime: {
|
|
kind: 'claude-session',
|
|
command: 'claude',
|
|
pid: null,
|
|
active: false,
|
|
dead: true,
|
|
},
|
|
intent: {
|
|
objective: metadata.inProgress && metadata.inProgress.length > 0
|
|
? metadata.inProgress[0]
|
|
: (metadata.title || ''),
|
|
seedPaths: parseContextSeedPaths(metadata.context)
|
|
},
|
|
outputs: {
|
|
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
|
|
validation: [],
|
|
remainingRisks: metadata.notes ? [metadata.notes] : []
|
|
},
|
|
artifacts: {
|
|
sessionFile: session.sessionPath,
|
|
context: metadata.context || null
|
|
}
|
|
};
|
|
|
|
return validateCanonicalSnapshot({
|
|
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
adapterId: 'claude-history',
|
|
session: {
|
|
id: workerId,
|
|
kind: 'history',
|
|
state: 'recorded',
|
|
repoRoot: metadata.worktree || null,
|
|
sourceTarget
|
|
},
|
|
workers: [worker],
|
|
aggregates: buildAggregates([worker])
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
SESSION_SCHEMA_VERSION,
|
|
buildAggregates,
|
|
getFallbackSessionRecordingPath,
|
|
normalizeClaudeHistorySession,
|
|
normalizeDmuxSnapshot,
|
|
persistCanonicalSnapshot,
|
|
validateCanonicalSnapshot
|
|
};
|