Skip to main content

forge_workspace/store/
sessions.rs

1//! Session identity on the redb `sessions` table.
2//!
3//! One row per `(org, project, label)`, holding the `claude` session id
4//! forge spawns or resumes that session under. The label carries the
5//! role: [`LEAD_LABEL`] for a project's lead, the worker's own label
6//! otherwise, so no separate kind column is needed. The value is stored
7//! as serde-json and the worker-only fields are absent on a lead's row.
8//!
9//! A worker row written by [`migrate_from_dynamic_workers`] gets its
10//! spawn args from `dynamic_workers`, which never stored an id. A worker
11//! that has no row yet therefore starts fresh under a newly minted id; one
12//! that already has a row keeps the occupant that row names.
13
14use anyhow::Context;
15use redb::{ReadableTable, TableDefinition};
16use serde::{Deserialize, Serialize};
17
18use super::Db;
19use super::dynamic_workers;
20
21const SESSIONS: TableDefinition<(&str, &str, &str), &[u8]> = TableDefinition::new("sessions");
22
23/// The label a project's lead is stored under. A worker stores its own
24/// label, so the label alone says which role holds the row.
25pub const LEAD_LABEL: &str = "lead";
26
27/// What a seat was doing when forge last had a say: whether it is open at
28/// all, and whether a turn was in flight. The boot wave reads it to reopen
29/// the seats that were open and kick only the ones whose turn was cut off.
30///
31/// A worker is never `closed` by this model; its row reading `closed` or
32/// absent is treated as `idle`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum SeatState {
36    /// The seat is not open: the close door took it, and a bare boot
37    /// leaves it shut. Naming the project opens it again.
38    Closed,
39    /// Open with a turn in flight. A boot resumes this seat and hands it
40    /// the restart note.
41    InTurn,
42    /// Open with no turn in flight. A boot resumes it and hands it nothing.
43    Idle,
44}
45
46impl SeatState {
47    /// Whether a bare boot reopens this seat.
48    pub fn is_open(self) -> bool {
49        matches!(self, Self::Idle | Self::InTurn)
50    }
51}
52
53/// One session's identity, plus the worker fields a re-spawn needs.
54/// `session_id` is absent on a worker row the sweep creates, because
55/// `dynamic_workers` never stored one; every other field is worker-only,
56/// so a lead's row carries none of them.
57///
58/// The identity is the row's key: the three fields are here because a
59/// caller building a record has to name the row it writes, but they are
60/// not stored in the body - every reader fills them from the key, and a
61/// body that carried them anyway (written before this change) is ignored.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct SessionRecord {
64    #[serde(default, skip_serializing)]
65    pub org: String,
66    #[serde(default, skip_serializing)]
67    pub project: String,
68    #[serde(default, skip_serializing)]
69    pub label: String,
70    #[serde(default)]
71    pub session_id: Option<String>,
72    #[serde(default)]
73    pub charter: Option<String>,
74    #[serde(default)]
75    pub kick: Option<String>,
76    #[serde(default)]
77    pub resume_kick: Option<String>,
78    #[serde(default)]
79    pub interactive: Option<bool>,
80    /// Whether the seat is open and whether a turn was in flight. `None`
81    /// is a row written before the field existed; the boot migration seeds
82    /// those `Idle` once, so from then on the state is explicit.
83    #[serde(default)]
84    pub seat_state: Option<SeatState>,
85    /// Whether the worker runs in a worktree, which fixes the directory
86    /// its session starts in: `worker_tag_dir` reads it to compose the
87    /// path. Absent on a row written before the field existed, and the
88    /// spawn probes for it then - the row is what lets the launchpad and
89    /// the boot wave ask the same question without a git probe of their
90    /// own.
91    #[serde(default)]
92    pub is_git_repo: Option<bool>,
93    /// The MCP families the spawn's allowlist restricted this worker to,
94    /// canonicalised by the tool that wrote it (wire order, no
95    /// duplicates - both `agents__spawn` and `agents__update` validate).
96    /// `None` means every family, so a row written before the field
97    /// existed keeps its full surface.
98    #[serde(default)]
99    pub mcp_families: Option<Vec<String>>,
100    /// The tree the worker runs in, when the spawn pointed it somewhere
101    /// other than the project's own directory: the resolved repo root,
102    /// which is the only handle on where its JSONL lives once the session
103    /// is not live. `None` is the project root, so a row written before
104    /// the field existed reads as today.
105    #[serde(default)]
106    pub cwd: Option<String>,
107}
108
109/// A configured project as the migration needs it: its catalog key, and
110/// the org and name its session rows are keyed by. `dynamic_workers` is
111/// keyed by the catalog key alone, so the caller supplies the mapping.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ProjectIdentity {
114    pub key: String,
115    pub org: String,
116    pub name: String,
117}
118
119/// The record for `(org, project, label)`, if the store holds one.
120pub fn get(
121    db: &Db,
122    org: &str,
123    project: &str,
124    label: &str,
125) -> anyhow::Result<Option<SessionRecord>> {
126    let txn = db.database().begin_read()?;
127    let table = match txn.open_table(SESSIONS) {
128        Ok(t) => t,
129        // A fresh database has no table until the first write; an absent
130        // table is an empty store, not an error.
131        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
132        Err(e) => return Err(e.into()),
133    };
134    let Some(value) = table.get((org, project, label))? else {
135        return Ok(None);
136    };
137    decode(org, project, label, value.value()).map(Some)
138}
139
140/// Write `record`, replacing any prior row for the same identity.
141pub fn put(db: &Db, record: &SessionRecord) -> anyhow::Result<()> {
142    let value = serde_json::to_vec(record).context("serialize session record")?;
143    let txn = db.database().begin_write()?;
144    {
145        let mut table = txn.open_table(SESSIONS)?;
146        table.insert(
147            (record.org.as_str(), record.project.as_str(), record.label.as_str()),
148            value.as_slice(),
149        )?;
150    }
151    txn.commit()?;
152    Ok(())
153}
154
155/// The bytes a row holds, so a test can assert what the body carries.
156/// Test-only: every production read goes through [`get`] / [`list_all`].
157#[cfg(test)]
158pub(crate) fn raw_for_test(
159    db: &Db,
160    org: &str,
161    project: &str,
162    label: &str,
163) -> anyhow::Result<Vec<u8>> {
164    let txn = db.database().begin_read()?;
165    let table = txn.open_table(SESSIONS)?;
166    let value = table.get((org, project, label))?.expect("the row is there");
167    Ok(value.value().to_vec())
168}
169
170/// Write raw bytes at a row, so a test can plant a record that will not
171/// decode. Test-only: every production write goes through [`put`].
172#[cfg(test)]
173pub(crate) fn put_raw_for_test(
174    db: &Db,
175    org: &str,
176    project: &str,
177    label: &str,
178    value: &[u8],
179) -> anyhow::Result<()> {
180    let txn = db.database().begin_write()?;
181    {
182        let mut table = txn.open_table(SESSIONS)?;
183        table.insert((org, project, label), value)?;
184    }
185    txn.commit()?;
186    Ok(())
187}
188
189/// What a sweep of `dynamic_workers` accounted for. The retired table is
190/// only safe to drop when [`Self::drained`], and everything else holds it
191/// for a later boot.
192pub struct SweepOutcome {
193    /// Rows copied into `sessions` and removed from the retired table.
194    pub moved: usize,
195    /// Rows left where they are: no configured project carries their
196    /// key, so they cannot be keyed by `(org, project, label)`.
197    pub unkeyable: usize,
198    /// Rows the retired table held when the sweep looked.
199    pub rows: usize,
200}
201
202impl SweepOutcome {
203    /// Nothing is left in the retired table: every row it held was
204    /// copied, so dropping it loses nothing. An unkeyable row counts as
205    /// not drained by construction - it is in `rows` and not in `moved` -
206    /// and a later boot retries it, which is what the hold is for.
207    pub fn drained(&self) -> bool {
208        self.moved == self.rows
209    }
210}
211
212/// Move any `dynamic_workers` rows onto `sessions`, and take each one out
213/// of the retired table as it lands.
214///
215/// A worker's persisted row carries no id, so the session it names starts
216/// fresh under a newly minted one rather than resuming. An existing
217/// `sessions` row for the same `(org, project, label)` keeps its id: this
218/// merges the spawn args onto it, which is the only place they exist for a
219/// worker spawned by the release that wrote `sessions` ids and
220/// `dynamic_workers` args.
221///
222/// A worker whose project the config no longer names cannot be keyed by
223/// `(org, project, label)`: it is left where it is, warned rather than
224/// dropped silently, and it holds the table for a later boot.
225///
226/// Both halves are idempotent, so a boot interrupted between the copy and
227/// the removal simply repeats the copy.
228pub fn migrate_from_dynamic_workers(
229    db: &Db,
230    projects: &[ProjectIdentity],
231) -> anyhow::Result<SweepOutcome> {
232    // What the table HOLDS, not what decoded. An entry whose value will
233    // not decode is skipped by the loop below and can never be moved, so
234    // counting the decoded rows would call a table with an unreadable
235    // entry drained and let the caller drop it.
236    let rows = dynamic_workers::count(db)?;
237    let workers = dynamic_workers::list_all(db)?;
238    let mut moved = 0;
239    let mut unkeyable = 0;
240    for worker in &workers {
241        let Some(project) = projects.iter().find(|p| p.key == worker.project_key) else {
242            tracing::warn!(
243                target: "forge_workspace::store::sessions",
244                project_key = %worker.project_key,
245                label = %worker.label,
246                "no configured project for this worker's key; leaving its row in \
247                 dynamic_workers",
248            );
249            unkeyable += 1;
250            continue;
251        };
252        let fields = SessionRecord {
253            org: project.org.clone(),
254            project: project.name.clone(),
255            label: worker.label.clone(),
256            session_id: None,
257            charter: Some(worker.charter.clone()),
258            kick: worker.kick.clone(),
259            resume_kick: worker.resume_kick.clone(),
260            interactive: Some(worker.interactive),
261            // The seat-state migration in the same boot seeds it; a newly
262            // moved row is idle until its worker's first turn.
263            seat_state: None,
264            is_git_repo: None,
265            // Nor a family selection; absent means every family.
266            mcp_families: None,
267            // Nor a tree of its own: the retired table never held one.
268            cwd: None,
269        };
270        // An existing row is the id-bearing one: merge onto it so the
271        // occupant it names survives. `update` leaves an absent field at
272        // its stored value, and it reports whether there was a row to
273        // merge onto.
274        if !update(db, &fields)? {
275            put(db, &fields)?;
276        }
277        dynamic_workers::delete(db, &worker.project_key, &worker.label)?;
278        moved += 1;
279    }
280    if moved > 0 {
281        tracing::info!(
282            target: "forge_workspace::store::sessions",
283            rows = moved,
284            "moved persisted workers onto the sessions table and out of the retired one",
285        );
286    }
287    Ok(SweepOutcome { moved, unkeyable, rows })
288}
289
290/// Every row for `(org, project)`, in label order. What a project's lead
291/// reads to re-spawn its persisted workers.
292pub fn list_for_project(db: &Db, org: &str, project: &str) -> anyhow::Result<Vec<SessionRecord>> {
293    Ok(list_all(db)?.into_iter().filter(|row| row.org == org && row.project == project).collect())
294}
295
296/// One persisted row, without its body: what a caller needs to decide
297/// whether that row's worker can still start.
298pub struct WorkerRowIndex {
299    pub org: String,
300    pub project: String,
301    pub label: String,
302    pub session_id: Option<String>,
303    pub is_git_repo: Option<bool>,
304    /// The tree the row records, `None` for the project's own directory.
305    /// Read here because the launchpad, the boot wave and the cron wake
306    /// all compose the directory a row would start in, and the rule for
307    /// that composition is one.
308    pub cwd: Option<String>,
309}
310
311/// What [`WorkerRowIndex`] reads out of a row's body. Deserialising into
312/// this rather than [`SessionRecord`] is the point: serde skips what it is
313/// not asked for, so the charter - kilobytes on a project's lead row - is
314/// never allocated. The identity is not here because it is not in the
315/// body: the key carries it, and this reader takes it from there.
316///
317/// A body that is not a record at all fails here exactly as it fails
318/// [`SessionRecord`], so the two readers agree on every row a running
319/// forge writes. They part company only for a body whose identity fields
320/// are the wrong type, which this reader no longer parses: a corrupted
321/// store rather than a shape forge produces.
322#[derive(Deserialize)]
323struct RowStart {
324    session_id: Option<String>,
325    is_git_repo: Option<bool>,
326    #[serde(default)]
327    cwd: Option<String>,
328}
329
330/// Every row's identity plus the fields that say whether its worker can
331/// still start, in key order.
332///
333/// A caller needs this rather than `(org, project, label)` alone because
334/// the launchpad decides a row is still real from its recorded gitness
335/// and its stored id, and it needs [`list_all`] rather than this only if
336/// it also wants the charter.
337pub fn worker_row_index(db: &Db) -> anyhow::Result<Vec<WorkerRowIndex>> {
338    let txn = db.database().begin_read()?;
339    let table = match txn.open_table(SESSIONS) {
340        Ok(t) => t,
341        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
342        Err(e) => return Err(e.into()),
343    };
344    let mut out = Vec::new();
345    for entry in table.iter()? {
346        let (key, value) = entry?;
347        // A body that will not decode is not a row to offer. `list_all`
348        // skips the same rows, and the boot wave reads through it, so
349        // reporting one here would draw a worker the wave never starts.
350        // The loss is reported by `list_all`'s warn, on the wave's read.
351        let Ok(start) = serde_json::from_slice::<RowStart>(value.value()) else {
352            continue;
353        };
354        let (org, project, label) = key.value();
355        out.push(WorkerRowIndex {
356            org: org.to_owned(),
357            project: project.to_owned(),
358            label: label.to_owned(),
359            // An empty string is absence, not an id - see `decode`.
360            session_id: start.session_id.filter(|id| !id.is_empty()),
361            is_git_repo: start.is_git_repo,
362            cwd: start.cwd,
363        });
364    }
365    Ok(out)
366}
367
368/// Every session row, in key order.
369pub fn list_all(db: &Db) -> anyhow::Result<Vec<SessionRecord>> {
370    let txn = db.database().begin_read()?;
371    let table = match txn.open_table(SESSIONS) {
372        Ok(t) => t,
373        // A fresh database has no table until the first write; an absent
374        // table is an empty store, not an error.
375        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
376        Err(e) => return Err(e.into()),
377    };
378    let mut out = Vec::new();
379    for entry in table.iter()? {
380        let (key, value) = entry?;
381        let (row_org, row_project, row_label) = key.value();
382        match decode(row_org, row_project, row_label, value.value()) {
383            Ok(row) => out.push(row),
384            // One undecodable row (schema drift, a corrupt blob) must not
385            // hide the rest. The key stays readable when the value does
386            // not, so name which session lost its row.
387            Err(error) => {
388                tracing::warn!(
389                    target: "forge_workspace::store::sessions",
390                    org = %row_org,
391                    project = %row_project,
392                    label = %row_label,
393                    %error,
394                    "skipping session row that failed to decode",
395                );
396            }
397        }
398    }
399    Ok(out)
400}
401
402/// Merge `fields` onto the row at `(org, project, label)`, leaving an
403/// absent field at its stored value. Returns whether a row existed; this
404/// never creates one, because the row is what makes a worker re-spawn on
405/// the next lead connect.
406pub fn update(db: &Db, fields: &SessionRecord) -> anyhow::Result<bool> {
407    let Some(mut row) = get(db, &fields.org, &fields.project, &fields.label)? else {
408        return Ok(false);
409    };
410    // `Option::clone_from` overwrites, so each guard is what keeps an
411    // absent field at its stored value.
412    if fields.session_id.is_some() {
413        row.session_id.clone_from(&fields.session_id);
414    }
415    if fields.charter.is_some() {
416        row.charter.clone_from(&fields.charter);
417    }
418    if fields.kick.is_some() {
419        row.kick.clone_from(&fields.kick);
420    }
421    if fields.resume_kick.is_some() {
422        row.resume_kick.clone_from(&fields.resume_kick);
423    }
424    if fields.interactive.is_some() {
425        row.interactive = fields.interactive;
426    }
427    if fields.seat_state.is_some() {
428        row.seat_state = fields.seat_state;
429    }
430    if fields.is_git_repo.is_some() {
431        row.is_git_repo = fields.is_git_repo;
432    }
433    // An empty list is the reset: it means every family, which the row
434    // stores as absence.
435    if let Some(families) = &fields.mcp_families {
436        row.mcp_families = if families.is_empty() { None } else { Some(families.clone()) };
437    }
438    // `cwd` has no arm on purpose: a worker's tree is fixed at spawn, so
439    // nothing that merges onto a row moves it.
440    put(db, &row)?;
441    Ok(true)
442}
443
444/// Move the row at `(org, project, label)` to `state`, keeping every other
445/// field.
446///
447/// **`Closed` is authoritative.** Unless `reopen` states that an opening
448/// door asked for the seat, a write leaves a closed row closed: the task a
449/// close tore down can still drain a queued `/resume` or translate a
450/// `Result`, and one of those writes landing would reopen a project the
451/// user shut. Returns whether a row existed; a row that is not there is
452/// not created, because the state describes an occupant the row names.
453pub fn set_seat_state(
454    db: &Db,
455    org: &str,
456    project: &str,
457    label: &str,
458    state: SeatState,
459    reopen: bool,
460) -> anyhow::Result<bool> {
461    let Some(mut record) = get(db, org, project, label)? else { return Ok(false) };
462    if record.seat_state == Some(state) {
463        return Ok(true);
464    }
465    if !reopen && record.seat_state == Some(SeatState::Closed) {
466        return Ok(true);
467    }
468    record.seat_state = Some(state);
469    put(db, &record)?;
470    Ok(true)
471}
472
473/// Put a row's seat state back to what it held before a refused spawn,
474/// but only while the row still holds `expected` - the state that spawn
475/// wrote. A newer decision (a close landing mid-spawn) stands: the
476/// restore undoes the spawn's own write, never the reader's.
477pub fn restore_seat_state(
478    db: &Db,
479    org: &str,
480    project: &str,
481    label: &str,
482    state: Option<SeatState>,
483    expected: SeatState,
484) -> anyhow::Result<bool> {
485    let Some(mut record) = get(db, org, project, label)? else { return Ok(false) };
486    if record.seat_state != Some(expected) || record.seat_state == state {
487        return Ok(false);
488    }
489    record.seat_state = state;
490    put(db, &record)?;
491    Ok(true)
492}
493
494/// The state the row at `(org, project, label)` holds, or `None` when the
495/// row is absent or was written before the field existed.
496pub fn seat_state(
497    db: &Db,
498    org: &str,
499    project: &str,
500    label: &str,
501) -> anyhow::Result<Option<SeatState>> {
502    Ok(get(db, org, project, label)?.and_then(|row| row.seat_state))
503}
504
505/// Seed every row written before `seat_state` existed with `Idle`, so the
506/// first boot on this build reads an explicit state instead of absence.
507/// Returns how many rows it seeded; a second run writes nothing.
508pub fn seed_seat_state(db: &Db) -> anyhow::Result<usize> {
509    let mut seeded = 0;
510    for mut row in list_all(db)? {
511        if row.seat_state.is_some() {
512            continue;
513        }
514        row.seat_state = Some(SeatState::Idle);
515        put(db, &row)?;
516        seeded += 1;
517    }
518    Ok(seeded)
519}
520
521/// Drop the id a row pinned, keeping everything else it carries.
522pub fn clear_session_id(db: &Db, org: &str, project: &str, label: &str) -> anyhow::Result<bool> {
523    let Some(mut record) = get(db, org, project, label)? else { return Ok(false) };
524    record.session_id = None;
525    put(db, &record)?;
526    Ok(true)
527}
528
529/// Delete the row at `(org, project, label)`. Returns whether one
530/// existed. This is what stops a despawned worker coming back: the row is
531/// the only thing a boot re-spawns from.
532pub fn delete(db: &Db, org: &str, project: &str, label: &str) -> anyhow::Result<bool> {
533    let txn = db.database().begin_write()?;
534    let existed = {
535        let mut table = txn.open_table(SESSIONS)?;
536        table.remove((org, project, label))?.is_some()
537    };
538    txn.commit()?;
539    Ok(existed)
540}
541
542fn decode(org: &str, project: &str, label: &str, value: &[u8]) -> anyhow::Result<SessionRecord> {
543    let mut record: SessionRecord =
544        serde_json::from_slice(value).context("decode session record")?;
545    // The key is the single source of the identity, so a body written
546    // before the identity left it cannot place its row anywhere else.
547    org.clone_into(&mut record.org);
548    project.clone_into(&mut record.project);
549    label.clone_into(&mut record.label);
550    // An empty string is absence, not an id: the bridge's id slot starts
551    // empty, so a blank must never read back as a session to resume.
552    if record.session_id.as_deref() == Some("") {
553        record.session_id = None;
554    }
555    Ok(record)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use tempfile::tempdir;
562
563    fn worker(project: &str, label: &str, interactive: bool) -> dynamic_workers::DynamicWorker {
564        dynamic_workers::DynamicWorker {
565            project_key: project.to_owned(),
566            label: label.to_owned(),
567            charter: format!("charter for {label}"),
568            kick: Some(format!("kick for {label}")),
569            resume_kick: Some(format!("resume kick for {label}")),
570            interactive,
571        }
572    }
573
574    fn project(key: &str, org: &str, name: &str) -> ProjectIdentity {
575        ProjectIdentity { key: key.to_owned(), org: org.to_owned(), name: name.to_owned() }
576    }
577
578    fn record(org: &str, project: &str, label: &str, session_id: Option<&str>) -> SessionRecord {
579        SessionRecord {
580            org: org.to_owned(),
581            project: project.to_owned(),
582            label: label.to_owned(),
583            session_id: session_id.map(str::to_owned),
584            charter: None,
585            kick: None,
586            resume_kick: None,
587            interactive: None,
588            seat_state: None,
589            is_git_repo: None,
590            mcp_families: None,
591            cwd: None,
592        }
593    }
594
595    /// A record carrying only the fields an `update` supplies; the rest
596    /// stay absent, which is what makes them keep their stored value.
597    fn record_fields(
598        org: &str,
599        project: &str,
600        label: &str,
601        session_id: Option<&str>,
602        kick: Option<&str>,
603    ) -> SessionRecord {
604        SessionRecord { kick: kick.map(str::to_owned), ..record(org, project, label, session_id) }
605    }
606
607    /// The family selection survives the store: written as spawned, read
608    /// back whole; a row written before the field existed decodes as
609    /// absent, which resolves to every family.
610    #[test]
611    fn the_family_selection_round_trips_and_old_rows_stay_absent() {
612        let dir = tempdir().expect("tempdir");
613        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
614
615        let mut row = record("Personal", "forge", "steward", None);
616        row.mcp_families = Some(vec!["slack".to_owned(), "tasks".to_owned()]);
617        put(&db, &row).expect("write");
618
619        let read = get(&db, "Personal", "forge", "steward").expect("read").expect("row");
620        assert_eq!(
621            read.mcp_families,
622            Some(vec!["slack".to_owned(), "tasks".to_owned()]),
623            "the stored selection reads back whole"
624        );
625
626        let legacy = br#"{"session_id":null}"#;
627        let decoded =
628            decode("Personal", "forge", "old", legacy).expect("a legacy body still decodes");
629        assert_eq!(
630            decoded.mcp_families, None,
631            "an absent field stays absent, never an invented set"
632        );
633    }
634
635    /// The worker's own tree survives the store: written as spawned, read
636    /// back whole; a row written before the field existed decodes as
637    /// absent, which is the project's own directory.
638    #[test]
639    fn the_worker_cwd_round_trips_and_older_rows_stay_absent() {
640        let dir = tempdir().expect("tempdir");
641        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
642
643        let mut row = record("Personal", "forge", "nazar-hand", None);
644        row.cwd = Some("/Users/someone/Projects/nazar".to_owned());
645        put(&db, &row).expect("write");
646
647        let read = get(&db, "Personal", "forge", "nazar-hand").expect("read").expect("row");
648        assert_eq!(
649            read.cwd,
650            Some("/Users/someone/Projects/nazar".to_owned()),
651            "the stored tree reads back whole"
652        );
653
654        let legacy = br#"{"session_id":null}"#;
655        let decoded =
656            decode("Personal", "forge", "old", legacy).expect("a legacy body still decodes");
657        assert_eq!(decoded.cwd, None, "an absent tree stays absent, never an invented one");
658    }
659
660    /// The launchpad's row read answers whether the worker can start, and
661    /// the tree it would start in is part of that: the projection carries
662    /// the recorded cwd beside the gitness.
663    #[test]
664    fn the_row_index_carries_the_recorded_cwd() {
665        let dir = tempdir().expect("tempdir");
666        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
667
668        let mut row = record("Personal", "forge", "nazar-hand", Some("id-1"));
669        row.cwd = Some("/Users/someone/Projects/nazar".to_owned());
670        row.is_git_repo = Some(true);
671        put(&db, &row).expect("write");
672
673        let index = worker_row_index(&db).expect("read");
674        let entry = index.iter().find(|entry| entry.label == "nazar-hand").expect("row");
675        assert_eq!(
676            entry.cwd.as_deref(),
677            Some("/Users/someone/Projects/nazar"),
678            "the launchpad composes the row's directory from this"
679        );
680    }
681
682    /// The seat's state survives the store with its own wire spelling, and
683    /// a row written before the field existed decodes as absent rather
684    /// than as an invented state.
685    #[test]
686    fn the_seat_state_round_trips_and_old_rows_stay_absent() {
687        let dir = tempdir().expect("tempdir");
688        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
689
690        let mut row = record("Personal", "forge", "lead", Some("id-1"));
691        row.seat_state = Some(SeatState::InTurn);
692        put(&db, &row).expect("write");
693
694        let read = get(&db, "Personal", "forge", "lead").expect("read").expect("row");
695        assert_eq!(read.seat_state, Some(SeatState::InTurn), "the stored state reads back whole");
696        let body: serde_json::Value =
697            serde_json::from_slice(&raw_for_test(&db, "Personal", "forge", "lead").expect("raw"))
698                .expect("the body is json");
699        assert_eq!(
700            body.get("seat_state").and_then(serde_json::Value::as_str),
701            Some("in_turn"),
702            "the wire spelling is the state's own name: {body}",
703        );
704
705        let legacy = br#"{"session_id":null}"#;
706        let decoded =
707            decode("Personal", "forge", "old", legacy).expect("a legacy body still decodes");
708        assert_eq!(
709            decoded.seat_state, None,
710            "an absent state stays absent until the migration seeds it"
711        );
712    }
713
714    /// Only the state moves: a write that rebuilt the row from a partial
715    /// record would drop the id, the charter and the kick, and the next
716    /// boot would start a fresh session for a seat that had one.
717    #[test]
718    fn setting_the_seat_state_keeps_the_rows_other_fields() {
719        let dir = tempdir().expect("tempdir");
720        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
721        let mut row = record("Personal", "forge", "steward", Some("id-1"));
722        row.charter = Some("charter".to_owned());
723        row.kick = Some("kick".to_owned());
724        put(&db, &row).expect("write");
725
726        assert!(
727            set_seat_state(&db, "Personal", "forge", "steward", SeatState::Closed, false)
728                .expect("set"),
729            "an existing row takes the state",
730        );
731        let read = get(&db, "Personal", "forge", "steward").expect("read").expect("row");
732        assert_eq!(read.seat_state, Some(SeatState::Closed));
733        assert_eq!(read.session_id.as_deref(), Some("id-1"), "the occupant survives");
734        assert_eq!(read.charter.as_deref(), Some("charter"), "and the charter");
735        assert_eq!(read.kick.as_deref(), Some("kick"), "and the kick");
736
737        assert!(
738            !set_seat_state(&db, "Personal", "forge", "ghost", SeatState::Idle, false)
739                .expect("set ghost"),
740            "a row that is not there is not created",
741        );
742    }
743
744    /// **A closed row stays closed against every write a torn-down task can
745    /// still land**, and only a spawn that states it is opening the seat
746    /// moves it. Without this, a buffered swap or a late settle reopens the
747    /// project the user just shut, and the next boot reopens it for real.
748    #[test]
749    fn a_closed_row_moves_only_for_an_explicit_reopen() {
750        let dir = tempdir().expect("tempdir");
751        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
752        let mut row = record("Personal", "forge", "lead", Some("id-1"));
753        row.seat_state = Some(SeatState::Closed);
754        put(&db, &row).expect("write");
755
756        for state in [SeatState::Idle, SeatState::InTurn, SeatState::Closed] {
757            assert!(
758                set_seat_state(&db, "Personal", "forge", "lead", state, false).expect("write"),
759                "the row is there to write",
760            );
761            assert_eq!(
762                get(&db, "Personal", "forge", "lead").expect("read").expect("row").seat_state,
763                Some(SeatState::Closed),
764                "a non-reopening write leaves {state:?} off a closed row",
765            );
766        }
767
768        assert!(
769            set_seat_state(&db, "Personal", "forge", "lead", SeatState::Idle, true)
770                .expect("reopen"),
771            "the row is there to reopen",
772        );
773        assert_eq!(
774            get(&db, "Personal", "forge", "lead").expect("read").expect("row").seat_state,
775            Some(SeatState::Idle),
776            "an opening door reopens it",
777        );
778    }
779
780    /// The first boot on this build seeds every row written before the
781    /// field existed with `idle`, so the boot wave opens every project
782    /// whose lead ever spawned, once; explicit states are left alone and a
783    /// second boot writes nothing.
784    #[test]
785    fn the_seat_state_migration_seeds_old_rows_once() {
786        let dir = tempdir().expect("tempdir");
787        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
788        put_raw_for_test(&db, "Personal", "forge", "lead", br#"{"session_id":"id-1"}"#)
789            .expect("plant a row of the shape the previous build wrote");
790        let mut explicit = record("Personal", "forge", "steward", Some("id-2"));
791        explicit.seat_state = Some(SeatState::InTurn);
792        put(&db, &explicit).expect("write");
793
794        assert_eq!(
795            seed_seat_state(&db).expect("migrate"),
796            1,
797            "only the row without a state is seeded",
798        );
799        assert_eq!(
800            get(&db, "Personal", "forge", "lead").expect("read").expect("row").seat_state,
801            Some(SeatState::Idle),
802            "an old row reads as an open seat",
803        );
804        assert_eq!(
805            get(&db, "Personal", "forge", "steward").expect("read").expect("row").seat_state,
806            Some(SeatState::InTurn),
807            "an explicit state is left alone",
808        );
809        assert_eq!(seed_seat_state(&db).expect("second boot"), 0, "the migration is idempotent");
810    }
811
812    /// The first boot after this table exists is the migration: every
813    /// persisted worker crosses over with its fields, and its id is left
814    /// empty because `dynamic_workers` never stored one - so the session
815    /// it names starts fresh rather than resuming.
816    #[test]
817    fn the_first_open_copies_the_persisted_workers_over_once() {
818        let dir = tempdir().expect("tempdir");
819        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
820        dynamic_workers::insert_for_test(&db, &worker("proj-a", "steward", true))
821            .expect("seed steward");
822        dynamic_workers::insert_for_test(&db, &worker("proj-a", "quartermaster", false))
823            .expect("seed quartermaster");
824
825        let projects = [project("proj-a", "Personal", "forge")];
826        let outcome = migrate_from_dynamic_workers(&db, &projects).expect("migrate");
827        assert_eq!(outcome.moved, 2, "both persisted workers cross over");
828        assert!(outcome.drained(), "and nothing is left in the retired table");
829
830        let steward = get(&db, "Personal", "forge", "steward").expect("read").expect("steward row");
831        assert_eq!(
832            steward,
833            SessionRecord {
834                org: "Personal".to_owned(),
835                project: "forge".to_owned(),
836                label: "steward".to_owned(),
837                session_id: None,
838                charter: Some("charter for steward".to_owned()),
839                kick: Some("kick for steward".to_owned()),
840                resume_kick: Some("resume kick for steward".to_owned()),
841                interactive: Some(true),
842                seat_state: None,
843                // The retired table never held it; the spawn probes and
844                // records it on the worker's next spawn.
845                is_git_repo: None,
846                // Neither did it hold a family selection; absent means
847                // every family.
848                mcp_families: None,
849                // Nor a tree of its own; absent is the project root.
850                cwd: None,
851            },
852            "the row carries the worker's fields and no id",
853        );
854
855        let second = migrate_from_dynamic_workers(&db, &projects).expect("second boot");
856        assert_eq!(second.moved, 0, "a second boot copies nothing");
857        assert!(
858            second.drained(),
859            "and it is a clean drain: the first sweep took every row with it, so the table is \
860             empty and the caller may drop it",
861        );
862        assert!(
863            get(&db, "Personal", "forge", "quartermaster").expect("read").is_some(),
864            "and leaves the rows it copied where they were",
865        );
866    }
867
868    /// A project the config no longer names cannot be keyed by
869    /// `(org, project, label)`. Its worker is left where it is - the
870    /// `dynamic_workers` read path is untouched in this change - and the
871    /// boot carries on rather than failing over one orphan.
872    #[test]
873    fn a_worker_whose_project_is_no_longer_configured_is_left_behind() {
874        let dir = tempdir().expect("tempdir");
875        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
876        dynamic_workers::insert_for_test(&db, &worker("proj-gone", "steward", false))
877            .expect("seed");
878
879        let outcome = migrate_from_dynamic_workers(&db, &[project("proj-a", "Personal", "forge")])
880            .expect("migrate");
881        assert_eq!(outcome.moved, 0, "a worker with no configured project is not copied");
882        assert_eq!(outcome.unkeyable, 1, "and it is counted as left behind rather than dropped");
883        assert!(
884            !outcome.drained(),
885            "so the retired table is not safe to drop: its row exists nowhere else",
886        );
887        assert!(get(&db, "Personal", "forge", "steward").expect("read").is_none());
888        assert_eq!(
889            dynamic_workers::list_all(&db).expect("list workers").len(),
890            1,
891            "its row is still where it was",
892        );
893    }
894
895    /// A row written before this table existed has a NULL id, and a row
896    /// a spawn has since filled carries one: both decode, so a store
897    /// written by the previous build is readable rather than skipped.
898    #[test]
899    fn a_row_without_an_id_and_one_with_it_both_round_trip() {
900        let dir = tempdir().expect("tempdir");
901        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
902
903        put(&db, &record("Personal", "forge", "lead", Some("id-1"))).expect("put lead");
904        put(&db, &record("Personal", "forge", "steward", None)).expect("put worker");
905
906        assert_eq!(
907            get(&db, "Personal", "forge", "lead").expect("get").and_then(|row| row.session_id),
908            Some("id-1".to_owned()),
909        );
910        assert_eq!(
911            get(&db, "Personal", "forge", "steward").expect("get").map(|row| row.session_id),
912            Some(None)
913        );
914        assert_eq!(get(&db, "Personal", "forge", "ghost").expect("get"), None);
915    }
916
917    /// The three additions the worker registry needs: a project's rows
918    /// list, a field merge that leaves an absent field alone, and a
919    /// delete that reports whether it removed anything.
920    #[test]
921    fn list_update_and_delete_are_scoped_to_the_row() {
922        let dir = tempdir().expect("tempdir");
923        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
924        let mut steward = record("Personal", "forge", "steward", Some("id-1"));
925        steward.charter = Some("charter".to_owned());
926        put(&db, &steward).expect("put steward");
927        put(&db, &record("Personal", "forge", "lead", Some("id-2"))).expect("put lead");
928        put(&db, &record("Personal", "other", "steward", None)).expect("put elsewhere");
929
930        assert_eq!(
931            list_for_project(&db, "Personal", "forge").expect("list").len(),
932            2,
933            "a project's rows are scoped by org and project",
934        );
935        assert_eq!(list_all(&db).expect("list all").len(), 3, "and the whole table lists");
936
937        assert!(
938            update(&db, &record_fields("Personal", "forge", "steward", None, Some("new kick")))
939                .expect("update"),
940            "an existing row updates",
941        );
942        let updated = get(&db, "Personal", "forge", "steward").expect("get").expect("row");
943        assert_eq!(updated.kick.as_deref(), Some("new kick"), "the supplied field lands");
944        assert_eq!(
945            updated.session_id.as_deref(),
946            Some("id-1"),
947            "and an absent field keeps its stored value",
948        );
949        assert_eq!(updated.charter.as_deref(), Some("charter"), "including the charter");
950
951        assert!(
952            !update(&db, &record_fields("Personal", "forge", "ghost", None, Some("k")))
953                .expect("update ghost"),
954            "an absent row is not created",
955        );
956        assert!(get(&db, "Personal", "forge", "ghost").expect("get").is_none());
957
958        assert!(delete(&db, "Personal", "forge", "steward").expect("delete"));
959        assert!(get(&db, "Personal", "forge", "steward").expect("get").is_none());
960        assert!(
961            !delete(&db, "Personal", "forge", "steward").expect("delete again"),
962            "a second delete reports nothing removed",
963        );
964        assert!(
965            get(&db, "Personal", "other", "steward").expect("get").is_some(),
966            "deleting one project's row leaves another's alone",
967        );
968    }
969
970    /// A row whose value will not decode is skipped, not fatal: the key
971    /// alone stays readable, so `list_all` reports the rest.
972    #[test]
973    fn list_all_skips_an_undecodable_row() {
974        let dir = tempdir().expect("tempdir");
975        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
976        put(&db, &record("Personal", "forge", "lead", Some("id-1"))).expect("put");
977        put_raw_for_test(&db, "Personal", "forge", "corrupt", b"not a record")
978            .expect("plant the undecodable row");
979
980        let rows = list_all(&db).expect("list tolerates the corrupt blob");
981        assert_eq!(rows.len(), 1, "the good row survives a corrupt sibling");
982        assert_eq!(rows[0].label, "lead");
983    }
984
985    /// An entry whose value will not decode cannot be moved, so the sweep
986    /// must not report a clean drain while one is there - the drop that
987    /// follows would destroy it, and it exists nowhere else. The row count
988    /// therefore comes from the table, not from the rows that decoded.
989    #[test]
990    fn a_table_holding_an_undecodable_entry_is_never_reported_as_drained() {
991        let dir = tempdir().expect("tempdir");
992        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
993        dynamic_workers::insert_for_test(&db, &worker("proj-a", "steward", false))
994            .expect("seed steward");
995        dynamic_workers::put_raw_for_test(&db, "proj-a", "unreadable", b"not a worker")
996            .expect("plant a retired row that will not decode");
997
998        let projects = [project("proj-a", "Personal", "forge")];
999        let outcome = migrate_from_dynamic_workers(&db, &projects).expect("migrate");
1000        assert_eq!(outcome.moved, 1, "the readable row crosses over");
1001        assert_eq!(outcome.rows, 2, "and the count is what the table holds, not what decoded");
1002        assert!(
1003            !outcome.drained(),
1004            "so the caller must not drop a table still holding an entry nothing else has",
1005        );
1006    }
1007
1008    /// `worker_row_index` reads a row without its body, and reports exactly
1009    /// the rows `list_all` reports. An undecodable body is skipped by both,
1010    /// which is what keeps the launchpad offering only what the boot wave
1011    /// would start: a row the wave skips but the picker draws is a worker
1012    /// nothing can bring back.
1013    ///
1014    /// It also has to read the same fields the launchpad decides off, and
1015    /// treat a blank id as absence the way `decode` does.
1016    #[test]
1017    fn worker_row_index_reports_what_the_wave_reads_without_the_row_body() {
1018        let dir = tempdir().expect("tempdir");
1019        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1020        put(&db, &record("Personal", "forge", "lead", Some("id-1"))).expect("put");
1021        let mut listed = record("Personal", "forge", "worker", Some("id-2"));
1022        listed.is_git_repo = Some(true);
1023        put(&db, &listed).expect("put");
1024        put(&db, &record("Personal", "forge", "blank", Some(""))).expect("put");
1025        put_raw_for_test(&db, "Personal", "forge", "corrupt", b"not a record")
1026            .expect("plant the undecodable row");
1027
1028        let rows = worker_row_index(&db).expect("the index reads the key and two fields");
1029        let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect();
1030        assert_eq!(
1031            labels,
1032            vec!["blank", "lead", "worker"],
1033            "an undecodable body is skipped, exactly as `list_all` skips it - offering it \
1034             would draw a worker the boot wave never starts",
1035        );
1036        let worker = rows.iter().find(|row| row.label == "worker").expect("the worker row");
1037        assert_eq!(worker.session_id.as_deref(), Some("id-2"), "the stored id is projected");
1038        assert_eq!(
1039            worker.is_git_repo,
1040            Some(true),
1041            "and so is the gitness the launchpad decides off",
1042        );
1043        let blank = rows.iter().find(|row| row.label == "blank").expect("the blank-id row");
1044        assert_eq!(
1045            blank.session_id, None,
1046            "a blank id is absence, not an id - a launchpad reading it as real would call \
1047             the row a resume",
1048        );
1049    }
1050
1051    /// `update` merges a field it was given and leaves one it was not, and
1052    /// the gitness flag is no exception. Only a spawn supplies it today, so
1053    /// this pins the arm rather than a caller: an arm nothing exercises is
1054    /// one that silently stops merging.
1055    #[test]
1056    fn update_merges_the_gitness_flag_and_leaves_an_absent_one_alone() {
1057        let dir = tempdir().expect("tempdir");
1058        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1059        put(&db, &record("Personal", "forge", "worker", Some("id"))).expect("put");
1060
1061        let mut supplied = record("Personal", "forge", "worker", None);
1062        supplied.is_git_repo = Some(true);
1063        assert!(update(&db, &supplied).expect("update"), "the row exists to merge onto");
1064        assert_eq!(
1065            get(&db, "Personal", "forge", "worker").expect("get").and_then(|row| row.is_git_repo),
1066            Some(true),
1067            "a supplied gitness merges onto the row",
1068        );
1069
1070        let blank = record("Personal", "forge", "worker", None);
1071        assert!(update(&db, &blank).expect("update"));
1072        assert_eq!(
1073            get(&db, "Personal", "forge", "worker").expect("get").and_then(|row| row.is_git_repo),
1074            Some(true),
1075            "and an absent one leaves the stored value alone",
1076        );
1077    }
1078
1079    /// An empty id is absence, not an id: a blank is not an answer a
1080    /// spawn can resume onto.
1081    #[test]
1082    fn an_empty_session_id_is_absent() {
1083        let dir = tempdir().expect("tempdir");
1084        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1085        put(&db, &record("Personal", "forge", "lead", Some(""))).expect("put");
1086        assert_eq!(
1087            get(&db, "Personal", "forge", "lead").expect("get").and_then(|row| row.session_id),
1088            None,
1089        );
1090    }
1091
1092    /// The row's identity is its key and only its key: a body carrying a
1093    /// second copy is a second answer to the same question, and the two
1094    /// can disagree.
1095    #[test]
1096    fn the_identity_is_the_key_and_is_not_repeated_in_the_body() {
1097        let dir = tempdir().expect("tempdir");
1098        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1099        let mut row = record("Personal", "forge", "steward", Some("id-1"));
1100        row.charter = Some("charter".to_owned());
1101        put(&db, &row).expect("put");
1102
1103        let raw = raw_for_test(&db, "Personal", "forge", "steward").expect("read the stored bytes");
1104        let body: serde_json::Value = serde_json::from_slice(&raw).expect("the body is json");
1105        for field in ["org", "project", "label"] {
1106            assert!(
1107                body.get(field).is_none(),
1108                "the key carries {field}, so the body must not restate it: {body}",
1109            );
1110        }
1111        assert_eq!(
1112            body.get("charter").and_then(serde_json::Value::as_str),
1113            Some("charter"),
1114            "the fields that belong to the body are still written there: {body}",
1115        );
1116    }
1117
1118    /// A row written before this change carries the identity in its body.
1119    /// It still reads, and every reader takes the identity from the key -
1120    /// including when the body disagrees, which is the disagreement the
1121    /// duplicate copy made possible.
1122    #[test]
1123    fn a_legacy_body_reads_back_under_its_key() {
1124        let dir = tempdir().expect("tempdir");
1125        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1126        put_raw_for_test(
1127            &db,
1128            "Personal",
1129            "forge",
1130            "steward",
1131            br#"{"org":"Elsewhere","project":"elsewhere","label":"ghost","session_id":"id-1","charter":"c"}"#,
1132        )
1133        .expect("plant a row of the shape the previous build wrote");
1134
1135        let row = get(&db, "Personal", "forge", "steward")
1136            .expect("a legacy body decodes")
1137            .expect("the row is there");
1138        assert_eq!(row.org, "Personal", "the key names the row's org, not its stale body");
1139        assert_eq!(row.project, "forge", "the key names the row's project, not its stale body");
1140        assert_eq!(row.label, "steward", "and its label");
1141        assert_eq!(row.session_id.as_deref(), Some("id-1"), "the body's own fields still decode");
1142        assert_eq!(row.charter.as_deref(), Some("c"), "including the charter");
1143
1144        assert_eq!(
1145            list_for_project(&db, "Personal", "forge").expect("list").len(),
1146            1,
1147            "the row lists under the project its key names",
1148        );
1149        assert!(
1150            list_for_project(&db, "Personal", "elsewhere").expect("list").is_empty(),
1151            "and not under the one its stale body names",
1152        );
1153        let index = worker_row_index(&db).expect("the key-only reader reads it");
1154        assert_eq!(
1155            index
1156                .iter()
1157                .map(|row| (row.org.as_str(), row.project.as_str(), row.label.as_str()))
1158                .collect::<Vec<_>>(),
1159            vec![("Personal", "forge", "steward")],
1160            "the launcher's reader takes the identity from the key too",
1161        );
1162    }
1163}