Skip to content

Commit 31b655d

Browse files
authored
feat(everything-dev): bos clean — the local-only rule's exit valve (ADR 0047, #844) (#855)
One command that removes regenerable .bos state and refuses the artifacts that are truth or identity. The partition (planClean/executeClean) is the contract, unit-tested; the handler and renderer just run and show it. - removes: logs (except files touched within an hour — live sessions), bundles, regression, generated, bundle-cache, snapshot, sync-backup, import-backup, compose-retired, manifest cache, resolved config, build cache - never touches (reported as kept, with reason): db/ (PGlite data), session.json (credentials), infra-state.json (port prefs), deploy-state.json (cleaning costs full re-uploads) - --dry-run reports per-category bytes without deleting - route POST /clean in the CLI contract; smoke-tested end to end
1 parent 210c88a commit 31b655d

9 files changed

Lines changed: 476 additions & 1 deletion

File tree

‎.changeset/bos-clean.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"everything-dev": minor
3+
---
4+
5+
`bos clean` — the local-only rule's exit valve (ADR 0047, #844)
6+
7+
One command that removes regenerable `.bos` state and refuses the artifacts that are truth or identity:
8+
9+
- **Removes**: `logs/` (except files touched within the last hour — a live session is appending), `bundles/` (regression container staging), `regression/` (seeds + image staging), `generated/`, `bundle-cache/`, `snapshot/`, `sync-backup/`, `import-backup/`, `compose-retired/`, `manifest.json` (release-manifest cache), `bos.resolved-config.json`, `build-cache.json`.
10+
- **Never touches**: `db/` (PGlite data), `session.json` (credentials — that's `bos logout`), `infra-state.json` (machine port prefs), `deploy-state.json` (cleaning the skip-unchanged accelerator would silently cost full re-uploads). The guarded set is reported as `kept` with its reason.
11+
- `--dry-run` reports per-category bytes without deleting; `--config-dir` targets another checkout.
12+
- Route `POST /clean` in the CLI contract; the partition (`planClean`/`executeClean` in `services/clean.ts`) is the unit-tested contract surface.

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,14 +297,15 @@ Two resolution rules keep this safe (ADR 0018): **bundler-configuration code res
297297
```bash
298298
bos sync # Pull updates from published config/template state (release-scoped — ADR 0034)
299299
bos lock # Freeze the resolved composition + sync baseline into bos.lock.json (ADR 0042/0047) — commit the result; --check is the CI drift gate
300+
bos clean # Remove regenerable .bos state (logs, staging, caches) — --dry-run previews; never touches db, session, ports, deploy-state
300301
bos upgrade # Check for new versions, update, then sync
301302
bos upgrade --check # Manual drift alarm: exit 1 when updates are available (prints what drifted)
302303
bos manifest # Show the resolved release manifest (--check adds the compat gate)
303304
bos publish # Re-publish the resolved config to FastKV (config-only; authored hand-edits merge over what's live)
304305
bos deploy # Deploy train: preflight → build → upload bundles → publish config + release manifest → image (pre-pushed digest or local docker build) → post-publish smoke (--smoke) → Railway
305306
```
306307

307-
**One committed artifact (ADR 0047):** `bos.lock.json` at the repo root is the only committed runtime state — it freezes the composition (release pin, remote digests, SRI integrity, ADR 0042) and carries the sync baseline (`template.sync`) that `bos sync`'s conflict detection reads. Everything under `.bos/` is local-only; a locally-modified template file with no recorded baseline conflicts instead of losing to the template silently, and the upgrade migration absorbs any legacy `.bos/sync-snapshot.json` into the lock. The drift trio: `bos lock --check` (composition vs committed lock), `bos verify` (served bytes + attribution vs pins, ADR 0046), sync conflicts (local edits vs template via the baseline).
308+
**One committed artifact (ADR 0047):** `bos.lock.json` at the repo root is the only committed runtime state — it freezes the composition (release pin, remote digests, SRI integrity, ADR 0042) and carries the sync baseline (`template.sync`) that `bos sync`'s conflict detection reads. Everything under `.bos/` is local-only; a locally-modified template file with no recorded baseline conflicts instead of losing to the template silently, and the upgrade migration absorbs any legacy `.bos/sync-snapshot.json` into the lock. The drift trio: `bos lock --check` (composition vs committed lock), `bos verify` (served bytes + attribution vs pins, ADR 0046), sync conflicts (local edits vs template via the baseline). `bos clean` is the local-only rule's exit valve — it removes the regenerable side (logs, bundle/regression staging, caches, backups) and refuses the truth: `db/`, `session.json`, `infra-state.json`, `deploy-state.json`.
308309

309310
**Release manifest (ADR 0034):** one release per framework version — child configs record `release: "<framework-version>"` in the authored config and everything else derives from the resolved manifest (published to FastKV as the thin tag `apps/<root-account>/<root-gateway>/manifest.json` naming the immutable `versions/<v>.json` record — ADR 0040 — and staged in-image): the upgrade catalog, the image ref (`ref@v<version>` + registry digest at deploy time, never hand-pinned), the sync source (the release tag, not `main` HEAD), and the compat contract (`bos manifest --check`, the deploy smoke gate, and `bos mf check`). Field absent → latest manifest via the extends chain. `ci.image` is removed — a deploy fails fast when an authored config sets it (children target the image via `release:`; the root derives its image from `repository`). **Release lifecycle (#546):** a root republish overwrites the `manifest.json` thin tag — a stale child release resolves only while the fresh `.bos/manifest.json` cache (24h TTL) stands in, then fails loud (`release <version> not found`); the repair is `bos upgrade` (re-targets the latest release). The singletons are policed at every plane: each uploaded bundle's version manifest records the shared-singleton versions its build embedded (`shared`), the publish train's truth gate refuses to publish a build that disagrees with the release manifest, and the host boot guard raises `SharedSingletonSkewError` (mirrored by `bos mf check`'s UI gate) when composed remotes disagree — the named error replacing the federation runtime's cryptic `__webpack_modules__` negotiation failure. The lifecycle is exercised end-to-end by `packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts`.
310311

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { colors, icons } from "../../utils/theme";
2+
import type { ResultRenderer } from "./context";
3+
4+
function formatBytes(bytes: number): string {
5+
if (bytes < 1024) return `${bytes} B`;
6+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
7+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
9+
}
10+
11+
export const cleanRenderers: Record<string, ResultRenderer> = {
12+
clean: async (result, _ctx) => {
13+
console.log();
14+
if (result.status === "error") {
15+
console.error(colors.error(`${icons.err} ${result.error ?? "bos clean failed"}`));
16+
return;
17+
}
18+
for (const entry of result.entries) {
19+
if (entry.action === "kept") {
20+
console.log(` ${colors.dim(`${icons.ok} kept`)} ${entry.id} — ${entry.reason ?? ""}`);
21+
continue;
22+
}
23+
if (entry.action === "absent") continue;
24+
const verb = entry.action === "would-remove" ? "would remove" : "removed";
25+
console.log(
26+
` ${colors.green(icons.ok)} ${verb.padEnd(12)} ${entry.id.padEnd(16)} ${formatBytes(entry.bytes)}` +
27+
(entry.reason ? ` ${colors.dim(`(${entry.reason})`)}` : ""),
28+
);
29+
}
30+
const label = result.dryRun ? "Would free" : "Freed";
31+
console.log(`\n ${colors.green(`${label}:`)} ${formatBytes(result.freedBytes)}`);
32+
if (result.dryRun) {
33+
console.log(colors.dim(" bos clean performs the removals"));
34+
}
35+
console.log();
36+
},
37+
};

‎packages/everything-dev/src/cli/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { installBundleFetchFromEnv } from "../services/resolution/resolver";
99
import { printBanner } from "../utils/banner";
1010
import { formatUnknownError } from "../utils/errors";
1111
import { findCommandDescriptor } from "./catalog";
12+
import { cleanRenderers } from "./commands/clean";
1213
import type { CommandContext } from "./commands/context";
1314
import { dbRenderers } from "./commands/db";
1415
import { runDev, runStart } from "./commands/dev";
@@ -36,6 +37,7 @@ const preCalls: Record<string, (ctx: CommandContext) => Promise<void>> = {
3637
};
3738

3839
const renderers = {
40+
...cleanRenderers,
3941
...dbRenderers,
4042
...inspectRenderers,
4143
...lifecycleRenderers,

‎packages/everything-dev/src/contract.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,27 @@ export const LockResultSchema = z.object({
363363
code: z.number().optional(),
364364
});
365365

366+
export const CleanOptionsSchema = z.object({
367+
configDir: z.string().optional().describe("Config directory to clean (defaults to cwd)"),
368+
dryRun: z.boolean().default(false).describe("Report what would be removed without deleting"),
369+
});
370+
371+
export const CleanEntrySchema = z.object({
372+
id: z.string(),
373+
path: z.string(),
374+
bytes: z.number(),
375+
action: z.enum(["removed", "would-remove", "kept", "absent"]),
376+
reason: z.string().optional(),
377+
});
378+
379+
export const CleanResultSchema = z.object({
380+
status: z.enum(["ok", "error"]),
381+
dryRun: z.boolean(),
382+
entries: z.array(CleanEntrySchema),
383+
freedBytes: z.number(),
384+
error: z.string().optional(),
385+
});
386+
366387
export const DeployOptionsSchema = z.object({
367388
statusList: z.boolean().default(false),
368389
env: z
@@ -1266,6 +1287,7 @@ export const commandOptionSchemas = {
12661287
pluginPublish: PluginPublishOptionsSchema,
12671288
publish: PublishOptionsSchema,
12681289
lock: LockOptionsSchema,
1290+
clean: CleanOptionsSchema,
12691291
rollback: RollbackOptionsSchema,
12701292
promote: PromoteOptionsSchema,
12711293
deploy: DeployOptionsSchema,
@@ -1329,6 +1351,10 @@ export const bosContract = oc.router({
13291351
.route({ method: "POST", path: "/lock" })
13301352
.input(LockOptionsSchema)
13311353
.output(LockResultSchema),
1354+
clean: oc
1355+
.route({ method: "POST", path: "/clean" })
1356+
.input(CleanOptionsSchema)
1357+
.output(CleanResultSchema),
13321358
rollback: oc
13331359
.route({ method: "POST", path: "/rollback" })
13341360
.input(RollbackOptionsSchema)
@@ -1446,6 +1472,7 @@ export type PublishOptions = z.infer<typeof PublishOptionsSchema>;
14461472
export type PublishResult = z.infer<typeof PublishResultSchema>;
14471473
export type LockOptions = z.infer<typeof LockOptionsSchema>;
14481474
export type LockResult = z.infer<typeof LockResultSchema>;
1475+
export type CleanResult = z.infer<typeof CleanResultSchema>;
14491476
export type RollbackResult = z.infer<typeof RollbackResultSchema>;
14501477
export type PromoteOptions = z.infer<typeof PromoteOptionsSchema>;
14511478
export type PromoteResult = z.infer<typeof PromoteResultSchema>;
@@ -1578,6 +1605,12 @@ export const cliCommandMeta = {
15781605
"Freeze the resolved composition into bos.lock.json (--check verifies drift for CI — exit 1)",
15791606
interactive: false,
15801607
},
1608+
clean: {
1609+
commandPath: ["clean"],
1610+
summary:
1611+
"Remove regenerable .bos state (logs, staging, caches) — never db, session, ports, deploy-state (--dry-run previews)",
1612+
interactive: false,
1613+
},
15811614
rollback: {
15821615
commandPath: ["rollback"],
15831616
summary: "Republish an earlier config snapshot from the registry publish history",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Effect } from "effect";
2+
import type { CleanResult } from "../contract";
3+
import { executeClean } from "../services/clean";
4+
import type { BosBuilder } from "./shared";
5+
6+
export function registerClean(builder: BosBuilder) {
7+
return {
8+
clean: builder.clean.effect(function* (_opts, input) {
9+
return yield* Effect.try({
10+
try: () => executeClean(input.configDir ?? process.cwd(), { dryRun: input.dryRun }),
11+
catch: (error) => ({
12+
status: "error" as const,
13+
dryRun: input.dryRun,
14+
entries: [],
15+
freedBytes: 0,
16+
error: error instanceof Error ? error.message : String(error),
17+
}),
18+
});
19+
}),
20+
};
21+
}

‎packages/everything-dev/src/plugin.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DrizzleKit, makeDrizzleKitLive } from "./db/drizzle-kit";
1111
import { migrateLegacyComposeState } from "./env/legacy-databases";
1212
import { ProjectEnv, ProjectEnvLive } from "./env/project-env";
1313
import { registerBuild } from "./handlers/build";
14+
import { registerClean } from "./handlers/clean";
1415
import { registerConfig } from "./handlers/config";
1516
import { registerDb } from "./handlers/db";
1617
import { registerDeploy } from "./handlers/deploy";
@@ -142,6 +143,7 @@ export default createPlugin({
142143
...registerBuild(builder),
143144
...registerDeploy(builder),
144145
...registerLock(builder),
146+
...registerClean(builder),
145147
...registerSmoke(builder),
146148
...registerKeys(builder),
147149
...registerSecrets(builder),
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
/**
5+
* `bos clean` — the local-only rule's exit valve (ADR 0047): one command that
6+
* removes regenerable `.bos` state and refuses the artifacts that are truth
7+
* or identity. The partition is the contract; the handler just runs it.
8+
*/
9+
10+
export interface CleanTarget {
11+
/** dotted category id — the report key */
12+
id: string;
13+
/** absolute path under the config dir */
14+
path: string;
15+
/** `dir` removes the tree, `files` removes matching files in place (logs —
16+
* the dir itself stays; a live session's fresh log survives), `file`
17+
* removes a single file */
18+
kind: "dir" | "files" | "file";
19+
/** present ⇒ never deleted: the never-touch invariant, with the reason */
20+
guarded?: string;
21+
}
22+
23+
/** The absolute never-touch list — truth (PGlite data), identity (session
24+
* credentials), machine prefs (port pins), and the publish accelerator whose
25+
* cleaning would silently cost full re-uploads. `bos clean` reports these as
26+
* kept, always. */
27+
export const CLEAN_NEVER_TOUCH: ReadonlyArray<{ name: string; reason: string }> = [
28+
{ name: "db", reason: "PGlite data — the app's actual state" },
29+
{ name: "session.json", reason: "login credentials — bos logout, not bos clean" },
30+
{ name: "infra-state.json", reason: "machine port prefs" },
31+
{
32+
name: "deploy-state.json",
33+
reason: "skip-unchanged accelerator — cleaning costs full re-uploads",
34+
},
35+
];
36+
37+
/** Files touched within this window are never cleaned — a live dev session
38+
* is appending to its log and `bos logs` reads it. */
39+
const LIVE_LOG_WINDOW_MS = 60 * 60 * 1000;
40+
41+
function bosPath(configDir: string, name: string): string {
42+
return join(configDir, ".bos", name);
43+
}
44+
45+
export function planClean(configDir: string): CleanTarget[] {
46+
const removable: CleanTarget[] = [
47+
{ id: "logs", path: bosPath(configDir, "logs"), kind: "files" },
48+
{ id: "bundles", path: bosPath(configDir, "bundles"), kind: "dir" },
49+
{ id: "regression", path: bosPath(configDir, "regression"), kind: "dir" },
50+
{ id: "generated", path: bosPath(configDir, "generated"), kind: "dir" },
51+
{ id: "bundle-cache", path: bosPath(configDir, "bundle-cache"), kind: "dir" },
52+
{ id: "snapshot", path: bosPath(configDir, "snapshot"), kind: "dir" },
53+
{ id: "sync-backup", path: bosPath(configDir, "sync-backup"), kind: "dir" },
54+
{ id: "import-backup", path: bosPath(configDir, "import-backup"), kind: "dir" },
55+
{ id: "compose-retired", path: bosPath(configDir, "compose-retired"), kind: "dir" },
56+
{ id: "manifest-cache", path: bosPath(configDir, "manifest.json"), kind: "file" },
57+
{ id: "resolved-config", path: bosPath(configDir, "bos.resolved-config.json"), kind: "file" },
58+
{ id: "build-cache", path: bosPath(configDir, "build-cache.json"), kind: "file" },
59+
];
60+
const guarded: CleanTarget[] = CLEAN_NEVER_TOUCH.map(({ name, reason }) => ({
61+
id: name,
62+
path: bosPath(configDir, name),
63+
kind: existsSync(bosPath(configDir, name)) ? "dir" : "file",
64+
guarded: reason,
65+
}));
66+
return [...removable, ...guarded];
67+
}
68+
69+
export function dirSize(path: string): number {
70+
let total = 0;
71+
try {
72+
for (const name of readdirSync(path)) {
73+
const full = join(path, name);
74+
const stat = statSync(full);
75+
if (stat.isDirectory()) {
76+
total += dirSize(full);
77+
} else {
78+
total += stat.size;
79+
}
80+
}
81+
} catch {
82+
return total;
83+
}
84+
return total;
85+
}
86+
87+
export interface CleanEntryResult {
88+
id: string;
89+
path: string;
90+
bytes: number;
91+
action: "removed" | "would-remove" | "kept" | "absent";
92+
/** `kept` — the never-touch reason; `removed` on logs — fresh files skipped */
93+
reason?: string;
94+
}
95+
96+
export interface CleanReport {
97+
status: "ok";
98+
dryRun: boolean;
99+
entries: CleanEntryResult[];
100+
freedBytes: number;
101+
}
102+
103+
function removeTree(target: CleanTarget): number {
104+
const bytes = dirSize(target.path);
105+
rmSync(target.path, { recursive: true, force: true });
106+
return bytes;
107+
}
108+
109+
function removeFile(target: CleanTarget): number {
110+
const bytes = statSync(target.path).size;
111+
rmSync(target.path, { force: true });
112+
return bytes;
113+
}
114+
115+
function cleanLogs(target: CleanTarget, dryRun: boolean): CleanEntryResult {
116+
const now = Date.now();
117+
let bytes = 0;
118+
let fresh = 0;
119+
let names: string[] = [];
120+
try {
121+
names = readdirSync(target.path);
122+
} catch {
123+
return { id: target.id, path: target.path, bytes: 0, action: "absent" };
124+
}
125+
for (const name of names) {
126+
const full = join(target.path, name);
127+
let size = 0;
128+
let mtimeMs = 0;
129+
try {
130+
const stat = statSync(full);
131+
if (!stat.isFile()) continue;
132+
size = stat.size;
133+
mtimeMs = stat.mtimeMs;
134+
} catch {
135+
continue;
136+
}
137+
if (now - mtimeMs < LIVE_LOG_WINDOW_MS) {
138+
fresh++;
139+
continue;
140+
}
141+
bytes += size;
142+
if (!dryRun) rmSync(full, { force: true });
143+
}
144+
return {
145+
id: target.id,
146+
path: target.path,
147+
bytes,
148+
action: bytes > 0 || fresh > 0 ? (dryRun ? "would-remove" : "removed") : "absent",
149+
...(fresh > 0 ? { reason: `${fresh} fresh file(s) kept (live sessions)` } : {}),
150+
};
151+
}
152+
153+
export function executeClean(configDir: string, opts: { dryRun?: boolean } = {}): CleanReport {
154+
const dryRun = opts.dryRun ?? false;
155+
const entries: CleanEntryResult[] = [];
156+
let freedBytes = 0;
157+
158+
for (const target of planClean(configDir)) {
159+
if (target.guarded) {
160+
entries.push({
161+
id: target.id,
162+
path: target.path,
163+
bytes: 0,
164+
action: "kept",
165+
reason: target.guarded,
166+
});
167+
continue;
168+
}
169+
if (!existsSync(target.path)) {
170+
entries.push({ id: target.id, path: target.path, bytes: 0, action: "absent" });
171+
continue;
172+
}
173+
if (target.kind === "files") {
174+
const entry = cleanLogs(target, dryRun);
175+
entries.push(entry);
176+
freedBytes += entry.bytes;
177+
continue;
178+
}
179+
const bytes = target.kind === "dir" ? dirSize(target.path) : statSync(target.path).size;
180+
if (!dryRun) {
181+
if (target.kind === "dir") removeTree(target);
182+
else removeFile(target);
183+
}
184+
entries.push({
185+
id: target.id,
186+
path: target.path,
187+
bytes,
188+
action: dryRun ? "would-remove" : "removed",
189+
});
190+
freedBytes += bytes;
191+
}
192+
193+
return { status: "ok", dryRun, entries, freedBytes };
194+
}

0 commit comments

Comments
 (0)