@celilo/cli 1.12.0 → 1.13.0

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.
Files changed (47) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +17 -2
  3. package/package.json +3 -3
  4. package/src/cli/commands/alerts-list.ts +16 -1
  5. package/src/cli/commands/backup-list.test.ts +82 -1
  6. package/src/cli/commands/backup-list.ts +113 -4
  7. package/src/cli/commands/console.ts +122 -0
  8. package/src/cli/commands/module-list.ts +3 -41
  9. package/src/cli/completion.ts +5 -0
  10. package/src/cli/index.ts +25 -1
  11. package/src/console/closure.test.ts +246 -0
  12. package/src/console/closure.ts +208 -0
  13. package/src/console/control-plane-boundary.test.ts +75 -0
  14. package/src/console/projection.test.ts +231 -0
  15. package/src/console/projection.ts +327 -0
  16. package/src/db/schema.ts +19 -14
  17. package/src/hooks/broker.test.ts +4 -6
  18. package/src/hooks/executor.test.ts +85 -4
  19. package/src/hooks/executor.ts +164 -9
  20. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  21. package/src/hooks/hook-state-dir.test.ts +14 -2
  22. package/src/hooks/hook-timeout.test.ts +2 -4
  23. package/src/hooks/hook-trespass.test.ts +50 -5
  24. package/src/hooks/jail.test.ts +370 -0
  25. package/src/hooks/jail.ts +491 -0
  26. package/src/hooks/mount-set.ts +24 -0
  27. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  28. package/src/manifest/schema.ts +35 -0
  29. package/src/manifest/validate.test.ts +142 -0
  30. package/src/manifest/validate.ts +101 -0
  31. package/src/module/import.test.ts +116 -0
  32. package/src/module/import.ts +73 -1
  33. package/src/module/packaging/audit.ts +103 -1
  34. package/src/module/packaging/classify-module-path.test.ts +36 -0
  35. package/src/module/packaging/package-rules.ts +18 -0
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/module-business-baseline.ts +12 -0
  38. package/src/services/alerting/observed-health.ts +71 -0
  39. package/src/services/api-principal-enrolment.test.ts +179 -0
  40. package/src/services/api-principal-enrolment.ts +103 -0
  41. package/src/services/audit/backups.ts +10 -1
  42. package/src/services/backup-metadata.ts +19 -11
  43. package/src/services/consumer-cleanup.ts +31 -5
  44. package/src/services/instance-ops.test.ts +302 -0
  45. package/src/services/instance-ops.ts +292 -0
  46. package/src/services/module-instances.test.ts +428 -42
  47. package/src/services/module-instances.ts +219 -26
@@ -69,10 +69,28 @@ export type ModulePathClass = 'package' | 'derived' | 'unknown';
69
69
  * composes this function with `includeNodeModulesPath` rather than restating
70
70
  * either rule.
71
71
  */
72
+ export const SUBMODULES_DIR = 'submodules';
73
+
72
74
  export function classifyModulePath(relPath: string): ModulePathClass {
73
75
  const segments = relPath.split('/');
74
76
  const name = segments[segments.length - 1] ?? '';
75
77
 
78
+ // A submodule's tree obeys exactly the same rules as a module's tree
79
+ // (openspec/changes/submodules D1: a submodule declares everything an
80
+ // ordinary module declares). Every rule below keys on `segments[0]`, so
81
+ // without this a nested tree silently gets different answers: a submodule's
82
+ // own `e2e/` would be checksummed where a module's is excluded, because
83
+ // `segments[0]` reads `submodules` rather than `e2e`.
84
+ //
85
+ // Recursing on the remainder means one set of rules rather than two that
86
+ // drift, and every rule applies at both levels: a submodule's `e2e/` is
87
+ // excluded, its `celilo/types.d.ts` and `cookies.json` are derived, its
88
+ // `.DS_Store` is a finding. `submodules/` and `submodules/<name>` themselves
89
+ // stay `package`, which is right: the directories ARE shipped bytes.
90
+ if (segments[0] === SUBMODULES_DIR && segments.length > 2) {
91
+ return classifyModulePath(segments.slice(2).join('/'));
92
+ }
93
+
76
94
  // The module's own e2e/ tree is tests plus their deps, including a
77
95
  // node_modules of its own. Excluded whole, before anything below.
78
96
  if (segments[0] === 'e2e') return 'unknown';
@@ -77,6 +77,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
77
77
  version: '1.0.0',
78
78
  hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
79
79
  },
80
+ control_plane_api: {
81
+ version: '1.0.0',
82
+ hash: '2c5d41d37a9a828a8b9a6dc633a2d48f24670d0831d879ec18426f0f9f8e5be5',
83
+ },
80
84
  private_web: {
81
85
  version: '1.0.0',
82
86
  hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
@@ -85,6 +89,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
85
89
  version: '1.0.0',
86
90
  hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
87
91
  },
92
+ celilo_module_deploy_worker: {
93
+ version: '1.0.0',
94
+ hash: '0b0e1f26aab0ebcf5d28283b9c5ba711f6d182f8f7ad474379c4ab02c354a2b8',
95
+ },
88
96
  web_routes: {
89
97
  version: '1.0.0',
90
98
  hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
@@ -111,6 +111,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
111
111
  count: 1,
112
112
  why: 'PERMANENT — the public_web implementation naming its own table; the capability package doing its job',
113
113
  },
114
+ {
115
+ file: 'packages/capabilities/src/capability-contract.ts',
116
+ capability: 'control_plane_api',
117
+ count: 1,
118
+ why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
119
+ },
114
120
  {
115
121
  file: 'packages/capabilities/src/capability-contract.ts',
116
122
  capability: 'web_routes',
@@ -327,6 +333,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
327
333
  count: 1,
328
334
  why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
329
335
  },
336
+ {
337
+ file: 'packages/capabilities/src/capability-contract.ts',
338
+ capability: 'celilo_module_deploy_worker',
339
+ count: 1,
340
+ why: 'PERMANENT — X10, a contract declaration with no implementation; this is the package doing its job',
341
+ },
330
342
  {
331
343
  file: 'packages/capabilities/src/capability-contract.ts',
332
344
  capability: 'control_plane_vpn',
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Observed health per module, derived from live alerts and enabled monitors.
3
+ *
4
+ * Extracted from `module list` because it now has a second consumer. The web
5
+ * console shows the same column, and celilo has already decided how this is
6
+ * computed: `nextModuleState()` documents why observation must NOT drive a
7
+ * module's lifecycle state, since letting a recurring check write it would
8
+ * redefine VERIFIED from "someone verified this" to "it was up recently" and
9
+ * churn it on every transient failure.
10
+ *
11
+ * So this stays a derivation and never a stored field, and both consumers read
12
+ * it here rather than each growing a copy.
13
+ */
14
+
15
+ import { eq } from 'drizzle-orm';
16
+ import type { DbClient } from '../../db/client';
17
+ import { modules as modulesTable, monitors } from '../../db/schema';
18
+ import { moduleHealthCell } from './format';
19
+ import { loadAllLiveAlerts, summariseByModule } from './store';
20
+
21
+ /** The structured form. `cell` is the display string the CLI column prints. */
22
+ export interface ObservedHealth {
23
+ cell: string;
24
+ monitored: boolean;
25
+ firingCount: number;
26
+ suppressed: boolean;
27
+ }
28
+
29
+ /**
30
+ * Observed health per module: `ok`, `N firing`, `suppressed`, or
31
+ * `not observed`.
32
+ *
33
+ * Only computed for DEPLOYED modules — a module still being imported has
34
+ * nothing to observe, and reporting it as unwatched would be noise rather
35
+ * than a finding.
36
+ */
37
+ export function loadObservedHealth(db: DbClient): Map<string, string> {
38
+ return new Map([...loadObservedHealthDetail(db)].map(([id, h]) => [id, h.cell]));
39
+ }
40
+
41
+ /**
42
+ * The same derivation, with the inputs kept rather than collapsed into a
43
+ * string. The console needs the firing COUNT and the not-observed case as
44
+ * separate facts: it renders them differently, and parsing them back out of a
45
+ * terminal column would be a second, worse implementation.
46
+ */
47
+ export function loadObservedHealthDetail(db: DbClient): Map<string, ObservedHealth> {
48
+ const monitored = new Set(
49
+ db
50
+ .select({ target: monitors.target })
51
+ .from(monitors)
52
+ .where(eq(monitors.enabled, true))
53
+ .all()
54
+ .map((m) => m.target),
55
+ );
56
+ const byModule = summariseByModule(loadAllLiveAlerts(db));
57
+
58
+ const result = new Map<string, ObservedHealth>();
59
+ for (const module of db.select().from(modulesTable).all()) {
60
+ if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
61
+
62
+ const summary = byModule.get(module.id) ?? [];
63
+ const input = {
64
+ monitored: monitored.has(module.id),
65
+ firingCount: summary.filter((s) => !s.suppressed).length,
66
+ suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
67
+ };
68
+ result.set(module.id, { ...input, cell: moduleHealthCell(input) });
69
+ }
70
+ return result;
71
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `control_plane_api`, and the one property that has to hold forever.
3
+ *
4
+ * The console reads celilo's own state. What it must never acquire is the
5
+ * ability to change it — and the way that would happen is not somebody adding
6
+ * `module:deploy` on purpose. It is a new verb appearing in the registry and
7
+ * quietly falling on the wrong side of a grant list nobody re-reads.
8
+ *
9
+ * So the grants are DERIVED, and these assert the derivation rather than a
10
+ * snapshot of its output. A test that pinned today's list would pass forever
11
+ * while the thing it protects rotted.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { COMMANDS, readOnlyGrants } from '@celilo/core';
18
+ import { closeDb } from '../db/client';
19
+ import { getPrincipalByName, isAuthorized } from './api-access';
20
+ import {
21
+ controlPlaneReadGrants,
22
+ enrolControlPlanePrincipal,
23
+ revokeControlPlanePrincipal,
24
+ } from './api-principal-enrolment';
25
+
26
+ /** A syntactically real ed25519 public key. Nothing here ever sees a private one. */
27
+ const PUBLIC_KEY =
28
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB7cWlXqPn+aGaNKm2Rt1cwj6cVy0hL0hVXHhWJKtLZ9 celilo-web-console';
29
+ const OTHER_KEY =
30
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILlqL4YQ1MDe0J0X2ZQ4pXFqNbXK9y7dQe2Vd1kZ8sWq rotated';
31
+
32
+ describe('the grant set', () => {
33
+ test('is exactly the read-only ops the classifier derives', () => {
34
+ // Not a snapshot. If this were a literal list it would pass forever while a
35
+ // newer celilo added verbs it no longer covered.
36
+ expect(controlPlaneReadGrants()).toEqual(readOnlyGrants(COMMANDS));
37
+ });
38
+
39
+ test('is not empty, so the derivation is doing something', () => {
40
+ // The failure mode a derived list has that a literal one does not: a
41
+ // classifier change makes it return nothing and every read starts failing
42
+ // closed. That is safe and completely broken, so it is asserted.
43
+ expect(controlPlaneReadGrants().length).toBeGreaterThan(10);
44
+ });
45
+
46
+ test('contains no obviously mutating op', () => {
47
+ // A coarse net on purpose. The classifier is the real gate; this catches the
48
+ // case where the classifier itself is changed to admit writes, which no test
49
+ // of the classifier's own output would notice.
50
+ const mutating = /:(deploy|remove|delete|create|set|add|uninstall|restore|grant|revoke|pause)$/;
51
+ expect(controlPlaneReadGrants().filter((op) => mutating.test(op))).toEqual([]);
52
+ });
53
+ });
54
+
55
+ describe('enrolment', () => {
56
+ let dir: string;
57
+
58
+ beforeEach(async () => {
59
+ // The service reaches for the global `getDb()`, so isolation is by
60
+ // CELILO_DB_PATH rather than by passing a client around.
61
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
62
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
63
+ closeDb();
64
+ });
65
+
66
+ afterEach(() => {
67
+ closeDb();
68
+ rmSync(dir, { recursive: true, force: true });
69
+ });
70
+
71
+ test('creates a principal carrying the derived grants', async () => {
72
+ const result = await enrolControlPlanePrincipal({
73
+ name: 'celilo-web-console',
74
+ publicKey: PUBLIC_KEY,
75
+ });
76
+ expect(result.created).toBe(true);
77
+ expect(result.grants).toEqual(controlPlaneReadGrants());
78
+
79
+ const stored = await getPrincipalByName('celilo-web-console');
80
+ expect(stored?.publicKey).toBe(PUBLIC_KEY);
81
+ });
82
+
83
+ test('is idempotent, because a redeploy presents the same name again', async () => {
84
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
85
+ const second = await enrolControlPlanePrincipal({
86
+ name: 'celilo-web-console',
87
+ publicKey: PUBLIC_KEY,
88
+ });
89
+ expect(second.created).toBe(false);
90
+ });
91
+
92
+ test('a new key for an existing name ROTATES rather than duplicating', async () => {
93
+ // What a module that regenerated its keypair needs. Refusing it would leave
94
+ // a console that cannot authenticate and no way to fix it from the module.
95
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
96
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: OTHER_KEY });
97
+ expect((await getPrincipalByName('celilo-web-console'))?.publicKey).toBe(OTHER_KEY);
98
+ });
99
+
100
+ test('a malformed public key is refused here, not at login', async () => {
101
+ // Otherwise it lands in authorized_keys and fails later with a message
102
+ // about SSH rather than about the thing that was wrong.
103
+ await expect(
104
+ enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: 'not-a-key' }),
105
+ ).rejects.toThrow();
106
+ });
107
+ });
108
+
109
+ describe('what the enrolled principal may actually do', () => {
110
+ let dir: string;
111
+
112
+ beforeEach(async () => {
113
+ // The service reaches for the global `getDb()`, so isolation is by
114
+ // CELILO_DB_PATH rather than by passing a client around.
115
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
116
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
117
+ closeDb();
118
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
119
+ });
120
+
121
+ afterEach(() => {
122
+ closeDb();
123
+ rmSync(dir, { recursive: true, force: true });
124
+ });
125
+
126
+ test('may run the console reads it exists for', async () => {
127
+ expect(await isAuthorized('celilo-web-console', 'console', 'status')).toBe(true);
128
+ expect(await isAuthorized('celilo-web-console', 'console', 'get')).toBe(true);
129
+ });
130
+
131
+ test('may run the other reads the console polls', async () => {
132
+ // `alerts:list`, not bare `alerts`. The API authorises command:subcommand,
133
+ // so a caller that omits the subcommand is refused — which is what the
134
+ // console server did until this test caught it.
135
+ expect(await isAuthorized('celilo-web-console', 'alerts', 'list')).toBe(true);
136
+ expect(await isAuthorized('celilo-web-console', 'alerts')).toBe(false);
137
+ expect(await isAuthorized('celilo-web-console', 'backup', 'list')).toBe(true);
138
+ });
139
+
140
+ test('may NOT deploy a module', async () => {
141
+ // The whole point. Everything above is convenience; this is the boundary.
142
+ expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
143
+ });
144
+
145
+ test('may NOT uninstall, restore, or grant', async () => {
146
+ expect(await isAuthorized('celilo-web-console', 'module', 'remove')).toBe(false);
147
+ expect(await isAuthorized('celilo-web-console', 'backup', 'restore')).toBe(false);
148
+ expect(await isAuthorized('celilo-web-console', 'api', 'grant')).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe('revocation', () => {
153
+ let dir: string;
154
+
155
+ beforeEach(async () => {
156
+ // The service reaches for the global `getDb()`, so isolation is by
157
+ // CELILO_DB_PATH rather than by passing a client around.
158
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
159
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
160
+ closeDb();
161
+ });
162
+
163
+ afterEach(() => {
164
+ closeDb();
165
+ rmSync(dir, { recursive: true, force: true });
166
+ });
167
+
168
+ test('removes the principal, so an uninstalled console leaves no working key', async () => {
169
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
170
+ expect(await revokeControlPlanePrincipal('celilo-web-console')).toBe(true);
171
+ expect(await getPrincipalByName('celilo-web-console')).toBeNull();
172
+ });
173
+
174
+ test('revoking one that is already gone is false, not a throw', async () => {
175
+ // An uninstall that fails on tidy-up is worse than one finding nothing to
176
+ // tidy, and a hook that throws here leaves the module half-removed.
177
+ expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
178
+ });
179
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Enrol a module's read-only principal on the remote API.
3
+ *
4
+ * Named for the MECHANISM, not for the capability it backs. A core service
5
+ * named after one capability is core doing that provider's work, which is the
6
+ * pattern `no-module-business-in-core` Scan C exists to stop — and it caught
7
+ * this file under its first name. Enrolling an API principal is celilo's own
8
+ * job: the row lands in celilo's `api_principals` table and the grants come
9
+ * from celilo's command registry. `control_plane_api` is one caller of it.
10
+ *
11
+ * The consuming module generates an ed25519 keypair on its own system and
12
+ * presents the public half here. The private half never crosses this boundary,
13
+ * which is what makes "the console holds no credential celilo issued it"
14
+ * checkable rather than merely intended.
15
+ *
16
+ * ## The grant set is derived, never passed in
17
+ *
18
+ * `readOnlyGrants(COMMANDS)` is the same classifier that decides whether a CLI
19
+ * leaf is a read. Two consequences, both wanted:
20
+ *
21
+ * A new read verb is picked up WITHOUT anyone editing a list — but only at the
22
+ * next enrolment. The grants are MATERIALISED into the principal row here, so a
23
+ * celilo-mgr that is upgraded without the consumer being redeployed keeps
24
+ * serving the grant set that existed when the consumer last installed. Measured:
25
+ * 27 literal ops in the row, and a read verb celilo gains afterwards is denied.
26
+ *
27
+ * That is a limitation rather than a hole. It fails CLOSED, and it heals on the
28
+ * next redeploy. Making it dynamic means a `ro:*` token resolved at check time,
29
+ * which is a change to the authorization core and a real trade — a principal's
30
+ * authority would then move whenever celilo reclassifies a verb, with nobody
31
+ * re-granting anything. celilo#1153 carries that decision.
32
+ *
33
+ * A new WRITE verb is never granted, however it is named, because the
34
+ * classifier reads the verb token rather than a list somebody maintains. A
35
+ * hand-written list would drift in exactly one direction — wider — because
36
+ * widening it is what unblocks whoever is stuck.
37
+ *
38
+ * There is deliberately no `grants` parameter. The widest thing this can issue
39
+ * is the widest thing it should ever issue, and an argument would turn that
40
+ * into a negotiation with the caller.
41
+ */
42
+
43
+ import { COMMANDS, readOnlyGrants } from '@celilo/core';
44
+ import { grantPrincipal, revokePrincipal } from './api-access';
45
+
46
+ export interface EnrolResult {
47
+ name: string;
48
+ grants: string[];
49
+ created: boolean;
50
+ }
51
+
52
+ /**
53
+ * Every read-only op, sorted.
54
+ *
55
+ * Computed on each call rather than cached at module load. It is derived from a
56
+ * static registry so the answer does not change within a process, but a cached
57
+ * copy is one more thing that can be stale in a long-lived server and the
58
+ * computation is a filter over a few hundred leaves.
59
+ */
60
+ export function controlPlaneReadGrants(): string[] {
61
+ return readOnlyGrants(COMMANDS);
62
+ }
63
+
64
+ /**
65
+ * Enrol a consuming module's principal. Idempotent on `name`.
66
+ *
67
+ * Re-presenting the same name and key is a no-op reporting `created: false`,
68
+ * which is what a redeploy does. Presenting a NEW key for an existing name
69
+ * rotates it — the case a module that regenerated its keypair needs, and one a
70
+ * module that did not will never hit.
71
+ *
72
+ * `grantPrincipal` validates the name and the key, so a malformed public key is
73
+ * refused here rather than written into `authorized_keys` and failing at login
74
+ * with a message about the wrong thing.
75
+ */
76
+ export async function enrolControlPlanePrincipal(params: {
77
+ name: string;
78
+ publicKey: string;
79
+ }): Promise<EnrolResult> {
80
+ const grants = controlPlaneReadGrants();
81
+ const { principal, created } = await grantPrincipal({
82
+ name: params.name,
83
+ publicKey: params.publicKey,
84
+ grants,
85
+ });
86
+
87
+ // `grantPrincipal` already reports false for a principal that existed, and a
88
+ // re-grant that rotates a key is correctly not-a-creation. Re-deriving that
89
+ // here with a second lookup would be two sources for one answer.
90
+ return { name: principal.name, grants, created };
91
+ }
92
+
93
+ /**
94
+ * Remove a principal. Idempotent.
95
+ *
96
+ * Called from a consumer's `on_uninstall` so an uninstalled module does not
97
+ * leave a working key behind. Revoking one that is already gone returns false
98
+ * rather than throwing: an uninstall that fails on tidy-up is worse than one
99
+ * that finds nothing to tidy.
100
+ */
101
+ export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
102
+ return revokePrincipal(name);
103
+ }
@@ -65,7 +65,16 @@ export function backupStaleThresholdMs(cadence: Cadence): number | null {
65
65
  return interval + Math.max(HOUR, interval * 0.1);
66
66
  }
67
67
 
68
- function moduleHasBackupHook(manifest: ModuleManifest): boolean {
68
+ /**
69
+ * Whether celilo can back this module up at all.
70
+ *
71
+ * Exported because it is the gate `backup-create.ts` actually applies, and the
72
+ * console has to draw the same distinction. On the live fleet five of 23
73
+ * deployed modules declare the hook: the other eighteen are not overdue and not
74
+ * failing, there is simply nothing to run (celilo#1131). A second copy of this
75
+ * test elsewhere would eventually disagree about which eighteen.
76
+ */
77
+ export function moduleHasBackupHook(manifest: ModuleManifest): boolean {
69
78
  return Boolean(manifest.hooks?.on_backup);
70
79
  }
71
80
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { randomUUID } from 'node:crypto';
7
- import { and, desc, eq, gt, isNotNull, like } from 'drizzle-orm';
7
+ import { and, desc, eq, gt, gte, isNotNull, like } from 'drizzle-orm';
8
8
  import { getDb } from '../db/client';
9
9
  import { type Backup, type BackupStatus, type BackupType, backups } from '../db/schema';
10
10
  import type { BackupHistory } from './backup-schedule';
@@ -140,21 +140,29 @@ export function getBackup(id: string): Backup | null {
140
140
  export function listBackups(options?: {
141
141
  moduleId?: string;
142
142
  limit?: number;
143
+ /**
144
+ * Only attempts started at or after this instant (epoch ms).
145
+ *
146
+ * A WINDOW rather than a bigger `limit`, because the two answer different
147
+ * questions and only one of them is stable. "The last 500 attempts" spans a
148
+ * week for a module failing hourly and a year for one succeeding daily, so a
149
+ * caller asking for a fixed period cannot express it as a count. The console
150
+ * needs a period: it draws days, and a row missing from its answer renders as
151
+ * a day on which nothing ran.
152
+ */
153
+ since?: number;
143
154
  }): Backup[] {
144
155
  const db = getDb();
145
156
  const limit = options?.limit ?? 20;
146
157
 
147
- if (options?.moduleId) {
148
- return db
149
- .select()
150
- .from(backups)
151
- .where(eq(backups.moduleId, options.moduleId))
152
- .orderBy(desc(backups.startedAt))
153
- .limit(limit)
154
- .all();
155
- }
158
+ const filters = [
159
+ options?.moduleId ? eq(backups.moduleId, options.moduleId) : undefined,
160
+ options?.since !== undefined ? gte(backups.startedAt, new Date(options.since)) : undefined,
161
+ ].filter((clause) => clause !== undefined);
156
162
 
157
- return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
163
+ const query = db.select().from(backups);
164
+ const filtered = filters.length > 0 ? query.where(and(...filters)) : query;
165
+ return filtered.orderBy(desc(backups.startedAt)).limit(limit).all();
158
166
  }
159
167
 
160
168
  /**
@@ -48,6 +48,21 @@ export interface CleanupTarget {
48
48
  skip?: CleanupSkipReason;
49
49
  }
50
50
 
51
+ /**
52
+ * The only part of a manifest this plan reads.
53
+ *
54
+ * Narrower than `ModuleManifest` on purpose. A full manifest is a large,
55
+ * strictly-validated shape, and the console reads manifests straight out of the
56
+ * `modules` table where they are stored as opaque JSON. Asking for the whole
57
+ * shape would force either a full re-validation on every poll or a cast, and a
58
+ * cast at a trust boundary is the thing that eventually bites. `ModuleManifest`
59
+ * satisfies this structurally, so existing callers are unaffected.
60
+ */
61
+ export interface ConsumedCapabilities {
62
+ requires?: { capabilities?: readonly { name: string }[] };
63
+ optional?: { capabilities?: readonly { name: string }[] };
64
+ }
65
+
51
66
  export interface ProviderRow {
52
67
  moduleId: string;
53
68
  capabilityName: string;
@@ -76,7 +91,7 @@ export interface ProviderState {
76
91
  */
77
92
  export function planConsumerCleanup(
78
93
  consumer: string,
79
- manifest: ModuleManifest,
94
+ manifest: ConsumedCapabilities,
80
95
  providerRows: ProviderRow[],
81
96
  providerStates: ProviderState[],
82
97
  ): CleanupTarget[] {
@@ -112,16 +127,27 @@ export function planConsumerCleanup(
112
127
  .sort((a, b) => a.providerId.localeCompare(b.providerId));
113
128
  }
114
129
 
130
+ /**
131
+ * Every (module, capability) provider row.
132
+ *
133
+ * Exported because the console's closure walk needs the same edge this plan
134
+ * does, and reads it for many modules rather than one. Two copies of this query
135
+ * would be two definitions of what a provider is.
136
+ */
137
+ export function loadCapabilityProviderRows(db: DbClient): ProviderRow[] {
138
+ return db
139
+ .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
140
+ .from(capabilities)
141
+ .all();
142
+ }
143
+
115
144
  /** Read the plan's inputs out of the DB. */
116
145
  export function loadConsumerCleanupPlan(
117
146
  consumer: string,
118
147
  manifest: ModuleManifest,
119
148
  db: DbClient,
120
149
  ): CleanupTarget[] {
121
- const providerRows = db
122
- .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
123
- .from(capabilities)
124
- .all();
150
+ const providerRows = loadCapabilityProviderRows(db);
125
151
  const providerStates = db
126
152
  .select({ moduleId: modules.id, state: modules.state })
127
153
  .from(modules)