mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 12:03:31 +08:00
feat: record canonical session snapshots
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';
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const SESSION_SCHEMA_VERSION = 'ecc.session.v1';
|
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) {
|
function buildAggregates(workers) {
|
||||||
const states = workers.reduce((accumulator, worker) => {
|
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) {
|
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) {
|
if (snapshot.sessionActive) {
|
||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.workerCount > 0) {
|
if (totalWorkers === 0) {
|
||||||
return 'idle';
|
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) {
|
function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
||||||
@@ -59,7 +398,7 @@ function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return validateCanonicalSnapshot({
|
||||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||||
adapterId: 'dmux-tmux',
|
adapterId: 'dmux-tmux',
|
||||||
session: {
|
session: {
|
||||||
@@ -71,7 +410,7 @@ function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
|||||||
},
|
},
|
||||||
workers,
|
workers,
|
||||||
aggregates: buildAggregates(workers)
|
aggregates: buildAggregates(workers)
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveClaudeWorkerId(session) {
|
function deriveClaudeWorkerId(session) {
|
||||||
@@ -102,7 +441,7 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
|||||||
objective: metadata.inProgress && metadata.inProgress.length > 0
|
objective: metadata.inProgress && metadata.inProgress.length > 0
|
||||||
? metadata.inProgress[0]
|
? metadata.inProgress[0]
|
||||||
: (metadata.title || ''),
|
: (metadata.title || ''),
|
||||||
seedPaths: []
|
seedPaths: parseContextSeedPaths(metadata.context)
|
||||||
},
|
},
|
||||||
outputs: {
|
outputs: {
|
||||||
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
|
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
|
||||||
@@ -115,7 +454,7 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return validateCanonicalSnapshot({
|
||||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||||
adapterId: 'claude-history',
|
adapterId: 'claude-history',
|
||||||
session: {
|
session: {
|
||||||
@@ -127,12 +466,15 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
|||||||
},
|
},
|
||||||
workers: [worker],
|
workers: [worker],
|
||||||
aggregates: buildAggregates([worker])
|
aggregates: buildAggregates([worker])
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
SESSION_SCHEMA_VERSION,
|
SESSION_SCHEMA_VERSION,
|
||||||
buildAggregates,
|
buildAggregates,
|
||||||
|
getFallbackSessionRecordingPath,
|
||||||
normalizeClaudeHistorySession,
|
normalizeClaudeHistorySession,
|
||||||
normalizeDmuxSnapshot
|
normalizeDmuxSnapshot,
|
||||||
|
persistCanonicalSnapshot,
|
||||||
|
validateCanonicalSnapshot
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const path = require('path');
|
|||||||
|
|
||||||
const sessionManager = require('../session-manager');
|
const sessionManager = require('../session-manager');
|
||||||
const sessionAliases = require('../session-aliases');
|
const sessionAliases = require('../session-aliases');
|
||||||
const { normalizeClaudeHistorySession } = require('./canonical-session');
|
const { normalizeClaudeHistorySession, persistCanonicalSnapshot } = require('./canonical-session');
|
||||||
|
|
||||||
function parseClaudeTarget(target) {
|
function parseClaudeTarget(target) {
|
||||||
if (typeof target !== 'string') {
|
if (typeof target !== 'string') {
|
||||||
@@ -111,7 +111,9 @@ function resolveSessionRecord(target, cwd) {
|
|||||||
throw new Error(`Unsupported Claude session target: ${target}`);
|
throw new Error(`Unsupported Claude session target: ${target}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClaudeHistoryAdapter() {
|
function createClaudeHistoryAdapter(options = {}) {
|
||||||
|
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'claude-history',
|
id: 'claude-history',
|
||||||
description: 'Claude local session history and session-file snapshots',
|
description: 'Claude local session history and session-file snapshots',
|
||||||
@@ -135,7 +137,16 @@ function createClaudeHistoryAdapter() {
|
|||||||
adapterId: 'claude-history',
|
adapterId: 'claude-history',
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
const { session, sourceTarget } = resolveSessionRecord(target, cwd);
|
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 path = require('path');
|
||||||
|
|
||||||
const { collectSessionSnapshot } = require('../orchestration-session');
|
const { collectSessionSnapshot } = require('../orchestration-session');
|
||||||
const { normalizeDmuxSnapshot } = require('./canonical-session');
|
const { normalizeDmuxSnapshot, persistCanonicalSnapshot } = require('./canonical-session');
|
||||||
|
|
||||||
function isPlanFileTarget(target, cwd) {
|
function isPlanFileTarget(target, cwd) {
|
||||||
if (typeof target !== 'string' || target.length === 0) {
|
if (typeof target !== 'string' || target.length === 0) {
|
||||||
@@ -42,6 +42,7 @@ function buildSourceTarget(target, cwd) {
|
|||||||
|
|
||||||
function createDmuxTmuxAdapter(options = {}) {
|
function createDmuxTmuxAdapter(options = {}) {
|
||||||
const collectSessionSnapshotImpl = options.collectSessionSnapshotImpl || collectSessionSnapshot;
|
const collectSessionSnapshotImpl = options.collectSessionSnapshotImpl || collectSessionSnapshot;
|
||||||
|
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'dmux-tmux',
|
id: 'dmux-tmux',
|
||||||
@@ -66,7 +67,16 @@ function createDmuxTmuxAdapter(options = {}) {
|
|||||||
adapterId: 'dmux-tmux',
|
adapterId: 'dmux-tmux',
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
const snapshot = collectSessionSnapshotImpl(target, cwd);
|
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'
|
'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 [
|
return [
|
||||||
createClaudeHistoryAdapter(),
|
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
|
||||||
createDmuxTmuxAdapter()
|
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux'))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +76,7 @@ function normalizeStructuredTarget(target, context = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAdapterRegistry(options = {}) {
|
function createAdapterRegistry(options = {}) {
|
||||||
const adapters = options.adapters || createDefaultAdapters();
|
const adapters = options.adapters || createDefaultAdapters(options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adapters,
|
adapters,
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ const fs = require('fs');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
const {
|
||||||
|
getFallbackSessionRecordingPath,
|
||||||
|
persistCanonicalSnapshot
|
||||||
|
} = require('../../scripts/lib/session-adapters/canonical-session');
|
||||||
const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history');
|
const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history');
|
||||||
const { createDmuxTmuxAdapter } = require('../../scripts/lib/session-adapters/dmux-tmux');
|
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');
|
console.log('=== Testing session-adapters ===\n');
|
||||||
|
|
||||||
@@ -41,74 +48,233 @@ function withHome(homeDir, fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('dmux adapter normalizes orchestration snapshots into canonical form', () => {
|
test('dmux adapter normalizes orchestration snapshots into canonical form', () => {
|
||||||
const adapter = createDmuxTmuxAdapter({
|
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
||||||
collectSessionSnapshotImpl: () => ({
|
|
||||||
sessionName: 'workflow-visual-proof',
|
try {
|
||||||
coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof',
|
const adapter = createDmuxTmuxAdapter({
|
||||||
repoRoot: '/tmp/repo',
|
collectSessionSnapshotImpl: () => ({
|
||||||
targetType: 'plan',
|
sessionName: 'workflow-visual-proof',
|
||||||
sessionActive: true,
|
coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof',
|
||||||
paneCount: 1,
|
repoRoot: '/tmp/repo',
|
||||||
workerCount: 1,
|
targetType: 'plan',
|
||||||
workerStates: { running: 1 },
|
sessionActive: true,
|
||||||
panes: [{
|
paneCount: 1,
|
||||||
paneId: '%95',
|
workerCount: 1,
|
||||||
windowIndex: 1,
|
workerStates: { running: 1 },
|
||||||
paneIndex: 0,
|
panes: [{
|
||||||
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',
|
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.schemaVersion, 'ecc.session.v1');
|
||||||
assert.strictEqual(snapshot.adapterId, 'dmux-tmux');
|
assert.strictEqual(snapshot.adapterId, 'dmux-tmux');
|
||||||
assert.strictEqual(snapshot.session.id, 'workflow-visual-proof');
|
assert.strictEqual(snapshot.session.id, 'workflow-visual-proof');
|
||||||
assert.strictEqual(snapshot.session.kind, 'orchestrated');
|
assert.strictEqual(snapshot.session.kind, 'orchestrated');
|
||||||
assert.strictEqual(snapshot.session.sourceTarget.type, 'session');
|
assert.strictEqual(snapshot.session.state, 'active');
|
||||||
assert.strictEqual(snapshot.aggregates.workerCount, 1);
|
assert.strictEqual(snapshot.session.sourceTarget.type, 'session');
|
||||||
assert.strictEqual(snapshot.workers[0].runtime.kind, 'tmux-pane');
|
assert.strictEqual(snapshot.aggregates.workerCount, 1);
|
||||||
assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet');
|
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', () => {
|
test('claude-history adapter loads the latest recorded session', () => {
|
||||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-adapter-home-'));
|
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');
|
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -140,8 +306,10 @@ test('claude-history adapter loads the latest recorded session', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
withHome(homeDir, () => {
|
withHome(homeDir, () => {
|
||||||
const adapter = createClaudeHistoryAdapter();
|
const adapter = createClaudeHistoryAdapter({ recordingDir });
|
||||||
const snapshot = adapter.open('claude:latest').getSnapshot();
|
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.schemaVersion, 'ecc.session.v1');
|
||||||
assert.strictEqual(snapshot.adapterId, 'claude-history');
|
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].branch, 'feat/session-adapter');
|
||||||
assert.strictEqual(snapshot.workers[0].worktree, '/tmp/ecc-worktree');
|
assert.strictEqual(snapshot.workers[0].worktree, '/tmp/ecc-worktree');
|
||||||
assert.strictEqual(snapshot.workers[0].runtime.kind, 'claude-session');
|
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.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionPath);
|
||||||
assert.ok(snapshot.workers[0].outputs.summary.includes('Build snapshot prototype'));
|
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 {
|
} finally {
|
||||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
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', () => {
|
test('adapter registry lists adapter metadata and target types', () => {
|
||||||
const registry = createAdapterRegistry();
|
const registry = createAdapterRegistry();
|
||||||
const adapters = registry.listAdapters();
|
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 ===`);
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||||
if (failed > 0) process.exit(1);
|
if (failed > 0) process.exit(1);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
const { getFallbackSessionRecordingPath } = require('../../scripts/lib/session-adapters/canonical-session');
|
||||||
|
|
||||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js');
|
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js');
|
||||||
|
|
||||||
function run(args = [], options = {}) {
|
function run(args = [], options = {}) {
|
||||||
@@ -67,6 +69,7 @@ function runTests() {
|
|||||||
|
|
||||||
if (test('prints canonical JSON for claude history targets', () => {
|
if (test('prints canonical JSON for claude history targets', () => {
|
||||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
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');
|
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -77,16 +80,24 @@ function runTests() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = run(['claude:latest'], {
|
const result = run(['claude:latest'], {
|
||||||
env: { HOME: homeDir }
|
env: {
|
||||||
|
HOME: homeDir,
|
||||||
|
ECC_SESSION_RECORDING_DIR: recordingDir
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0, result.stderr);
|
assert.strictEqual(result.code, 0, result.stderr);
|
||||||
const payload = JSON.parse(result.stdout);
|
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.adapterId, 'claude-history');
|
||||||
assert.strictEqual(payload.session.kind, 'history');
|
assert.strictEqual(payload.session.kind, 'history');
|
||||||
assert.strictEqual(payload.workers[0].branch, 'feat/session-inspect');
|
assert.strictEqual(payload.workers[0].branch, 'feat/session-inspect');
|
||||||
|
assert.strictEqual(persisted.latest.adapterId, 'claude-history');
|
||||||
|
assert.strictEqual(persisted.history.length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user