@teamscala/service-boot 0.23.335 → 0.23.336

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,8 +21,8 @@
21
21
  * identity in kind_data.lane = { pane, session, cli_kind, role } so any surface
22
22
  * (fleet UI, another lane) can resolve "who owns this work" and push changes
23
23
  * back. Binding is by pane identity (the stable $TMUX_PANE), not by fleet run —
24
- * so it works before/without the fleet_agent_runs.work_item_id join (that column
25
- * is a UI convenience for the unified lane+task+subtask view, not a dependency).
24
+ * so it works without any second runtime-assignment join; the canonical runtime
25
+ * identity remains cli_sessions and the assignment remains work_items.
26
26
  *
27
27
  * These procedures are the ONLY CLI-agnostic surface an adapter calls:
28
28
  * bindLane — bind (create-or-confirm) a lane's task. SessionStart.
@@ -56,11 +56,13 @@ import {
56
56
  import * as v from "valibot";
57
57
  import {
58
58
  STATUS_TO_WORK_ITEM,
59
+ deriveEvidenceProgress,
59
60
  normalizeStatus,
60
61
  resolveProjectedStatus,
61
62
  subtaskKey,
62
63
  resolveTaskUpdateTarget,
63
64
  planReconcile,
65
+ type ProgressAcceptanceEvidence,
64
66
  type ReconcilePlan,
65
67
  } from "./task-sync-projection.ts";
66
68
 
@@ -134,12 +136,16 @@ export type Prisma = {
134
136
  update: (a: unknown) => Promise<WorkItem>;
135
137
  deleteMany: (a: unknown) => Promise<{ count: number }>;
136
138
  };
139
+ cli_sessions: {
140
+ findFirst: (a: unknown) => Promise<{ id: string; label?: string | null } | null>;
141
+ };
137
142
  // `comments` is a universal shared model (owned by @teamgbg/db), so every
138
143
  // service with the db client carries it. Declared here rather than
139
- // feature-detected because, unlike fleet_agent_runs (fleet-service-only), it
140
- // is part of the shared client every consumer installs.
144
+ // feature-detected only for clients generated before the shared comments model
145
+ // was deployed; current consumers install the universal comments surface.
141
146
  comments?: {
142
147
  findFirst: (a: unknown) => Promise<CommentRow | null>;
148
+ findMany: (a: unknown) => Promise<Array<ProgressAcceptanceEvidence>>;
143
149
  };
144
150
  };
145
151
 
@@ -149,20 +155,6 @@ export interface CommentRow {
149
155
  created_at: Date;
150
156
  }
151
157
 
152
- /**
153
- * A dispatched run already points at its fleet_tasks row. Task-sync may fill
154
- * the convenience join for an adopted lane, but must never replace that
155
- * dispatch identity with the lane's work_items projection id.
156
- */
157
- export function untrackedRunForPane(pane: string) {
158
- return {
159
- pane,
160
- state: { not: "closed" },
161
- tracked_task_id: null,
162
- };
163
- }
164
-
165
-
166
158
  /**
167
159
  * Resolve the org a lane's task is created under. Explicit when the lane knows
168
160
  * its org (a dispatched fleet lane carries one); defaults to the platform org
@@ -215,35 +207,26 @@ export function discardApplied(
215
207
  }
216
208
 
217
209
  /**
218
- * The lane's human work name, read from its live fleet run (slot_name carries the
219
- * tmux window label per lanes-named-by-the-spawner). Returns null when no run
220
- * carries this pane yet — adoption and bind race at SessionStart, and a missing
221
- * name must not block the bind. Feature-detected like the run-link below, because
222
- * fleet_agent_runs lives only in the fleet service's schema.
210
+ * The lane's human work name is resolved by the assignment owner before binding.
211
+ * This router never reads a retired fleet-run row to derive task identity.
223
212
  */
224
213
 
225
214
  /**
226
- * Resolve a lane's CLI-native session id from its live run row by pane. Used by
227
- * the task-event handlers (upsertFromTaskHook) whose payloads lack session_id —
228
- * the run row's opencode_session_id IS the same CLI-native id the bind stamped
229
- * on kind_data.lane.session, so this recovers it for session-keyed matching.
230
- * Feature-detected (fleet_agent_runs lives only in the fleet service's schema).
215
+ * Resolve a lane's CLI-native session id from the canonical runtime row by pane.
216
+ * Task-event payloads may lack session_id, so the active cli_sessions row's
217
+ * host_route is the only valid recovery address.
231
218
  */
232
219
  async function resolveSessionFromPane(prisma: Prisma, pane: string): Promise<string | null> {
233
220
  try {
234
- const runs = (
235
- prisma as unknown as {
236
- fleet_agent_runs?: {
237
- findFirst: (a: unknown) => Promise<{ opencode_session_id?: string | null } | null>;
238
- };
239
- }
240
- ).fleet_agent_runs;
241
- if (!runs?.findFirst) return null;
242
- const run = await runs.findFirst({
243
- where: { pane, state: { not: "closed" } },
244
- select: { opencode_session_id: true },
221
+ const session = await prisma.cli_sessions.findFirst({
222
+ where: {
223
+ closed_at: null,
224
+ lifecycle_state: { not: "closed" },
225
+ host_route: { path: ["pane_id"], equals: pane },
226
+ },
227
+ select: { id: true },
245
228
  });
246
- return run?.opencode_session_id ?? null;
229
+ return session?.id ?? null;
247
230
  } catch {
248
231
  return null;
249
232
  }
@@ -446,8 +429,7 @@ export const taskSyncRouter = {
446
429
  * adapter so the lane has a durable task row before its first todo write.
447
430
  * Idempotent: re-binding updates the identity (role/cli_kind can change) but
448
431
  * never orphans existing subtasks. After binding, links the lane's
449
- * fleet_agent_runs row (matched by pane) to the task via tracked_task_id —
450
- * the join the operator's lane-labels monitor + the unified fleet view read.
432
+ * cli_sessions is the runtime identity and work_items is the assignment.
451
433
  */
452
434
  bindLane: authed.input(bindLaneInput).handler(async ({ input, context }) => {
453
435
  const ctx = context as Context;
@@ -488,8 +470,9 @@ export const taskSyncRouter = {
488
470
  message: "bindLane requires lane.session (the CLI's native session id)",
489
471
  });
490
472
  }
473
+ const lanePane = pane ?? "";
491
474
  const lane: LaneIdentity = {
492
- pane,
475
+ pane: lanePane,
493
476
  session,
494
477
  cli_kind: (raw.lane?.cli_kind ?? headerWorkKind ?? "claude-code") as LaneIdentity["cli_kind"],
495
478
  role: raw.lane?.role ?? "agent",
@@ -509,7 +492,7 @@ export const taskSyncRouter = {
509
492
  // orchestrator cannot attribute). null when no run resolves a slot_name;
510
493
  // the create path REFUSES rather than write "Lane" (lanes-named-by-the-
511
494
  // spawner).
512
- const laneTitle = raw.title ?? (await resolveLaneWorkName(prisma, pane)) ?? null;
495
+ const laneTitle = raw.title ?? (await resolveLaneWorkName(prisma, lanePane)) ?? null;
513
496
  let taskId: string;
514
497
  let created: boolean;
515
498
  if (existing) {
@@ -626,51 +609,7 @@ export const taskSyncRouter = {
626
609
  created = true;
627
610
  }
628
611
  }
629
- // Link the fleet run carrying this pane to the bound task — sets
630
- // fleet_agent_runs.tracked_task_id. Best-effort by design: fleet_agent_runs
631
- // is only in the fleet service's schema, so feature-detect the model; and a
632
- // run not yet carrying its pane (adoption race at SessionStart — adoption
633
- // fires first, but its pane resolve can come up empty on a tmux hiccup) is
634
- // simply not matched, converging on re-bind / next adoption. The work_items
635
- // task is the source of truth; this column is the UI convenience join.
636
- try {
637
- const runs = (
638
- prisma as unknown as {
639
- fleet_agent_runs?: {
640
- updateMany: (a: unknown) => Promise<{ count: number }>;
641
- };
642
- }
643
- ).fleet_agent_runs;
644
- if (runs?.updateMany) {
645
- // STEAL before CLAIM. A task is tracked by at most one LIVE run — the
646
- // partial unique index fleet_agent_runs_one_active_run_per_task
647
- // (UNIQUE(tracked_task_id) WHERE state IN active/committing/waiting)
648
- // enforces it. Assigning tracked_task_id without first releasing it
649
- // from any OTHER run holding it leaves two runs sharing the task, and
650
- // the next state→active transition on either one (upsertLaneRun's
651
- // UPDATE) violates the index → P2002 → the lane-event 500s and the
652
- // run-link join is lost. The common trigger is re-adoption: a lane
653
- // moves to a new pane, bindLane claims the task for the new run while
654
- // the OLD pane's run still holds it. A task is session-scoped, so every
655
- // run holding this taskId is this lane's own (or a stale duplicate) —
656
- // releasing from all non-closed runs is correct, then only this lane's
657
- // untracked run re-claims it.
658
- await runs.updateMany({
659
- where: { tracked_task_id: taskId, state: { not: "closed" } },
660
- data: { tracked_task_id: null },
661
- });
662
- await runs.updateMany({
663
- where: untrackedRunForPane(pane),
664
- data: { tracked_task_id: taskId },
665
- });
666
- }
667
- } catch (err) {
668
- log.warn("[task-sync] bindLane: run-link by pane failed", {
669
- pane,
670
- taskId,
671
- error: err instanceof Error ? err.message : String(err),
672
- });
673
- }
612
+ // Runtime identity is owned by cli_sessions; task binding must not mutate a retired run row.
674
613
  return { task_id: taskId, created };
675
614
  }),
676
615
 
@@ -691,10 +630,9 @@ export const taskSyncRouter = {
691
630
  const { ORPCError } = await import("@orpc/server");
692
631
  throw new ORPCError("BAD_REQUEST", { message: "upsertFromTaskList requires lane_session" });
693
632
  }
694
- // The pane rides in the header the callers already send (`x-lane-pane`),
695
- // and it is what lets the on-demand bind link the new task to the lane's
696
- // run. Absent, the bind still succeeds — it just cannot link the run — so
697
- // this is passed opportunistically rather than required.
633
+ // The pane rides in the header the callers already send (`x-lane-pane`)
634
+ // for canonical cli_sessions routing. Absent, task binding still uses the
635
+ // native lane session id.
698
636
  const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
699
637
  try {
700
638
  const res = await reconcileTaskList(prisma, session, list, body.source, headerPane);
@@ -752,17 +690,38 @@ export const taskSyncRouter = {
752
690
  throw new ORPCError("BAD_REQUEST", { message: "readTaskList requires lane_session" });
753
691
  }
754
692
  const task = await resolveLaneTask(prisma, session);
755
- if (!task) return { task_id: null, task_title: null, subtasks: [] };
693
+ if (!task) {
694
+ return {
695
+ task_id: null,
696
+ task_title: null,
697
+ subtasks: [],
698
+ progress: { total: 0, accepted: 0, percentage: 0 },
699
+ };
700
+ }
756
701
  const rows = await prisma.work_items.findMany({
757
702
  where: { parent_id: task.id, kind: "subtask" },
758
703
  });
759
704
  rows.sort((a, b) => (a.position ?? "").localeCompare(b.position ?? "", undefined, { numeric: true }));
705
+ const evidence = prisma.comments
706
+ ? await prisma.comments.findMany({
707
+ where: {
708
+ work_item_id: { in: rows.map((row) => row.id) },
709
+ sender_role: "orchestrator",
710
+ },
711
+ select: { work_item_id: true, sender_role: true, system_key: true },
712
+ })
713
+ : [];
760
714
  const subtasks: TaskListItem[] = rows.map((r) => ({
761
715
  content: r.title,
762
716
  status: normalizeStatus(r.status),
763
717
  mark: (r.kind_data?.task_sync as { mark?: string } | undefined)?.mark,
764
718
  }));
765
- return { task_id: task.id, task_title: task.title, subtasks };
719
+ return {
720
+ task_id: task.id,
721
+ task_title: task.title,
722
+ subtasks,
723
+ progress: deriveEvidenceProgress(rows, evidence),
724
+ };
766
725
  }),
767
726
 
768
727
  /**
@@ -801,11 +760,9 @@ export const taskSyncRouter = {
801
760
  * there the lane is the originator (no self-echo); here the lane is the
802
761
  * recipient.
803
762
  *
804
- * COMPLETION STEP (not MVP): this procedure is the explicit trigger. For
805
- * FULL coverage — every work_items subtask write, including the projects
806
- * UI's generated CRUD — a Postgres trigger + LISTEN subscriber on
807
- * scala-channel fires the same publish, so an operator edit reaches the
808
- * lane with no explicit call. Tracked in apps/agent-fleet.md task-sync.
763
+ * Every call writes the canonical task-change comment for the active
764
+ * cli_sessions row; the database comment emission is the delivery trigger.
765
+ * There is no pane transport or secondary channel publisher here.
809
766
  */
810
767
  applyExternalChange: authed.handler(async ({ input, context }) => {
811
768
  const body = input as { task_id: string; subtask_id?: string; status?: TaskListItem["status"] };
@@ -823,17 +780,19 @@ export const taskSyncRouter = {
823
780
  });
824
781
  }
825
782
  const task = await prisma.work_items.findFirst({ where: { id: task_id } });
826
- const lanePane = (task?.kind_data?.lane as { pane?: string } | undefined)?.pane;
827
- // Fire-and-forget: a dispatch failure must never block the work_items write.
828
- if (lanePane) {
829
- notifyLaneOfTaskChange(lanePane, task_id).catch((err) => {
830
- log.warn("[task-sync] applyExternalChange: lane notify failed", {
831
- taskId: task_id,
832
- error: err instanceof Error ? err.message : String(err),
833
- });
834
- });
835
- }
836
- return { task_id, lane_pane: lanePane ?? null, notified: !!lanePane };
783
+ const dispatch = task
784
+ ? await notifyLaneOfTaskChange(
785
+ prisma as unknown as Parameters<typeof notifyLaneOfTaskChange>[0],
786
+ task_id,
787
+ `${subtask_id ?? "task"}:${status ?? "refresh"}`,
788
+ )
789
+ : { delivered: false, sessionId: null, commentId: null };
790
+ log.info("[task-sync] applyExternalChange: durable comment emission", {
791
+ taskId: task_id,
792
+ sessionId: dispatch.sessionId,
793
+ delivered: dispatch.delivered,
794
+ });
795
+ return { task_id, cli_session_id: dispatch.sessionId, comment_id: dispatch.commentId, notified: dispatch.delivered };
837
796
  }),
838
797
  };
839
798
 
@@ -871,9 +830,8 @@ export async function upsertFromTaskHookHandler(input: unknown, context: Context
871
830
  // TaskCreated/TaskCompleted event payloads do NOT carry session_id (only
872
831
  // SessionStart does), so the task hooks cannot populate x-lane-session —
873
832
  // the extraction in the hook command yields an empty string. Resolve the
874
- // session from the lane's run row by pane instead: the bind (at
875
- // session_start) already stamped kind_data.lane.session, and the run row's
876
- // opencode_session_id is the same CLI-native id. This keeps matching
833
+ // session from the canonical cli_sessions row by pane instead: the bind (at
834
+ // session_start) already stamped kind_data.lane.session. This keeps matching
877
835
  // session-keyed (resolveLaneTask still filters kind_data.lane.session)
878
836
  // without requiring every task-event payload to carry the session id.
879
837
  const session = headerSession || (headerPane ? await resolveSessionFromPane(prisma, headerPane) : null);
@@ -1 +1,21 @@
1
- export {};
1
+ /**
2
+ * Expand a path pattern containing single-segment `*` wildcards into concrete
3
+ * paths (files or dirs). Used by `retention_dirs` so a single config key like
4
+ * a pattern like `${WORKSPACE}/<star>/target` reaches every repo's target dir.
5
+ * Sync + bounded: the
6
+ * pattern is shallow (3 segments), and this runs once
7
+ * nightly. Non-existent segments yield no matches (the caller stats each result).
8
+ */
9
+ export declare function expandGlob(pattern: string): string[];
10
+ /**
11
+ * Is `dir` stale — i.e. NO file under it is newer than `cutoffMs`? Walks
12
+ * recursively and SHORT-CIRCUITS the instant it finds a fresh file (so an
13
+ * actively-built target dir returns fast without walking its full tree). On a
14
+ * stale dir the walk is complete and returns the total size + file count, so
15
+ * the caller can report bytes-freed before deleting the dir whole.
16
+ */
17
+ export declare function checkDirStale(dir: string, cutoffMs: number): Promise<{
18
+ stale: boolean;
19
+ bytes: number;
20
+ fileCount: number;
21
+ }>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Unit tests for the `retention_dirs` helpers (`expandGlob`, `checkDirStale`) —
7
+ * the whole-directory prune that bounds Rust `target/` waste. The file-by-mtime
8
+ * `retention` path is unchanged; these cover ONLY the new build-cache model:
9
+ * (1) glob expansion reaches every repo's target dir; (2) an actively-built dir
10
+ * is kept (short-circuits on a fresh file); (3) an abandoned dir is reported
11
+ * stale with its full size. Lives next to the handler per co-located-test rule.
12
+ */
13
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
14
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { expandGlob, checkDirStale } from "./sweep-host-cache.ts";
18
+
19
+ const DAY = 86_400_000;
20
+ const now = Date.now();
21
+ const daysAgo = (n: number) => new Date(now - n * DAY);
22
+ const touch = (p: string, t: Date) => utimesSync(p, t, t);
23
+
24
+ let root: string;
25
+ beforeEach(() => {
26
+ root = mkdtempSync(join(tmpdir(), "sweep-"));
27
+ });
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("expandGlob", () => {
33
+ test("expands a single-segment wildcard to concrete dirs", () => {
34
+ mkdirSync(join(root, "scala-rust", "target"), { recursive: true });
35
+ mkdirSync(join(root, "scala-os", "target"), { recursive: true });
36
+ mkdirSync(join(root, "scala-os", "src"), { recursive: true });
37
+ const matches = expandGlob(`${root}/*/target`).sort();
38
+ expect(matches).toEqual(
39
+ [join(root, "scala-os", "target"), join(root, "scala-rust", "target")].sort(),
40
+ );
41
+ });
42
+ test("literal segment matches only itself", () => {
43
+ mkdirSync(join(root, "scala-rust", "target"), { recursive: true });
44
+ mkdirSync(join(root, "other", "target"), { recursive: true });
45
+ const matches = expandGlob(`${root}/scala-rust/target`);
46
+ expect(matches).toEqual([join(root, "scala-rust", "target")]);
47
+ });
48
+ test("no matches when the wildcard segment has no children", () => {
49
+ const matches = expandGlob(`${root}/does-not-exist/*/target`);
50
+ expect(matches).toEqual([]);
51
+ });
52
+ });
53
+
54
+ describe("checkDirStale", () => {
55
+ test("stale dir: all files older than cutoff → stale + full size", async () => {
56
+ const dir = join(root, "target");
57
+ mkdirSync(join(dir, "debug", "deps"), { recursive: true });
58
+ writeFileSync(join(dir, "debug", "deps", "a.o"), "x".repeat(40));
59
+ writeFileSync(join(dir, "debug", "deps", "b.o"), "y".repeat(60));
60
+ touch(join(dir, "debug", "deps", "a.o"), daysAgo(40));
61
+ touch(join(dir, "debug", "deps", "b.o"), daysAgo(35));
62
+ const res = await checkDirStale(dir, now - 30 * DAY);
63
+ expect(res.stale).toBe(true);
64
+ expect(res.fileCount).toBe(2);
65
+ expect(res.bytes).toBe(100);
66
+ });
67
+ test("active dir: a file newer than cutoff → not stale (short-circuit)", async () => {
68
+ const dir = join(root, "target");
69
+ mkdirSync(join(dir, "debug"), { recursive: true });
70
+ writeFileSync(join(dir, "debug", "old.o"), "x".repeat(40));
71
+ touch(join(dir, "debug", "old.o"), daysAgo(40));
72
+ writeFileSync(join(dir, "debug", "fresh.o"), "y".repeat(60));
73
+ touch(join(dir, "debug", "fresh.o"), daysAgo(1));
74
+ const res = await checkDirStale(dir, now - 30 * DAY);
75
+ expect(res.stale).toBe(false);
76
+ });
77
+ test("empty dir → stale with zero bytes", async () => {
78
+ const dir = join(root, "target");
79
+ mkdirSync(dir, { recursive: true });
80
+ const res = await checkDirStale(dir, now - 30 * DAY);
81
+ expect(res.stale).toBe(true);
82
+ expect(res.bytes).toBe(0);
83
+ expect(res.fileCount).toBe(0);
84
+ });
85
+ });
@@ -16,8 +16,14 @@
16
16
  * Path convention (config.retention keys, substituted at runtime via `userHome()`
17
17
  * / `workspaceRoot()` per `host-literals-via-substitution` — source never
18
18
  * hardcodes host paths):
19
- * "${HOME}/.bun/install/cache" → "30 days"
20
- * "${HOME}/.config/*.retired-*" → "14 days" (glob leaf, optional)
19
+ * "${HOME}/.bun/install/cache" → "30 days"
20
+ * "${HOME}/.config/*.retired-*" → "14 days" (glob leaf, optional)
21
+ *
22
+ * config.retention_dirs — whole-DIRECTORY prune for build caches (Rust
23
+ * `target/`) whose unit is the dir, not the file (file-by-mtime would corrupt
24
+ * the cache). Keys support `*` globs; a matched dir is deleted ENTIRELY only
25
+ * when its newest file is older than the cutoff:
26
+ * "${WORKSPACE}/<repo>/target" → "30 days" (glob wildcard, whole-dir delete)
21
27
  *
22
28
  * Deletion runs through Bun fs APIs — pure filesystem deletion needs no shell,
23
29
  * so `spawn-is-the-only-spawn` does not apply (that governs shell commands).
@@ -34,7 +40,7 @@
34
40
  * Registered under slug `@teamscala/service-boot:sweep-host-cache`.
35
41
  */
36
42
  import { rm, readdir, stat } from "node:fs/promises";
37
- import type { Dirent } from "node:fs";
43
+ import { readdirSync, type Dirent } from "node:fs";
38
44
  import { join } from "node:path";
39
45
  import { spawnSync } from "@teamscala/os/spawn/spawn";
40
46
  import { userHome, workspaceRoot } from "@teamscala/os/host-paths";
@@ -117,11 +123,89 @@ async function sweepPath(
117
123
  return { path: rootDir, scanned, deleted, bytesFreed };
118
124
  }
119
125
 
126
+ /**
127
+ * Expand a path pattern containing single-segment `*` wildcards into concrete
128
+ * paths (files or dirs). Used by `retention_dirs` so a single config key like
129
+ * a pattern like `${WORKSPACE}/<star>/target` reaches every repo's target dir.
130
+ * Sync + bounded: the
131
+ * pattern is shallow (3 segments), and this runs once
132
+ * nightly. Non-existent segments yield no matches (the caller stats each result).
133
+ */
134
+ export function expandGlob(pattern: string): string[] {
135
+ const absolute = pattern.startsWith("/");
136
+ const segments = (absolute ? pattern.slice(1) : pattern)
137
+ .split("/")
138
+ .filter((s) => s.length > 0);
139
+ let current: string[] = [absolute ? "/" : "."];
140
+ for (const seg of segments) {
141
+ if (current.length === 0) break;
142
+ const next: string[] = [];
143
+ for (const dir of current) {
144
+ let names: string[];
145
+ try {
146
+ names = readdirSync(dir);
147
+ } catch {
148
+ continue;
149
+ }
150
+ for (const name of names) {
151
+ if (seg === "*" || seg === name) next.push(join(dir, name));
152
+ }
153
+ }
154
+ current = next;
155
+ }
156
+ return current;
157
+ }
158
+
159
+ /**
160
+ * Is `dir` stale — i.e. NO file under it is newer than `cutoffMs`? Walks
161
+ * recursively and SHORT-CIRCUITS the instant it finds a fresh file (so an
162
+ * actively-built target dir returns fast without walking its full tree). On a
163
+ * stale dir the walk is complete and returns the total size + file count, so
164
+ * the caller can report bytes-freed before deleting the dir whole.
165
+ */
166
+ export async function checkDirStale(
167
+ dir: string,
168
+ cutoffMs: number,
169
+ ): Promise<{ stale: boolean; bytes: number; fileCount: number }> {
170
+ const stack: string[] = [dir];
171
+ let bytes = 0;
172
+ let fileCount = 0;
173
+ while (stack.length > 0) {
174
+ const d = stack.pop();
175
+ if (d === undefined) break;
176
+ let entries: Dirent[];
177
+ try {
178
+ entries = await readdir(d, { withFileTypes: true });
179
+ } catch {
180
+ continue;
181
+ }
182
+ for (const entry of entries) {
183
+ const full = join(d, entry.name);
184
+ if (entry.isDirectory()) {
185
+ stack.push(full);
186
+ } else if (entry.isFile()) {
187
+ try {
188
+ const st = await stat(full);
189
+ if (st.mtimeMs >= cutoffMs) {
190
+ return { stale: false, bytes: 0, fileCount: 0 };
191
+ }
192
+ bytes += st.size;
193
+ fileCount++;
194
+ } catch {
195
+ // vanished/unreadable — skip
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return { stale: true, bytes, fileCount };
201
+ }
202
+
120
203
  async function sweepHostCache(ctx: {
121
- config: { retention?: Record<string, string> };
204
+ config: { retention?: Record<string, string>; retention_dirs?: Record<string, string> };
122
205
  }): Promise<void> {
123
206
  const log = getAppLogger();
124
207
  const retention = ctx.config.retention ?? {};
208
+ const retentionDirs = ctx.config.retention_dirs ?? {};
125
209
  const now = Date.now();
126
210
  const results: SweepResult[] = [];
127
211
 
@@ -154,6 +238,36 @@ async function sweepHostCache(ctx: {
154
238
  );
155
239
  }
156
240
 
241
+ // retention_dirs — whole-directory prune for build caches (Rust target/).
242
+ // A matched dir is deleted ENTIRELY only when its newest file is older than
243
+ // the cutoff, so an actively-built repo keeps its cache and an abandoned one
244
+ // is reclaimed whole (never the partial slice that would corrupt the cache).
245
+ for (const [rawPattern, rawDuration] of Object.entries(retentionDirs)) {
246
+ const days = parseDays(rawDuration);
247
+ if (days === null || days <= 0) {
248
+ log.warn(
249
+ `[host-cache-sweep] skipping retention_dirs "${rawPattern}" — value "${rawDuration}" is not a day-granularity duration (expected e.g. "30 days")`,
250
+ );
251
+ continue;
252
+ }
253
+ const cutoffMs = now - days * MS_PER_DAY;
254
+ for (const dir of expandGlob(resolveHostPath(rawPattern))) {
255
+ try {
256
+ const st = await stat(dir);
257
+ if (!st.isDirectory()) continue;
258
+ } catch {
259
+ continue; // glob match that is not a dir (or vanished) — skip
260
+ }
261
+ const { stale, bytes, fileCount } = await checkDirStale(dir, cutoffMs);
262
+ if (!stale) continue;
263
+ await rm(dir, { recursive: true, force: true });
264
+ results.push({ path: dir, scanned: fileCount, deleted: fileCount, bytesFreed: bytes });
265
+ log.info(
266
+ `[host-cache-sweep] ${dir}: no build in ${rawDuration}; reclaimed whole dir — ${fileCount} file(s), ${formatBytes(bytes)}`,
267
+ );
268
+ }
269
+ }
270
+
157
271
  const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
158
272
  const totalBytes = results.reduce((sum, r) => sum + r.bytesFreed, 0);
159
273
  log.info(
@@ -37,7 +37,7 @@ export async function mountOrpcRouter(
37
37
  );
38
38
 
39
39
  // No generated ORPC router is the EXPECTED state for daemon-only / boot-hook
40
- // services (session-picker, package-poller, etc.) that own no DB schema or
40
+ // services (session-picker, dep-sync, etc.) that own no DB schema or
41
41
  // api_routes — they serve no /api/rpc surface. Skip silently: importing a
42
42
  // nonexistent module would throw into the catch below and surface a
43
43
  // misleading "ORPC router not mounted" WARN on every boot. The catch is
@@ -21,7 +21,7 @@ export declare function resolveBrandFaviconPath(brandOrg: string | undefined): P
21
21
  * pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
22
22
  * `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
23
23
  * store takes 6s locally / up to 21s in prod, and the in-process cache resets
24
- * on every restart (deploys, package-poller). Without warming, the first
24
+ * on every restart (deploys, dep-sync). Without warming, the first
25
25
  * /favicon.ico after each restart stalls for that duration (operator-reported
26
26
  * 23s). Fire-and-forget — runs in the background during boot, never blocks.
27
27
  */
@@ -55,7 +55,7 @@ export async function resolveBrandFaviconPath(
55
55
  * pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
56
56
  * `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
57
57
  * store takes 6s locally / up to 21s in prod, and the in-process cache resets
58
- * on every restart (deploys, package-poller). Without warming, the first
58
+ * on every restart (deploys, dep-sync). Without warming, the first
59
59
  * /favicon.ico after each restart stalls for that duration (operator-reported
60
60
  * 23s). Fire-and-forget — runs in the background during boot, never blocks.
61
61
  */