@teamscala/service-boot 0.23.334 → 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.
- package/package.json +1088 -1089
- package/src/auth-gate.ts +2 -3
- package/src/build-client-assets.test.ts +1 -1
- package/src/build-client-assets.ts +1 -1
- package/src/fn-routers/orchestrator-registration-router.d.ts +17 -3
- package/src/fn-routers/orchestrator-registration-router.ts +17 -3
- package/src/fn-routers/orchestrator-registration.live.test.ts +23 -2
- package/src/fn-routers/orchestrator-registration.test.ts +23 -0
- package/src/fn-routers/task-sync-assign.test.ts +1 -0
- package/src/fn-routers/task-sync-bind.test.ts +18 -18
- package/src/fn-routers/task-sync-brief.d.ts +7 -8
- package/src/fn-routers/task-sync-brief.test.ts +10 -12
- package/src/fn-routers/task-sync-brief.ts +9 -10
- package/src/fn-routers/task-sync-dispatch.d.ts +40 -54
- package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
- package/src/fn-routers/task-sync-dispatch.ts +57 -100
- package/src/fn-routers/task-sync-lane.test.ts +36 -0
- package/src/fn-routers/task-sync-lane.ts +8 -19
- package/src/fn-routers/task-sync-projection.d.ts +23 -0
- package/src/fn-routers/task-sync-projection.test.ts +26 -0
- package/src/fn-routers/task-sync-projection.ts +51 -0
- package/src/fn-routers/task-sync-router.d.ts +30 -23
- package/src/fn-routers/task-sync-router.test.ts +14 -2
- package/src/fn-routers/task-sync-router.ts +72 -114
- package/src/handlers/sweep-host-cache.d.ts +21 -1
- package/src/handlers/sweep-host-cache.test.ts +85 -0
- package/src/handlers/sweep-host-cache.ts +118 -4
- package/src/mount-orpc-router.ts +1 -1
- package/src/resolve-brand-favicon.d.ts +1 -1
- package/src/resolve-brand-favicon.ts +1 -1
|
@@ -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
|
|
25
|
-
*
|
|
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
|
|
140
|
-
//
|
|
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
|
|
219
|
-
*
|
|
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
696
|
-
//
|
|
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)
|
|
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 {
|
|
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
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
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
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
|
875
|
-
// session_start) already stamped kind_data.lane.session
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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(
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
*/
|