mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: record canonical session snapshots via adapters (#511)
This commit is contained in:
285
docs/SESSION-ADAPTER-CONTRACT.md
Normal file
285
docs/SESSION-ADAPTER-CONTRACT.md
Normal 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.
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user