From 426fc544563774178194f73b96f0bdfc760783ba Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 16 Mar 2026 01:35:45 -0700 Subject: [PATCH] feat: record canonical session snapshots via adapters (#511) --- docs/SESSION-ADAPTER-CONTRACT.md | 285 +++++++++++++ .../lib/session-adapters/canonical-session.js | 360 +++++++++++++++- .../lib/session-adapters/claude-history.js | 17 +- scripts/lib/session-adapters/dmux-tmux.js | 14 +- scripts/lib/session-adapters/registry.js | 24 +- tests/lib/session-adapters.test.js | 394 +++++++++++++++--- tests/scripts/session-inspect.test.js | 13 +- 7 files changed, 1025 insertions(+), 82 deletions(-) create mode 100644 docs/SESSION-ADAPTER-CONTRACT.md diff --git a/docs/SESSION-ADAPTER-CONTRACT.md b/docs/SESSION-ADAPTER-CONTRACT.md new file mode 100644 index 00000000..fbc213c0 --- /dev/null +++ b/docs/SESSION-ADAPTER-CONTRACT.md @@ -0,0 +1,285 @@ +# Session Adapter Contract + +This document defines the canonical ECC session snapshot contract for +`ecc.session.v1`. + +The contract is implemented in +`scripts/lib/session-adapters/canonical-session.js`. This document is the +normative specification for adapters and consumers. + +## Purpose + +ECC has multiple session sources: + +- tmux-orchestrated worktree sessions +- Claude local session history +- future harnesses and control-plane backends + +Adapters normalize those sources into one control-plane-safe snapshot shape so +inspection, persistence, and future UI layers do not depend on harness-specific +files or runtime details. + +## Canonical Snapshot + +Every adapter MUST return a JSON-serializable object with this top-level shape: + +```json +{ + "schemaVersion": "ecc.session.v1", + "adapterId": "dmux-tmux", + "session": { + "id": "workflow-visual-proof", + "kind": "orchestrated", + "state": "active", + "repoRoot": "/tmp/repo", + "sourceTarget": { + "type": "session", + "value": "workflow-visual-proof" + } + }, + "workers": [ + { + "id": "seed-check", + "label": "seed-check", + "state": "running", + "branch": "feature/seed-check", + "worktree": "/tmp/worktree", + "runtime": { + "kind": "tmux-pane", + "command": "codex", + "pid": 1234, + "active": false, + "dead": false + }, + "intent": { + "objective": "Inspect seeded files.", + "seedPaths": ["scripts/orchestrate-worktrees.js"] + }, + "outputs": { + "summary": [], + "validation": [], + "remainingRisks": [] + }, + "artifacts": { + "statusFile": "/tmp/status.md", + "taskFile": "/tmp/task.md", + "handoffFile": "/tmp/handoff.md" + } + } + ], + "aggregates": { + "workerCount": 1, + "states": { + "running": 1 + } + } +} +``` + +## Required Fields + +### Top level + +| Field | Type | Notes | +| --- | --- | --- | +| `schemaVersion` | string | MUST be exactly `ecc.session.v1` for this contract | +| `adapterId` | string | Stable adapter identifier such as `dmux-tmux` or `claude-history` | +| `session` | object | Canonical session metadata | +| `workers` | array | Canonical worker records; may be empty | +| `aggregates` | object | Derived worker counts | + +### `session` + +| Field | Type | Notes | +| --- | --- | --- | +| `id` | string | Stable identifier within the adapter domain | +| `kind` | string | High-level session family such as `orchestrated` or `history` | +| `state` | string | Canonical session state | +| `sourceTarget` | object | Provenance for the target that opened the session | + +### `session.sourceTarget` + +| Field | Type | Notes | +| --- | --- | --- | +| `type` | string | Lookup class such as `plan`, `session`, `claude-history`, `claude-alias`, or `session-file` | +| `value` | string | Raw target value or resolved path | + +### `workers[]` + +| Field | Type | Notes | +| --- | --- | --- | +| `id` | string | Stable worker identifier in adapter scope | +| `label` | string | Operator-facing label | +| `state` | string | Canonical worker state | +| `runtime` | object | Execution/runtime metadata | +| `intent` | object | Why this worker/session exists | +| `outputs` | object | Structured outcomes and checks | +| `artifacts` | object | Adapter-owned file/path references | + +### `workers[].runtime` + +| Field | Type | Notes | +| --- | --- | --- | +| `kind` | string | Runtime family such as `tmux-pane` or `claude-session` | +| `active` | boolean | Whether the runtime is active now | +| `dead` | boolean | Whether the runtime is known dead/finished | + +### `workers[].intent` + +| Field | Type | Notes | +| --- | --- | --- | +| `objective` | string | Primary objective or title | +| `seedPaths` | string[] | Seed or context paths associated with the worker/session | + +### `workers[].outputs` + +| Field | Type | Notes | +| --- | --- | --- | +| `summary` | string[] | Completed outputs or summary items | +| `validation` | string[] | Validation evidence or checks | +| `remainingRisks` | string[] | Open risks, follow-ups, or notes | + +### `aggregates` + +| Field | Type | Notes | +| --- | --- | --- | +| `workerCount` | integer | MUST equal `workers.length` | +| `states` | object | Count map derived from `workers[].state` | + +## Optional Fields + +Optional fields MAY be omitted, but if emitted they MUST preserve the documented +type: + +| Field | Type | Notes | +| --- | --- | --- | +| `session.repoRoot` | `string \| null` | Repo/worktree root when known | +| `workers[].branch` | `string \| null` | Branch name when known | +| `workers[].worktree` | `string \| null` | Worktree path when known | +| `workers[].runtime.command` | `string \| null` | Active command when known | +| `workers[].runtime.pid` | `number \| null` | Process id when known | +| `workers[].artifacts.*` | adapter-defined | File paths or structured references owned by the adapter | + +Adapter-specific optional fields belong inside `runtime`, `artifacts`, or other +documented nested objects. Adapters MUST NOT invent new top-level fields without +updating this contract. + +## State Semantics + +The contract intentionally keeps `session.state` and `workers[].state` flexible +enough for multiple harnesses, but current adapters use these values: + +- `dmux-tmux` + - session states: `active`, `completed`, `failed`, `idle`, `missing` + - worker states: derived from worker status files, for example `running` or + `completed` +- `claude-history` + - session state: `recorded` + - worker state: `recorded` + +Consumers MUST treat unknown state strings as valid adapter-specific values and +degrade gracefully. + +## Versioning Strategy + +`schemaVersion` is the only compatibility gate. Consumers MUST branch on it. + +### Allowed in `ecc.session.v1` + +- adding new optional nested fields +- adding new adapter ids +- adding new state string values +- adding new artifact keys inside `workers[].artifacts` + +### Requires a new schema version + +- removing a required field +- renaming a field +- changing a field type +- changing the meaning of an existing field in a non-compatible way +- moving data from one field to another while keeping the same version string + +If any of those happen, the producer MUST emit a new version string such as +`ecc.session.v2`. + +## Adapter Compliance Requirements + +Every ECC session adapter MUST: + +1. Emit `schemaVersion: "ecc.session.v1"` exactly. +2. Return a snapshot that satisfies all required fields and types. +3. Use `null` for unknown optional scalar values and empty arrays for unknown + list values. +4. Keep adapter-specific details nested under `runtime`, `artifacts`, or other + documented nested objects. +5. Ensure `aggregates.workerCount === workers.length`. +6. Ensure `aggregates.states` matches the emitted worker states. +7. Produce plain JSON-serializable values only. +8. Validate the canonical shape before persistence or downstream use. +9. Persist the normalized canonical snapshot through the session recording shim. + In this repo, that shim first attempts `scripts/lib/state-store` and falls + back to a JSON recording file only when the state store module is not + available yet. + +## Consumer Expectations + +Consumers SHOULD: + +- rely only on documented fields for `ecc.session.v1` +- ignore unknown optional fields +- treat `adapterId`, `session.kind`, and `runtime.kind` as routing hints rather + than exhaustive enums +- expect adapter-specific artifact keys inside `workers[].artifacts` + +Consumers MUST NOT: + +- infer harness-specific behavior from undocumented fields +- assume all adapters have tmux panes, git worktrees, or markdown coordination + files +- reject snapshots only because a state string is unfamiliar + +## Current Adapter Mappings + +### `dmux-tmux` + +- Source: `scripts/lib/orchestration-session.js` +- Session id: orchestration session name +- Session kind: `orchestrated` +- Session source target: plan path or session name +- Worker runtime kind: `tmux-pane` +- Artifacts: `statusFile`, `taskFile`, `handoffFile` + +### `claude-history` + +- Source: `scripts/lib/session-manager.js` +- Session id: Claude short id when present, otherwise session filename-derived id +- Session kind: `history` +- Session source target: explicit history target, alias, or `.tmp` session file +- Worker runtime kind: `claude-session` +- Intent seed paths: parsed from `### Context to Load` +- Artifacts: `sessionFile`, `context` + +## Validation Reference + +The repo implementation validates: + +- required object structure +- required string fields +- boolean runtime flags +- string-array outputs and seed paths +- aggregate count consistency + +Adapters should treat validation failures as contract bugs, not user input +errors. + +## Recording Fallback Behavior + +The JSON fallback recorder is a temporary compatibility shim for the period +before the dedicated state store lands. Its behavior is: + +- latest snapshot is always replaced in-place +- history records only distinct snapshot bodies +- unchanged repeated reads do not append duplicate history entries + +This keeps `session-inspect` and other polling-style reads from growing +unbounded history for the same unchanged session snapshot. diff --git a/scripts/lib/session-adapters/canonical-session.js b/scripts/lib/session-adapters/canonical-session.js index 76cbcdea..4e87aa7e 100644 --- a/scripts/lib/session-adapters/canonical-session.js +++ b/scripts/lib/session-adapters/canonical-session.js @@ -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 }; diff --git a/scripts/lib/session-adapters/claude-history.js b/scripts/lib/session-adapters/claude-history.js index c6ff7f6d..a42c4628 100644 --- a/scripts/lib/session-adapters/claude-history.js +++ b/scripts/lib/session-adapters/claude-history.js @@ -5,7 +5,7 @@ const path = require('path'); const sessionManager = require('../session-manager'); const sessionAliases = require('../session-aliases'); -const { normalizeClaudeHistorySession } = require('./canonical-session'); +const { normalizeClaudeHistorySession, persistCanonicalSnapshot } = require('./canonical-session'); function parseClaudeTarget(target) { if (typeof target !== 'string') { @@ -111,7 +111,9 @@ function resolveSessionRecord(target, cwd) { throw new Error(`Unsupported Claude session target: ${target}`); } -function createClaudeHistoryAdapter() { +function createClaudeHistoryAdapter(options = {}) { + const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot; + return { id: 'claude-history', description: 'Claude local session history and session-file snapshots', @@ -135,7 +137,16 @@ function createClaudeHistoryAdapter() { adapterId: 'claude-history', getSnapshot() { const { session, sourceTarget } = resolveSessionRecord(target, cwd); - return normalizeClaudeHistorySession(session, sourceTarget); + const canonicalSnapshot = normalizeClaudeHistorySession(session, sourceTarget); + + persistCanonicalSnapshotImpl(canonicalSnapshot, { + loadStateStoreImpl: options.loadStateStoreImpl, + persist: context.persistSnapshots !== false && options.persistSnapshots !== false, + recordingDir: context.recordingDir || options.recordingDir, + stateStore: options.stateStore + }); + + return canonicalSnapshot; } }; } diff --git a/scripts/lib/session-adapters/dmux-tmux.js b/scripts/lib/session-adapters/dmux-tmux.js index 05fffa1f..40bd3899 100644 --- a/scripts/lib/session-adapters/dmux-tmux.js +++ b/scripts/lib/session-adapters/dmux-tmux.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { collectSessionSnapshot } = require('../orchestration-session'); -const { normalizeDmuxSnapshot } = require('./canonical-session'); +const { normalizeDmuxSnapshot, persistCanonicalSnapshot } = require('./canonical-session'); function isPlanFileTarget(target, cwd) { if (typeof target !== 'string' || target.length === 0) { @@ -42,6 +42,7 @@ function buildSourceTarget(target, cwd) { function createDmuxTmuxAdapter(options = {}) { const collectSessionSnapshotImpl = options.collectSessionSnapshotImpl || collectSessionSnapshot; + const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot; return { id: 'dmux-tmux', @@ -66,7 +67,16 @@ function createDmuxTmuxAdapter(options = {}) { adapterId: 'dmux-tmux', getSnapshot() { const snapshot = collectSessionSnapshotImpl(target, cwd); - return normalizeDmuxSnapshot(snapshot, buildSourceTarget(target, cwd)); + const canonicalSnapshot = normalizeDmuxSnapshot(snapshot, buildSourceTarget(target, cwd)); + + persistCanonicalSnapshotImpl(canonicalSnapshot, { + loadStateStoreImpl: options.loadStateStoreImpl, + persist: context.persistSnapshots !== false && options.persistSnapshots !== false, + recordingDir: context.recordingDir || options.recordingDir, + stateStore: options.stateStore + }); + + return canonicalSnapshot; } }; } diff --git a/scripts/lib/session-adapters/registry.js b/scripts/lib/session-adapters/registry.js index cdbf24f7..28bc9298 100644 --- a/scripts/lib/session-adapters/registry.js +++ b/scripts/lib/session-adapters/registry.js @@ -11,10 +11,26 @@ const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({ 'session-file': 'claude-history' }); -function createDefaultAdapters() { +function buildDefaultAdapterOptions(options, adapterId) { + const sharedOptions = { + loadStateStoreImpl: options.loadStateStoreImpl, + persistSnapshots: options.persistSnapshots, + recordingDir: options.recordingDir, + stateStore: options.stateStore + }; + + return { + ...sharedOptions, + ...(options.adapterOptions && options.adapterOptions[adapterId] + ? options.adapterOptions[adapterId] + : {}) + }; +} + +function createDefaultAdapters(options = {}) { return [ - createClaudeHistoryAdapter(), - createDmuxTmuxAdapter() + createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')), + createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux')) ]; } @@ -60,7 +76,7 @@ function normalizeStructuredTarget(target, context = {}) { } function createAdapterRegistry(options = {}) { - const adapters = options.adapters || createDefaultAdapters(); + const adapters = options.adapters || createDefaultAdapters(options); return { adapters, diff --git a/tests/lib/session-adapters.test.js b/tests/lib/session-adapters.test.js index e1234d3d..32046f9c 100644 --- a/tests/lib/session-adapters.test.js +++ b/tests/lib/session-adapters.test.js @@ -5,9 +5,16 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { + getFallbackSessionRecordingPath, + persistCanonicalSnapshot +} = require('../../scripts/lib/session-adapters/canonical-session'); const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history'); const { createDmuxTmuxAdapter } = require('../../scripts/lib/session-adapters/dmux-tmux'); -const { createAdapterRegistry } = require('../../scripts/lib/session-adapters/registry'); +const { + createAdapterRegistry, + inspectSessionTarget +} = require('../../scripts/lib/session-adapters/registry'); console.log('=== Testing session-adapters ===\n'); @@ -41,74 +48,233 @@ function withHome(homeDir, fn) { } test('dmux adapter normalizes orchestration snapshots into canonical form', () => { - const adapter = createDmuxTmuxAdapter({ - collectSessionSnapshotImpl: () => ({ - sessionName: 'workflow-visual-proof', - coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', - repoRoot: '/tmp/repo', - targetType: 'plan', - sessionActive: true, - paneCount: 1, - workerCount: 1, - workerStates: { running: 1 }, - panes: [{ - paneId: '%95', - windowIndex: 1, - paneIndex: 0, - title: 'seed-check', - currentCommand: 'codex', - currentPath: '/tmp/worktree', - active: false, - dead: false, - pid: 1234 - }], - workers: [{ - workerSlug: 'seed-check', - workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check', - status: { - state: 'running', - updated: '2026-03-13T00:00:00Z', - branch: 'feature/seed-check', - worktree: '/tmp/worktree', - taskFile: '/tmp/task.md', - handoffFile: '/tmp/handoff.md' - }, - task: { - objective: 'Inspect seeded files.', - seedPaths: ['scripts/orchestrate-worktrees.js'] - }, - handoff: { - summary: ['Pending'], - validation: [], - remainingRisks: ['No screenshot yet'] - }, - files: { - status: '/tmp/status.md', - task: '/tmp/task.md', - handoff: '/tmp/handoff.md' - }, - pane: { + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); + + try { + const adapter = createDmuxTmuxAdapter({ + collectSessionSnapshotImpl: () => ({ + sessionName: 'workflow-visual-proof', + coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', + repoRoot: '/tmp/repo', + targetType: 'plan', + sessionActive: true, + paneCount: 1, + workerCount: 1, + workerStates: { running: 1 }, + panes: [{ paneId: '%95', - title: 'seed-check' - } - }] - }) - }); + windowIndex: 1, + paneIndex: 0, + title: 'seed-check', + currentCommand: 'codex', + currentPath: '/tmp/worktree', + active: false, + dead: false, + pid: 1234 + }], + workers: [{ + workerSlug: 'seed-check', + workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check', + status: { + state: 'running', + updated: '2026-03-13T00:00:00Z', + branch: 'feature/seed-check', + worktree: '/tmp/worktree', + taskFile: '/tmp/task.md', + handoffFile: '/tmp/handoff.md' + }, + task: { + objective: 'Inspect seeded files.', + seedPaths: ['scripts/orchestrate-worktrees.js'] + }, + handoff: { + summary: ['Pending'], + validation: [], + remainingRisks: ['No screenshot yet'] + }, + files: { + status: '/tmp/status.md', + task: '/tmp/task.md', + handoff: '/tmp/handoff.md' + }, + pane: { + paneId: '%95', + title: 'seed-check' + } + }] + }), + recordingDir + }); - const snapshot = adapter.open('workflow-visual-proof').getSnapshot(); + const snapshot = adapter.open('workflow-visual-proof').getSnapshot(); + const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir }); + const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); - assert.strictEqual(snapshot.schemaVersion, 'ecc.session.v1'); - assert.strictEqual(snapshot.adapterId, 'dmux-tmux'); - assert.strictEqual(snapshot.session.id, 'workflow-visual-proof'); - assert.strictEqual(snapshot.session.kind, 'orchestrated'); - assert.strictEqual(snapshot.session.sourceTarget.type, 'session'); - assert.strictEqual(snapshot.aggregates.workerCount, 1); - assert.strictEqual(snapshot.workers[0].runtime.kind, 'tmux-pane'); - assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet'); + assert.strictEqual(snapshot.schemaVersion, 'ecc.session.v1'); + assert.strictEqual(snapshot.adapterId, 'dmux-tmux'); + assert.strictEqual(snapshot.session.id, 'workflow-visual-proof'); + assert.strictEqual(snapshot.session.kind, 'orchestrated'); + assert.strictEqual(snapshot.session.state, 'active'); + assert.strictEqual(snapshot.session.sourceTarget.type, 'session'); + assert.strictEqual(snapshot.aggregates.workerCount, 1); + assert.strictEqual(snapshot.workers[0].runtime.kind, 'tmux-pane'); + assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet'); + assert.strictEqual(persisted.latest.session.state, 'active'); + assert.strictEqual(persisted.latest.adapterId, 'dmux-tmux'); + assert.strictEqual(persisted.history.length, 1); + } finally { + fs.rmSync(recordingDir, { recursive: true, force: true }); + } +}); + +test('dmux adapter marks finished sessions as completed and records history', () => { + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); + + try { + const adapter = createDmuxTmuxAdapter({ + collectSessionSnapshotImpl: () => ({ + sessionName: 'workflow-visual-proof', + coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', + repoRoot: '/tmp/repo', + targetType: 'session', + sessionActive: false, + paneCount: 0, + workerCount: 2, + workerStates: { completed: 2 }, + panes: [], + workers: [{ + workerSlug: 'seed-check', + workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check', + status: { + state: 'completed', + updated: '2026-03-13T00:00:00Z', + branch: 'feature/seed-check', + worktree: '/tmp/worktree-a', + taskFile: '/tmp/task-a.md', + handoffFile: '/tmp/handoff-a.md' + }, + task: { + objective: 'Inspect seeded files.', + seedPaths: ['scripts/orchestrate-worktrees.js'] + }, + handoff: { + summary: ['Finished'], + validation: ['Reviewed outputs'], + remainingRisks: [] + }, + files: { + status: '/tmp/status-a.md', + task: '/tmp/task-a.md', + handoff: '/tmp/handoff-a.md' + }, + pane: null + }, { + workerSlug: 'proof', + workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/proof', + status: { + state: 'completed', + updated: '2026-03-13T00:10:00Z', + branch: 'feature/proof', + worktree: '/tmp/worktree-b', + taskFile: '/tmp/task-b.md', + handoffFile: '/tmp/handoff-b.md' + }, + task: { + objective: 'Capture proof.', + seedPaths: ['README.md'] + }, + handoff: { + summary: ['Delivered proof'], + validation: ['Checked screenshots'], + remainingRisks: [] + }, + files: { + status: '/tmp/status-b.md', + task: '/tmp/task-b.md', + handoff: '/tmp/handoff-b.md' + }, + pane: null + }] + }), + recordingDir + }); + + const snapshot = adapter.open('workflow-visual-proof').getSnapshot(); + const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir }); + const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); + + assert.strictEqual(snapshot.session.state, 'completed'); + assert.strictEqual(snapshot.aggregates.states.completed, 2); + assert.strictEqual(persisted.latest.session.state, 'completed'); + assert.strictEqual(persisted.history.length, 1); + } finally { + fs.rmSync(recordingDir, { recursive: true, force: true }); + } +}); + +test('fallback recording does not append duplicate history entries for unchanged snapshots', () => { + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); + + try { + const adapter = createDmuxTmuxAdapter({ + collectSessionSnapshotImpl: () => ({ + sessionName: 'workflow-visual-proof', + coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', + repoRoot: '/tmp/repo', + targetType: 'session', + sessionActive: true, + paneCount: 1, + workerCount: 1, + workerStates: { running: 1 }, + panes: [], + workers: [{ + workerSlug: 'seed-check', + workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check', + status: { + state: 'running', + updated: '2026-03-13T00:00:00Z', + branch: 'feature/seed-check', + worktree: '/tmp/worktree', + taskFile: '/tmp/task.md', + handoffFile: '/tmp/handoff.md' + }, + task: { + objective: 'Inspect seeded files.', + seedPaths: ['scripts/orchestrate-worktrees.js'] + }, + handoff: { + summary: ['Pending'], + validation: [], + remainingRisks: [] + }, + files: { + status: '/tmp/status.md', + task: '/tmp/task.md', + handoff: '/tmp/handoff.md' + }, + pane: null + }] + }), + recordingDir + }); + + const handle = adapter.open('workflow-visual-proof'); + const firstSnapshot = handle.getSnapshot(); + const secondSnapshot = handle.getSnapshot(); + const recordingPath = getFallbackSessionRecordingPath(firstSnapshot, { recordingDir }); + const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); + + assert.deepStrictEqual(secondSnapshot, firstSnapshot); + assert.strictEqual(persisted.history.length, 1); + assert.deepStrictEqual(persisted.latest, secondSnapshot); + } finally { + fs.rmSync(recordingDir, { recursive: true, force: true }); + } }); test('claude-history adapter loads the latest recorded session', () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-adapter-home-')); + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); const sessionsDir = path.join(homeDir, '.claude', 'sessions'); fs.mkdirSync(sessionsDir, { recursive: true }); @@ -140,8 +306,10 @@ test('claude-history adapter loads the latest recorded session', () => { try { withHome(homeDir, () => { - const adapter = createClaudeHistoryAdapter(); + const adapter = createClaudeHistoryAdapter({ recordingDir }); const snapshot = adapter.open('claude:latest').getSnapshot(); + const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir }); + const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); assert.strictEqual(snapshot.schemaVersion, 'ecc.session.v1'); assert.strictEqual(snapshot.adapterId, 'claude-history'); @@ -151,11 +319,15 @@ test('claude-history adapter loads the latest recorded session', () => { assert.strictEqual(snapshot.workers[0].branch, 'feat/session-adapter'); assert.strictEqual(snapshot.workers[0].worktree, '/tmp/ecc-worktree'); assert.strictEqual(snapshot.workers[0].runtime.kind, 'claude-session'); + assert.deepStrictEqual(snapshot.workers[0].intent.seedPaths, ['scripts/lib/orchestration-session.js']); assert.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionPath); assert.ok(snapshot.workers[0].outputs.summary.includes('Build snapshot prototype')); + assert.strictEqual(persisted.latest.adapterId, 'claude-history'); + assert.strictEqual(persisted.history.length, 1); }); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(recordingDir, { recursive: true, force: true }); } }); @@ -264,6 +436,41 @@ test('adapter registry resolves structured target types into the correct adapter } }); +test('default registry forwards a nested state-store writer to adapters', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-registry-home-')); + const sessionsDir = path.join(homeDir, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, '2026-03-13-z9y8x7w6-session.tmp'), + '# History Session\n\n**Branch:** feat/history\n' + ); + + const stateStore = { + sessions: { + persisted: [], + persistCanonicalSessionSnapshot(snapshot, metadata) { + this.persisted.push({ snapshot, metadata }); + } + } + }; + + try { + withHome(homeDir, () => { + const snapshot = inspectSessionTarget('claude:latest', { + cwd: process.cwd(), + stateStore + }); + + assert.strictEqual(snapshot.adapterId, 'claude-history'); + assert.strictEqual(stateStore.sessions.persisted.length, 1); + assert.strictEqual(stateStore.sessions.persisted[0].snapshot.adapterId, 'claude-history'); + assert.strictEqual(stateStore.sessions.persisted[0].metadata.sessionId, snapshot.session.id); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +}); + test('adapter registry lists adapter metadata and target types', () => { const registry = createAdapterRegistry(); const adapters = registry.listAdapters(); @@ -281,5 +488,66 @@ test('adapter registry lists adapter metadata and target types', () => { ); }); +test('persistence only falls back when the state-store module is missing', () => { + const snapshot = { + schemaVersion: 'ecc.session.v1', + adapterId: 'claude-history', + session: { + id: 'a1b2c3d4', + kind: 'history', + state: 'recorded', + repoRoot: null, + sourceTarget: { + type: 'claude-history', + value: 'latest' + } + }, + workers: [{ + id: 'a1b2c3d4', + label: 'Session Review', + state: 'recorded', + branch: null, + worktree: null, + runtime: { + kind: 'claude-session', + command: 'claude', + pid: null, + active: false, + dead: true + }, + intent: { + objective: 'Session Review', + seedPaths: [] + }, + outputs: { + summary: [], + validation: [], + remainingRisks: [] + }, + artifacts: { + sessionFile: '/tmp/session.tmp', + context: null + } + }], + aggregates: { + workerCount: 1, + states: { + recorded: 1 + } + } + }; + + const loadError = new Error('state-store bootstrap failed'); + loadError.code = 'ERR_STATE_STORE_BOOT'; + + assert.throws(() => { + persistCanonicalSnapshot(snapshot, { + loadStateStoreImpl() { + throw loadError; + } + }); + }, /state-store bootstrap failed/); +}); + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); if (failed > 0) process.exit(1); diff --git a/tests/scripts/session-inspect.test.js b/tests/scripts/session-inspect.test.js index 42dd1132..aeb59fe7 100644 --- a/tests/scripts/session-inspect.test.js +++ b/tests/scripts/session-inspect.test.js @@ -8,6 +8,8 @@ const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); +const { getFallbackSessionRecordingPath } = require('../../scripts/lib/session-adapters/canonical-session'); + const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js'); function run(args = [], options = {}) { @@ -67,6 +69,7 @@ function runTests() { if (test('prints canonical JSON for claude history targets', () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-')); + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-recordings-')); const sessionsDir = path.join(homeDir, '.claude', 'sessions'); fs.mkdirSync(sessionsDir, { recursive: true }); @@ -77,16 +80,24 @@ function runTests() { ); const result = run(['claude:latest'], { - env: { HOME: homeDir } + env: { + HOME: homeDir, + ECC_SESSION_RECORDING_DIR: recordingDir + } }); assert.strictEqual(result.code, 0, result.stderr); const payload = JSON.parse(result.stdout); + const recordingPath = getFallbackSessionRecordingPath(payload, { recordingDir }); + const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); assert.strictEqual(payload.adapterId, 'claude-history'); assert.strictEqual(payload.session.kind, 'history'); assert.strictEqual(payload.workers[0].branch, 'feat/session-inspect'); + assert.strictEqual(persisted.latest.adapterId, 'claude-history'); + assert.strictEqual(persisted.history.length, 1); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(recordingDir, { recursive: true, force: true }); } })) passed++; else failed++;