Add the SKILL.md design contract (persisted state is the truth) and a dedicated Resume correctness section: backend new/resume prompt mode, missing cold-start procedure in the skill, end-to-end verification gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
271 lines
16 KiB
Markdown
271 lines
16 KiB
Markdown
# Session Management — Design
|
||
|
||
**Date:** 2026-06-29
|
||
**Status:** Approved (design), pending implementation plan
|
||
**Layers:** frontend, backend (Fastify), harness (`tht` CLI)
|
||
|
||
## Problem
|
||
|
||
The session rail ([NavSessions.tsx](../../../frontend/src/shell/NavSessions.tsx)) is
|
||
today a flat list whose only interaction is **click = resume** (spawns a Pi process).
|
||
There is no way to read a past session, rename it, organize it, archive it, or delete
|
||
it. Inspired by Claude's desktop session context menu, we want a complete management
|
||
surface with five capabilities:
|
||
|
||
1. **Vista divisa** — a read-only side panel showing the session's stored content.
|
||
2. **Rinomina** — rename a session.
|
||
3. **Sposta nel gruppo** — organize sessions into groups.
|
||
4. **Archivia** — archive a session out of the active list (read-only retention).
|
||
5. **Elimina** — permanently delete a session.
|
||
|
||
## Key architectural insight (drives the whole design)
|
||
|
||
The workflow is **phase-based**. Each phase persists its own document into the session
|
||
directory; advancing to the next phase does **not** require replaying the prior
|
||
conversation. Therefore **the phase documents ARE the persistence** — we do **not**
|
||
need to store the verbatim chat.
|
||
|
||
This is the explicit design contract of the orchestrator
|
||
([SKILL.md](../../../harness/.pi/skills/tht-sessione/SKILL.md)): *"the persisted state
|
||
(ledger `review_decisions.jsonl` + artifacts) is the truth — what is not recorded did not
|
||
happen."* The documents are not meant to *contain* the chat; they are meant to make the
|
||
chat **reconstructable / unnecessary** — the documents are the *state*, the skill is the
|
||
*procedure*, and together they let a fresh model continue the task. Whether that holds for
|
||
a cold-start resume is a **verifiable prerequisite**, treated explicitly below.
|
||
|
||
This was the crux decision in brainstorming. It means:
|
||
|
||
- The "vista divisa" is a **read-only document viewer** driven by what's on disk, not a
|
||
chat replay. No new transcript-persistence layer, no correlation of Pi's internal
|
||
JSONL logs.
|
||
- "Resuming an interrupted session" = the normal chat flow re-entering at the **last
|
||
incomplete phase**; the earlier phases' documents are the context.
|
||
|
||
### What each phase persists (from [workflow.yaml](../../../harness/workflow.yaml))
|
||
|
||
| Phase | Document on disk |
|
||
|-------|------------------|
|
||
| (manifest, always) | `session_manifest.yaml` — original question, name, status, author, timestamps |
|
||
| F3 riscrittura | `question.md` — revised question + assumptions |
|
||
| F4 schema_linking | `schema_linking.json` |
|
||
| F6 cte | `cte_plan.json`, `cte_tests.json` |
|
||
| F7 sql_finale | `sql_final.sql` |
|
||
| finalize | `validation_report.md`, `evidence.json` |
|
||
| (decisions, always) | `review_decisions.jsonl` — decision ledger |
|
||
|
||
`tht session show --json` already returns the manifest **plus** the computed `phase`
|
||
(current phase, folded from the ledger by [phase.py](../../../harness/tht/phase.py))
|
||
and `has_schema_linking`.
|
||
|
||
## Decisions (from brainstorming)
|
||
|
||
- **Read panel content (option C):** a status header (`Fase N di 8 · <nome>` /
|
||
`Completata` / `Archiviata`) + document cards **in phase order** (only those that
|
||
exist) + a collapsible **Decisioni** block. No verbatim chat.
|
||
- **Resume rule:** `status == "finalized"` OR `archived == true` → **read-only**, never
|
||
resumable. Any other state (`open`, `closed`) → **resumable** into the last incomplete
|
||
phase.
|
||
- **Row click = open the read-only panel** (it no longer resumes). **Resume** is an
|
||
explicit **"Riprendi"** button shown in the panel header only when the session is
|
||
resumable.
|
||
- **Archive = filesystem flag.** A boolean `archived` field on the manifest. The active
|
||
rail filters out archived sessions; a separate **"Archivio"** view lists them and opens
|
||
the same read-only panel. (A flag, not a directory move, so id→path resolution
|
||
`sessions_root / session_id` and resume/show keep working unchanged.)
|
||
- **Delete = hard delete** of the session directory, behind a **confirmation dialog**.
|
||
Available from both the active list and the archive. No trash/soft-delete — the archive
|
||
is the "soft" tier.
|
||
- **Groups = manifest field (model A).** A nullable `group` string on the manifest. The
|
||
rail derives the group list from distinct values. "Sposta nel gruppo ›" lists existing
|
||
groups + "Nuovo gruppo…" (type a name). No separate registry, no colors/ordering, no
|
||
persistent empty groups.
|
||
- **Rename = manifest `name`** (field already exists). The rail shows `name` instead of
|
||
the question when present.
|
||
|
||
## Data model — manifest changes
|
||
|
||
In [`SessionManifest`](../../../harness/tht/session/models.py) add two fields
|
||
(`name` already exists):
|
||
|
||
```python
|
||
archived: bool = False
|
||
group: str | None = None
|
||
```
|
||
|
||
Both default such that existing manifests load unchanged. They are written by the new
|
||
mutating commands below and surfaced by `session list`/`session show`.
|
||
|
||
## Harness layer (`tht` CLI)
|
||
|
||
New/changed subcommands in
|
||
[session_cmd.py](../../../harness/tht/cli/session_cmd.py), with corresponding helpers in
|
||
[store.py](../../../harness/tht/session/store.py). All mutate the manifest via the
|
||
existing `touch_manifest`/`to_yaml` pattern and update `updated_at/updated_by`.
|
||
|
||
- `tht session set-name <id> --name <name>` → set `manifest.name`.
|
||
- `tht session set-group <id> --group <name>` → set `manifest.group`
|
||
(empty string clears it back to `None`).
|
||
- `tht session archive <id>` / `tht session unarchive <id>` → flip `archived`.
|
||
(Unarchive is included so an accidental archive is recoverable; it does **not** change
|
||
resumability — a finalized session stays read-only.)
|
||
- `tht session delete <id>` → remove the session directory (`shutil.rmtree`). Idempotent
|
||
error if absent.
|
||
- `tht session documents <id> --json` → **new read command** returning the ordered,
|
||
available documents for the panel:
|
||
```json
|
||
[ { "phase": "F3", "key": "revised_question", "title": "Domanda rivista",
|
||
"format": "markdown", "content": "..." }, ... ]
|
||
```
|
||
Document set: original question (from manifest), `question.md`, `schema_linking.json`,
|
||
`sql_final.sql`, `validation_report.md`, and the decision ledger. Only documents that
|
||
exist on disk are returned. The F6 CTE artifacts (`cte_plan.json`, `cte_tests.json`)
|
||
are **intentionally excluded** from the v1 panel as intermediate workflow state, not
|
||
reviewer-facing deliverables; add later if needed. The backend cannot read these files directly because the
|
||
`sessions` root lives in the **workspace** YAML (resolved by `tht` config, unknown to
|
||
Node) — so reading goes through `tht`.
|
||
- `session list --json` → each row additionally carries `archived`, `group`, `name`
|
||
(in addition to the existing `id/status/question/summary/created_at/updated_at/author`).
|
||
The active vs archive split and grouping are done in the frontend from these fields.
|
||
|
||
## Backend layer (Fastify)
|
||
|
||
New routes in [sessions.ts](../../../backend/src/routes/sessions.ts), each delegating to a
|
||
new `ThtRunner` method that shells the matching `tht` subcommand:
|
||
|
||
- `POST /sessions/:id/rename` `{ name }` → `tht session set-name`.
|
||
- `POST /sessions/:id/group` `{ group }` → `tht session set-group` (`group: ""` clears).
|
||
- `POST /sessions/:id/archive` / `POST /sessions/:id/unarchive`.
|
||
- `DELETE /sessions/:id` → `tht session delete`. If a Pi runtime is live for that id,
|
||
tear it down first (`mgr.teardown`).
|
||
- `GET /sessions/:id/documents` → `tht session documents --json`.
|
||
- `GET /sessions` → unchanged route; rows now include `archived/group/name`.
|
||
|
||
**Resume guard.** `POST /sessions/:id/resume` must refuse when the session is read-only.
|
||
Before spawning, read the manifest (`tht.sessionShow`); if `status == "finalized"` or
|
||
`archived`, return `409` with a clear message and do **not** spawn Pi.
|
||
|
||
**Resume must rebuild phase context.** This is the backend half of the resume-correctness
|
||
prerequisite: `spawnFor` gains a `new`/`resume` mode (new → `/nuova-domanda`, resume →
|
||
`/riprendi-sessione <id>`). See the dedicated **Resume correctness** section below for the
|
||
full picture (backend prompt, skill cold-start procedure, end-to-end verification).
|
||
|
||
## Frontend layer (React + base-ui + Tailwind + React Query)
|
||
|
||
### Types ([types.ts](../../../frontend/src/api/types.ts))
|
||
- Extend `SessionSummary` with `archived: boolean`, `group: string | null`,
|
||
`name: string | null`.
|
||
- Add `SessionDocument { phase: string; key: string; title: string; format: "markdown" | "sql" | "schema-linking" | "decisions" | "text"; content: string }`.
|
||
|
||
### API client ([sessions.ts](../../../frontend/src/api/sessions.ts))
|
||
- Add `renameSession(id, name)`, `setSessionGroup(id, group)`, `archiveSession(id)`,
|
||
`unarchiveSession(id)`, `deleteSession(id)`, `getSessionDocuments(id)`.
|
||
|
||
### NavSessions ([NavSessions.tsx](../../../frontend/src/shell/NavSessions.tsx))
|
||
- **Row click → open the read-only panel** (via a new `onOpenPanel(id)` prop), no longer
|
||
resumes.
|
||
- Render the active list (filter `!archived`) grouped under **collapsible group headers**
|
||
derived from distinct `group` values, plus a **"Senza gruppo"** section for `group == null`.
|
||
- Each row gets a **kebab (⋮) menu on hover** (base-ui Menu) mirroring the reference
|
||
image:
|
||
- **Vista divisa** → open the read-only panel
|
||
- **Rinomina** → rename dialog (sets `name`)
|
||
- **Sposta nel gruppo ›** → submenu of existing groups + "Nuovo gruppo…"
|
||
- **Archivia** → `archiveSession`
|
||
- **Elimina** (red) → confirmation dialog → `deleteSession`
|
||
- After each mutation, invalidate the `["sessions"]` query.
|
||
|
||
### Archive view
|
||
- An **"Archivio"** entry at the bottom of the rail. Selecting it shows archived sessions
|
||
(filter `archived`), each opening the **same** read-only panel. Archived rows offer
|
||
**Vista divisa**, **Ripristina** (unarchive), and **Elimina** — no resume.
|
||
|
||
### SessionDocumentsPanel (new, left drawer)
|
||
- New `frontend/src/shell/SessionDocumentsPanel.tsx`. A left column/drawer, opened by
|
||
`onOpenPanel`, closed with ✕.
|
||
- **Header:** name/question, status chip (`Fase N di 8 · <nome>` / `Completata` /
|
||
`Archiviata`), author, created/updated. A **"Riprendi"** button rendered only when
|
||
resumable; it triggers the existing resume flow and focuses the center chat.
|
||
- **Body:** one card per `SessionDocument`, in phase order, rendered by format reusing
|
||
existing viewers — `format: "sql"` → [SqlViewer](../../../frontend/src/viewers/SqlViewer.tsx),
|
||
`"schema-linking"` → [SchemaLinkingViewer](../../../frontend/src/viewers/SchemaLinkingViewer.tsx),
|
||
`"markdown"`/`"text"` → [MarkdownView](../../../frontend/src/viewers/MarkdownView.tsx).
|
||
- **Decisioni:** the `decisions` document rendered as a collapsible list at the bottom.
|
||
|
||
### AppShell ([AppShell.tsx](../../../frontend/src/shell/AppShell.tsx))
|
||
- Add the **left panel column** to the existing layout, giving three columns:
|
||
`[ documents panel (left) | chat (center) | sessions rail (right) ]`.
|
||
- Track two distinct ids: the **active chat session** (drives `useSessionStream` + the
|
||
center) and the **panel session** (read-only, drives the left drawer). Opening the panel
|
||
does not touch the active chat; "Riprendi" promotes the panel session to the active one.
|
||
|
||
## UX summary
|
||
|
||
- **Layout:** three columns, panel on the left as requested, rail stays on the right.
|
||
- **Context menu:** kebab ⋮ on hover, items as above; "Elimina" in red with confirm.
|
||
- **Rail organization:** collapsible group headers + "Senza gruppo"; "Archivio" as a
|
||
separate section, never mixed into the active list.
|
||
|
||
## Resume correctness — verifiable prerequisite
|
||
|
||
The whole "no chat persistence" premise rests on a fresh Pi process being able to
|
||
**re-enter the workflow at the last incomplete phase using only the persisted state**.
|
||
That is the design contract quoted above (ledger + artifacts are the truth; each phase's
|
||
prerequisites are prior artifacts; rollback `/torna N`, discipline 11, already resumes
|
||
"reviewing the existing artifacts" without replaying chat). The premise is sound — but the
|
||
**cold-start resume path** (new process, zero conversation) is today thin and unproven.
|
||
This feature treats resume correctness as a prerequisite and must close three gaps:
|
||
|
||
1. **Backend sends the wrong prompt.** `PiProcessManager.spawnFor`
|
||
([pi-process-manager.ts](../../../backend/src/pi/pi-process-manager.ts)) always sends
|
||
`/nuova-domanda "kickoff"`, and `resume` calls the same path — so a "resume" kicks off a
|
||
*new* question. `spawnFor` must take a `new`/`resume` mode: new → `/nuova-domanda`,
|
||
resume → `/riprendi-sessione <id>` (the prompt
|
||
[riprendi-sessione.md](../../../harness/.pi/prompts/riprendi-sessione.md)).
|
||
2. **No cold-start procedure in the skill.** The phases assume "you must already be in
|
||
Phase 1"; there is no "Phase 0 / Resume" step telling a fresh process to read
|
||
`tht session show <id>`, take the current phase N, load that phase's artifacts, and
|
||
resume its procedure. Add a short **Resume** section to
|
||
[SKILL.md](../../../harness/.pi/skills/tht-sessione/SKILL.md) (or confirm the model
|
||
reliably bootstraps from `tht session show` + artifact reads). The one-line resume
|
||
prompt is not sufficient on its own — intra-session rollback works only because the
|
||
conversation is still live; cold start has nothing to lean on.
|
||
3. **End-to-end verification.** A test that creates a session, drives it partway (e.g. into
|
||
F4), tears down the Pi process, resumes in a fresh process, and asserts it lands on the
|
||
**correct phase** with the prior artifacts available and presents the next gate — not a
|
||
new-question kickoff.
|
||
|
||
Until (1)–(3) pass, the phase-document model is a design assumption, not a proven fact.
|
||
The read panel, rename, group, archive, and delete work regardless of resume; but the
|
||
**"Riprendi"** action is only trustworthy once resume correctness is verified, so it must
|
||
not ship as "working" before this gate is green.
|
||
|
||
## Testing approach (TDD per repo convention)
|
||
|
||
- **Harness (pytest):** new `store.py` helpers (set-name/group, archive/unarchive,
|
||
delete) and the `session documents --json` shape; manifest round-trips with the new
|
||
fields; `session list --json` includes them.
|
||
- **Backend (vitest):** each new route maps to the right `tht` invocation (injected
|
||
`ThtRunner`/spawn double); resume guard returns 409 for finalized/archived; `spawnFor`
|
||
sends `/riprendi-sessione` in resume mode and `/nuova-domanda` in new mode.
|
||
- **Frontend (vitest + RTL + MSW):** kebab menu actions call the right endpoints and
|
||
invalidate the query; grouping/archived filtering renders correctly; the panel renders
|
||
documents via the right viewers and shows/hides "Riprendi" by resumability;
|
||
delete-confirm flow.
|
||
- **Resume correctness (end-to-end):** the verification described in the *Resume
|
||
correctness* section — partway session → teardown → fresh-process resume → lands on the
|
||
correct phase with prior artifacts, not a new-question kickoff. This gate must be green
|
||
before "Riprendi" is considered working.
|
||
|
||
## Out of scope / deferred (with triggers)
|
||
|
||
- **Application database / pgvector.** Not introduced. The only DB is the client's
|
||
read-only DWH; an app DB is new infrastructure not justified by store/browse/view/
|
||
no-resume. Revisit when the archive must outlive the workspace filesystem (→ app
|
||
Postgres + `archived_sessions`, full-text via `tsvector`) or when "reuse a semantically
|
||
similar past session" becomes a goal (→ add a `vector` column + pgvector). Both are
|
||
non-breaking additions later.
|
||
- **Keyboard shortcuts** (R/A/D/F in the reference image) — polish, addable later.
|
||
- **Group colors, ordering, persistent empty groups** — the model-B registry, only if
|
||
needed.
|
||
- **Verbatim chat persistence** — explicitly unnecessary given the phase-document model.
|