Skip to content
4 changes: 2 additions & 2 deletions docs/architecture/canonical-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Workflow-tool spawns). Each line is a JSON object discriminated by
|---|---|
| `ID` | main sessions: `<session-id>` basename of the JSONL file (UUID), matching the `sessionId` field inside records. Subagents: the filename stem (`agent-<id>`) — their records carry the parent's `sessionId`, so the stem is the only stable identity |
| `Agent` | constant `"claude-code"` |
| `DeviceID` | constant `"local"` in cut 1 (fingerprint lands with sync) |
| `DeviceID` | `device.IDOnce()` |
| `ProjectPath` | first non-empty `cwd` encountered on any record |
| `StartedAt` | `min(timestamp)` over all records (RFC 3339, UTC after parse) |
| `LastActivityAt` | `max(timestamp)` over all records |
Expand Down Expand Up @@ -161,7 +161,7 @@ The full envelope reference is `docs/sources/codex.md`.
|---|---|
| `ID` | envelope: `session_meta.payload.id`. Legacy/missing meta: UUID suffix of the filename (`...-<UUID>.jsonl`) |
| `Agent` | constant `"codex"` |
| `DeviceID` | constant `"local"` in cut 2 |
| `DeviceID` | `device.IDOnce()` |
| `ProjectPath` | first non-empty of `session_meta.payload.cwd`, then `turn_context.payload.cwd` |
| `StartedAt` / `LastActivityAt` | `min`/`max` of every record's top-level `timestamp` (both envelope and legacy carry it) |
| `FirstPrompt` | first `response_item.payload.type=="message"` with `role=="user"` and non-empty `content[*].input_text` (legacy: first `{type:"message", role:"user"}`); whitespace-collapsed + truncated to 200 runes |
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ is the Connect-Go client in `internal/cli` that talks to `prosa-server`.
| `prosa devices …` | `internal/cli/devices.go` | `list`, `rename`, `revoke` |
| `prosa profiles …` | `internal/cli/profiles.go` | `list`, `add`, `remove`, `set-path` — manage per-agent import locations (local `profiles.json`) |
| `prosa schedule …` | `internal/cli/schedule_cmd.go` + `internal/cli/schedule/` | `install`, `status`, `uninstall` |
| `prosa prune` | `internal/cli/prune.go` | Delete local raw copies of old, server-confirmed sessions (`--older-than`, `--dry-run`, `--limit`) |
| `prosa prune` | `internal/cli/prune.go` | Delete local raw copies of old sessions a prior sync confirmed (`pushed_hash` equals `raw_hash`). `--older-than`, `--dry-run`, `--limit`. When none qualify, reports how many older sessions still lack that confirmation. |
| `prosa setup` | `internal/cli/setup.go` | Wizard wrapping login + schedule + first sync |

Each handler:
Expand Down Expand Up @@ -132,9 +132,11 @@ Each query funnels into a small set of functions:
| `WriteSession`, `LastHash` | `sync` (importer `Sink`) |
| `ListDevicesMap` | timeline column resolution |
| `Analytics*` | `analytics` |
| `RebindLocalSessions` | one-time migration helper during `setup` |
| `RebindLocalSessions` | `sync` and `prune`: move seed `local` sessions onto this machine's fingerprint |
| `RebindDevicesByMachineID` | `sync` and `prune`: collapse other device rows that share this machine id |
| `RecordPushed`, `RecordPushedBatch` | `sync` (push confirmation + manifest backfill) |
| `ListPruneCandidates`, `MarkPruned`, `ClearPruned` | `prune` |
| `CountOldUnconfirmed` | `prune`, when no candidate is confirmed |
| `PruneAdvisory` | `sync` (post-run Prune summary line) |

The CLI never builds SQL by hand. If a query doesn't exist, add a function
Expand Down
17 changes: 11 additions & 6 deletions docs/architecture/store.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,17 @@ down is for manual recovery.

| Column | Type | Notes |
| --- | --- | --- |
| `id` | TEXT PRIMARY KEY | Device fingerprint |
| `hostname` | TEXT | |
| `machine_id` | TEXT | OS-level machine ID |
| `id` | TEXT PRIMARY KEY | `hex(sha256(hostname + NUL + machine-id))[:16]` |
| `hostname` | TEXT | LocalHostName on macOS; system hostname elsewhere, with a trailing `.local` removed |
| `machine_id` | TEXT | IOPlatformUUID on macOS; `/etc/machine-id` on Linux |
| `friendly_name` | TEXT | Defaults to hostname; user-editable cross-device |
| `fingerprinted_at` | TEXT | RFC3339 of first registration |

Seeded with one `'local'` row at install. Replaced by the real
fingerprint on first successful `prosa sync` via `RebindLocalSessions`.
Seeded with one `local` row at install. `prosa sync` and `prosa prune`
upsert the current fingerprint. `RebindLocalSessions` moves
`device_id = 'local'` sessions onto it. `RebindDevicesByMachineID` moves
sessions off every other row with the same non-empty `machine_id` and
deletes those rows. Rows with an empty `machine_id` stay in place.

### `sessions`

Expand Down Expand Up @@ -251,10 +254,12 @@ Selected functions (full list in `internal/store/`):
| `RecordPushed(ctx, sessionID, hash, uri)` | Persist a server-confirmed push into `sync_state` |
| `RecordPushedBatch(ctx, stamps)` | Manifest-driven backfill of pushed state, keeping each entry's `last_synced_at` |
| `ListPruneCandidates(ctx, deviceID, before, limit)` | Pushed, unpruned sessions inactive since `before` |
| `CountOldUnconfirmed(ctx, deviceID, before)` | Older unpruned sessions whose `pushed_hash` is missing or differs from `raw_hash` |
| `MarkPruned(ctx, sessionID, rawHash)` / `ClearPruned(ctx, sessionID)` | Guarded prune flip and its revert |
| `PruneAdvisory(ctx, deviceID, before, pushedBefore)` | Count + bytes behind the sync summary's Prune line |
| `ListDevicesMap(ctx)` | `id → friendly_name` lookup |
| `RebindLocalSessions(ctx, deviceID)` | Migrate `local` seed device |
| `RebindLocalSessions(ctx, deviceID)` | Move `device_id = 'local'` sessions onto a fingerprint |
| `RebindDevicesByMachineID(ctx, device)` | Move sessions off other rows with the same machine id, then delete those rows |
| `Analytics*` | Per-report queries |

The `SessionFilter` type carries the parsed `--since/--until/--project/...`
Expand Down
24 changes: 22 additions & 2 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ its metadata, turns, FTS entries, and analytics locally; only raw reads
re-import of a changed source file rewrites the raw copy and un-prunes the
session.

`prosa prune` lists only sessions a prior sync confirmed: `pushed_hash`
equals the current `raw_hash`, the row is not pruned, and last activity is
older than the window. Older sessions that still lack that confirmation stay
on disk. Prune reports their count and names `prosa sync`, which records the
confirmation from the server manifest.

## Project identity

prosa picks a project for each session in this order:
Expand All @@ -101,9 +107,17 @@ meaningful.
A *device* is a single machine prosa has been set up on. Each device has a
fingerprint and a `friendly_name`.

Fingerprint = `hash(hostname + machine-id)`. Sources of the machine-id:
The fingerprint is `hex(sha256(hostname + NUL + machine-id))[:16]`.

On macOS the hostname is the LocalHostName (`scutil --get LocalHostName`,
trimmed). When that command fails or returns empty, prosa uses the system
hostname with a trailing `.local` removed. On Linux, and on every other
platform, the hostname is the system hostname (`os.Hostname()`) with a
trailing `.local` removed.

- **Linux**: `/etc/machine-id`.
Machine-id sources:

- **Linux**: `/etc/machine-id`, then `/var/lib/dbus/machine-id`.
- **macOS**: `IOPlatformUUID`.
- **Windows**: `MachineGuid` from the registry. (Not in MVP target
platforms.)
Expand All @@ -112,6 +126,12 @@ The `friendly_name` defaults to the hostname. You can rename via
`prosa devices rename <id|self> <name>` (cross-device only, talks to the
server).

`prosa sync` and `prosa prune` upsert this machine's device row. They move
sessions still stored on the seed `local` id onto the current fingerprint,
and they collapse other local device rows that share its machine-id:
sessions are reassigned to the current fingerprint and the extra rows are
deleted. Rows with an empty machine-id stay in place.

The CLI sends its device fingerprint as part of every authenticated request.
The server uses this to attribute pushed sessions to a device.

Expand Down
41 changes: 41 additions & 0 deletions internal/cli/device_bind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cli

import (
"context"
"fmt"
"log/slog"
"time"

"github.com/c3-oss/prosa/internal/device"
"github.com/c3-oss/prosa/internal/store"
)

// bindLocalDevice upserts this machine's fingerprint, moves sessions still
// tagged with the seed id onto it, and collapses other device rows that
// share its machine id. sync and prune both call it before they read by
// device id.
func bindLocalDevice(ctx context.Context, s *store.Store) (store.Device, error) {
dev := store.Device{
ID: device.IDOnce(),
Hostname: device.Hostname(),
MachineID: device.MachineID(),
FriendlyName: device.FriendlyName(),
FingerprintedAt: time.Now().UTC(),
}
if err := s.UpsertDevice(ctx, dev); err != nil {
return store.Device{}, fmt.Errorf("upsert device: %w", err)
}
if n, err := s.RebindLocalSessions(ctx, dev.ID); err != nil {
return store.Device{}, fmt.Errorf("rebind 'local' sessions to %s: %w", dev.ID, err)
} else if n > 0 {
slog.Info("rebound legacy 'local' sessions to fingerprint",
"device_id", dev.ID, "rows", n)
}
if n, err := s.RebindDevicesByMachineID(ctx, dev); err != nil {
return store.Device{}, fmt.Errorf("rebind devices by machine id: %w", err)
} else if n > 0 {
slog.Info("rebound sessions from stale device ids",
"device_id", dev.ID, "rows", n)
}
return dev, nil
}
21 changes: 18 additions & 3 deletions internal/cli/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (

"github.com/c3-oss/prosa/internal/cli/render"
"github.com/c3-oss/prosa/internal/cli/rpc"
"github.com/c3-oss/prosa/internal/device"
"github.com/c3-oss/prosa/internal/importers/importerutil"
"github.com/c3-oss/prosa/internal/paths"
"github.com/c3-oss/prosa/internal/rawlock"
Expand Down Expand Up @@ -75,6 +74,7 @@ type pruneJSONSummary struct {
Errors int `json:"errors"`
ReclaimedBytes int64 `json:"reclaimed_bytes"`
DryRun bool `json:"dry_run"`
Unconfirmed int `json:"unconfirmed,omitempty"`
}

func runPrune(cmd *cobra.Command, _ []string) error {
Expand Down Expand Up @@ -103,18 +103,33 @@ func runPrune(cmd *cobra.Command, _ []string) error {
}
defer func() { _ = s.Close() }()

dev, err := bindLocalDevice(ctx, s)
if err != nil {
return err
}

before := time.Now().UTC().Add(-olderThan)
// Limit is applied after the server confirms a row. A SQL limit would
// let unconfirmed old sessions consume the whole cap.
candidates, err := s.ListPruneCandidates(ctx, device.IDOnce(), before, 0)
candidates, err := s.ListPruneCandidates(ctx, dev.ID, before, 0)
if err != nil {
return fmt.Errorf("list prune candidates: %w", err)
}

enc := json.NewEncoder(os.Stdout)
if len(candidates) == 0 {
unconfirmed, err := s.CountOldUnconfirmed(ctx, dev.ID, before)
if err != nil {
return err
}
if g.JSON {
return enc.Encode(pruneJSONSummary{Type: "summary", DryRun: pruneDryRunFlag})
return enc.Encode(pruneJSONSummary{
Type: "summary", DryRun: pruneDryRunFlag, Unconfirmed: unconfirmed,
})
}
if unconfirmed > 0 {
fmt.Fprintf(os.Stderr, "Nothing to prune. Unconfirmed sessions older than the window: %d; run `prosa sync` first.\n", unconfirmed)
return nil
}
fmt.Fprintln(os.Stderr, "Nothing to prune.")
return nil
Expand Down
85 changes: 85 additions & 0 deletions internal/cli/prune_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,91 @@ func TestPruneDryRunConfirmsWithServer(t *testing.T) {
require.True(t, sawSkip)
}

func TestPruneDryRunFindsSessionsReboundFromStaleDevice(t *testing.T) {
const (
pinnedID = "aabbccddeeff0011"
pinnedMid = "machine-pinned"
staleID = "stale-dhcp"
)
restore := device.SetResolveForTest(pinnedID, "tbox", "tbox", pinnedMid)
t.Cleanup(restore)

s := openPruneCommandStore(t)
ctx := context.Background()
now := time.Now().UTC()
require.Equal(t, pinnedID, device.IDOnce())
require.NoError(t, s.UpsertDevice(ctx, store.Device{
ID: staleID,
Hostname: "192.168.0.19",
MachineID: pinnedMid,
FriendlyName: "192.168.0.19",
FingerprintedAt: now.Add(-time.Hour),
}))
rawPath, hash := seedCommandSession(t, s, "stale-sess", 60*24*time.Hour, []byte("raw-stale"))
_, err := s.DB().ExecContext(ctx, `UPDATE sessions SET device_id = ? WHERE id = ?`, staleID, "stale-sess")
require.NoError(t, err)

before := time.Now().UTC().Add(-30 * 24 * time.Hour)
got, err := s.ListPruneCandidates(ctx, device.IDOnce(), before, 0)
require.NoError(t, err)
require.Empty(t, got)

restorePruneHooks(t)
loadPruneManifest = func(context.Context, string, string) (map[string]serverManifestRow, error) {
return confirmedManifest("stale-sess", hash), nil
}

stdout, _ := captureStdoutStderr(t, func() {
cmd := newRootCmd()
cmd.SetArgs([]string{"--json", "prune", "--dry-run"})
require.NoError(t, cmd.Execute())
})
require.Contains(t, stdout, `"session_id":"stale-sess"`)
require.Contains(t, stdout, `"status":"would_prune"`)
require.FileExists(t, rawPath)

var deviceID string
require.NoError(t, s.DB().QueryRowContext(ctx,
`SELECT device_id FROM sessions WHERE id = 'stale-sess'`).Scan(&deviceID))
require.Equal(t, pinnedID, deviceID)
var staleRows int
require.NoError(t, s.DB().QueryRowContext(ctx,
`SELECT COUNT(*) FROM devices WHERE id = ?`, staleID).Scan(&staleRows))
require.Zero(t, staleRows)
}

func TestPruneNothingHintsUnconfirmed(t *testing.T) {
s := openPruneCommandStore(t)
ctx := context.Background()
rawPath, _ := seedCommandSession(t, s, "old-null", 60*24*time.Hour, []byte("raw-null"))
_, err := s.DB().ExecContext(ctx, `UPDATE sync_state SET pushed_hash = NULL WHERE session_id = ?`, "old-null")
require.NoError(t, err)
seedCommandSession(t, s, "old-diverged", 50*24*time.Hour, []byte("raw-diverged"))
_, err = s.DB().ExecContext(ctx, `UPDATE sync_state SET pushed_hash = ? WHERE session_id = ?`, "other-hash", "old-diverged")
require.NoError(t, err)
seedCommandSession(t, s, "recent-null", time.Hour, []byte("raw-recent"))
_, err = s.DB().ExecContext(ctx, `UPDATE sync_state SET pushed_hash = NULL WHERE session_id = ?`, "recent-null")
require.NoError(t, err)

restorePruneHooks(t)
_, stderr := captureStdoutStderr(t, func() {
cmd := newRootCmd()
cmd.SetArgs([]string{"prune", "--dry-run"})
require.NoError(t, cmd.Execute())
})
require.Contains(t, stderr, "Nothing to prune. Unconfirmed sessions older than the window: 2; run `prosa sync` first.")
require.FileExists(t, rawPath)

stdout, _ := captureStdoutStderr(t, func() {
cmd := newRootCmd()
cmd.SetArgs([]string{"--json", "prune", "--dry-run"})
require.NoError(t, cmd.Execute())
})
require.Contains(t, stdout, `"unconfirmed":2`)
require.Contains(t, stdout, `"dry_run":true`)
require.NotContains(t, stdout, `"status":"would_prune"`)
}

func TestPruneDryRunErrorsWhenServerUnreachable(t *testing.T) {
s := openPruneCommandStore(t)
rawPath, _ := seedCommandSession(t, s, "s1", 60*24*time.Hour, []byte("raw"))
Expand Down
21 changes: 3 additions & 18 deletions internal/cli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (

"github.com/spf13/cobra"

"github.com/c3-oss/prosa/internal/device"
"github.com/c3-oss/prosa/internal/importers/claudecode"
"github.com/c3-oss/prosa/internal/importers/codex"
"github.com/c3-oss/prosa/internal/importers/cursor"
Expand Down Expand Up @@ -88,23 +87,9 @@ func runSync(cmd *cobra.Command, _ []string) error {
}
defer func() { _ = s.Close() }()

// Migrate any rows inserted under the seed `'local'` device id (v1 bundle
// restore path) to the real fingerprint — those sessions came from this machine.
dev := store.Device{
ID: device.IDOnce(),
Hostname: device.Hostname(),
MachineID: device.MachineID(),
FriendlyName: device.FriendlyName(),
FingerprintedAt: time.Now().UTC(),
}
if err := s.UpsertDevice(ctx, dev); err != nil {
return fmt.Errorf("upsert device: %w", err)
}
if n, err := s.RebindLocalSessions(ctx, dev.ID); err != nil {
return fmt.Errorf("rebind 'local' sessions to %s: %w", dev.ID, err)
} else if n > 0 {
slog.Info("rebound legacy 'local' sessions to fingerprint",
"device_id", dev.ID, "rows", n)
dev, err := bindLocalDevice(ctx, s)
if err != nil {
return err
}

if err := backfillProjectIdentity(ctx, s); err != nil {
Expand Down
Loading
Loading