feat: record canonical session snapshots via adapters (#511)

This commit is contained in:
Affaan Mustafa
2026-03-16 01:35:45 -07:00
committed by GitHub
parent bae1129209
commit 426fc54456
7 changed files with 1025 additions and 82 deletions

View File

@@ -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.

View File

@@ -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
};

View File

@@ -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;
}
};
}

View File

@@ -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;
}
};
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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++;