@matthesketh/fleet 1.1.0 → 1.6.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 (217) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +43 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/logs.d.ts +1 -1
  39. package/dist/commands/logs.js +237 -8
  40. package/dist/commands/patch-systemd.d.ts +1 -0
  41. package/dist/commands/patch-systemd.js +126 -0
  42. package/dist/commands/rollback.d.ts +1 -0
  43. package/dist/commands/rollback.js +58 -0
  44. package/dist/commands/routine-run.d.ts +1 -0
  45. package/dist/commands/routine-run.js +122 -0
  46. package/dist/commands/routines.d.ts +1 -0
  47. package/dist/commands/routines.js +25 -0
  48. package/dist/commands/secrets.js +449 -16
  49. package/dist/commands/status.js +7 -3
  50. package/dist/commands/watchdog.d.ts +1 -1
  51. package/dist/commands/watchdog.js +16 -40
  52. package/dist/core/boot-refresh.d.ts +57 -0
  53. package/dist/core/boot-refresh.js +116 -0
  54. package/dist/core/deps/actors/pr-creator.js +11 -9
  55. package/dist/core/deps/collectors/docker-running.js +2 -2
  56. package/dist/core/deps/collectors/github-pr.js +5 -2
  57. package/dist/core/deps/collectors/npm.js +10 -5
  58. package/dist/core/deps/collectors/vulnerability.js +10 -6
  59. package/dist/core/deps/reporters/motd.js +1 -1
  60. package/dist/core/deps/reporters/telegram.js +2 -29
  61. package/dist/core/docker.js +45 -15
  62. package/dist/core/egress.d.ts +41 -0
  63. package/dist/core/egress.js +161 -0
  64. package/dist/core/exec.d.ts +7 -1
  65. package/dist/core/exec.js +25 -17
  66. package/dist/core/git.d.ts +1 -0
  67. package/dist/core/git.js +36 -23
  68. package/dist/core/github.js +27 -8
  69. package/dist/core/health.d.ts +3 -0
  70. package/dist/core/health.js +15 -3
  71. package/dist/core/logs-multi.d.ts +73 -0
  72. package/dist/core/logs-multi.js +163 -0
  73. package/dist/core/logs-policy.d.ts +55 -0
  74. package/dist/core/logs-policy.js +148 -0
  75. package/dist/core/nginx.js +8 -4
  76. package/dist/core/notify.d.ts +15 -0
  77. package/dist/core/notify.js +55 -0
  78. package/dist/core/registry.d.ts +25 -0
  79. package/dist/core/registry.js +57 -10
  80. package/dist/core/routines/cost-queries.d.ts +24 -0
  81. package/dist/core/routines/cost-queries.js +65 -0
  82. package/dist/core/routines/db.d.ts +9 -0
  83. package/dist/core/routines/db.js +126 -0
  84. package/dist/core/routines/defaults.d.ts +2 -0
  85. package/dist/core/routines/defaults.js +72 -0
  86. package/dist/core/routines/engine.d.ts +59 -0
  87. package/dist/core/routines/engine.js +175 -0
  88. package/dist/core/routines/incidents.d.ts +13 -0
  89. package/dist/core/routines/incidents.js +35 -0
  90. package/dist/core/routines/schema.d.ts +418 -0
  91. package/dist/core/routines/schema.js +113 -0
  92. package/dist/core/routines/signals-collector.d.ts +35 -0
  93. package/dist/core/routines/signals-collector.js +114 -0
  94. package/dist/core/routines/store.d.ts +316 -0
  95. package/dist/core/routines/store.js +99 -0
  96. package/dist/core/routines/test-utils.d.ts +2 -0
  97. package/dist/core/routines/test-utils.js +13 -0
  98. package/dist/core/secrets-audit.d.ts +21 -0
  99. package/dist/core/secrets-audit.js +60 -0
  100. package/dist/core/secrets-metadata.d.ts +39 -0
  101. package/dist/core/secrets-metadata.js +82 -0
  102. package/dist/core/secrets-motd.d.ts +20 -0
  103. package/dist/core/secrets-motd.js +72 -0
  104. package/dist/core/secrets-ops.d.ts +3 -1
  105. package/dist/core/secrets-ops.js +78 -13
  106. package/dist/core/secrets-providers.d.ts +50 -0
  107. package/dist/core/secrets-providers.js +291 -0
  108. package/dist/core/secrets-rotation.d.ts +52 -0
  109. package/dist/core/secrets-rotation.js +165 -0
  110. package/dist/core/secrets-snapshots.d.ts +26 -0
  111. package/dist/core/secrets-snapshots.js +95 -0
  112. package/dist/core/secrets-validate.js +2 -1
  113. package/dist/core/secrets.d.ts +12 -1
  114. package/dist/core/secrets.js +35 -24
  115. package/dist/core/self-update.d.ts +41 -0
  116. package/dist/core/self-update.js +73 -0
  117. package/dist/core/systemd.js +29 -12
  118. package/dist/core/telegram.d.ts +6 -0
  119. package/dist/core/telegram.js +32 -0
  120. package/dist/core/validate.d.ts +7 -0
  121. package/dist/core/validate.js +42 -0
  122. package/dist/index.js +0 -4
  123. package/dist/mcp/deps-tools.js +9 -1
  124. package/dist/mcp/git-tools.js +4 -4
  125. package/dist/mcp/server.js +193 -8
  126. package/dist/templates/systemd.js +3 -3
  127. package/dist/templates/unseal.js +5 -1
  128. package/dist/tui/components/Confirm.js +3 -4
  129. package/dist/tui/components/Header.js +37 -8
  130. package/dist/tui/components/KeyHint.js +14 -5
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
  135. package/dist/tui/hooks/use-terminal-size.js +1 -0
  136. package/dist/tui/router.js +133 -8
  137. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  138. package/dist/tui/routines/RoutinesApp.js +277 -0
  139. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  140. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  141. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  142. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  143. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  144. package/dist/tui/routines/components/CommandPalette.js +21 -0
  145. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  146. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  147. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  148. package/dist/tui/routines/components/RoutineForm.js +254 -0
  149. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  150. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  151. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  152. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  153. package/dist/tui/routines/format.d.ts +7 -0
  154. package/dist/tui/routines/format.js +51 -0
  155. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  156. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  157. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  158. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  159. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  160. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  161. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  162. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  163. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  164. package/dist/tui/routines/hooks/use-security.js +110 -0
  165. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  166. package/dist/tui/routines/hooks/use-signals.js +60 -0
  167. package/dist/tui/routines/runtime.d.ts +20 -0
  168. package/dist/tui/routines/runtime.js +40 -0
  169. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  170. package/dist/tui/routines/tabs/CostTab.js +24 -0
  171. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  172. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  173. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/GitTab.js +39 -0
  175. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  177. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  178. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  179. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  180. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  181. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  182. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  183. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  184. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  185. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  187. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  188. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  189. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  190. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  191. package/dist/tui/state.js +16 -1
  192. package/dist/tui/tests/flicker.test.d.ts +1 -0
  193. package/dist/tui/tests/flicker.test.js +105 -0
  194. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  195. package/dist/tui/tests/keyboard-integration.test.js +120 -0
  196. package/dist/tui/tests/test-app.d.ts +4 -0
  197. package/dist/tui/tests/test-app.js +79 -0
  198. package/dist/tui/types.d.ts +14 -1
  199. package/dist/tui/views/AppDetail.js +40 -26
  200. package/dist/tui/views/Dashboard.js +34 -9
  201. package/dist/tui/views/HealthView.js +42 -12
  202. package/dist/tui/views/LogsView.js +38 -10
  203. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  204. package/dist/tui/views/MultiLogsView.js +165 -0
  205. package/dist/tui/views/SecretEdit.js +18 -7
  206. package/dist/tui/views/SecretsView.js +55 -39
  207. package/dist/ui/prompt.d.ts +52 -0
  208. package/dist/ui/prompt.js +169 -0
  209. package/package.json +33 -5
  210. package/dist/commands/motd.d.ts +0 -1
  211. package/dist/commands/motd.js +0 -10
  212. package/dist/templates/motd.d.ts +0 -1
  213. package/dist/templates/motd.js +0 -7
  214. package/dist/tui/components/AppList.d.ts +0 -12
  215. package/dist/tui/components/AppList.js +0 -32
  216. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  217. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -0,0 +1,65 @@
1
+ function isoDaysAgo(n) {
2
+ return new Date(Date.now() - n * 86_400_000).toISOString();
3
+ }
4
+ export function costRollup(db) {
5
+ const windows = {
6
+ day: isoDaysAgo(1),
7
+ week: isoDaysAgo(7),
8
+ month: isoDaysAgo(30),
9
+ };
10
+ const stmt = db.prepare(`
11
+ SELECT COALESCE(SUM(c.usd), 0) AS usd, COUNT(DISTINCT r.run_id) AS runs
12
+ FROM routine_runs r
13
+ LEFT JOIN routine_cost c ON c.run_id = r.run_id
14
+ WHERE r.started_at >= ?
15
+ `);
16
+ const dayRow = stmt.get(windows.day);
17
+ const weekRow = stmt.get(windows.week);
18
+ const monthRow = stmt.get(windows.month);
19
+ return {
20
+ usdToday: dayRow.usd,
21
+ usdWeek: weekRow.usd,
22
+ usdMonth: monthRow.usd,
23
+ runsToday: dayRow.runs,
24
+ runsWeek: weekRow.runs,
25
+ runsMonth: monthRow.runs,
26
+ };
27
+ }
28
+ export function costByRoutine(db, days = 30, limit = 20) {
29
+ const since = isoDaysAgo(days);
30
+ const rows = db.prepare(`
31
+ SELECT r.routine_id AS routineId,
32
+ COUNT(DISTINCT r.run_id) AS runs,
33
+ COALESCE(SUM(c.usd), 0) AS usd,
34
+ COALESCE(SUM(c.input_tokens), 0) AS inputTokens,
35
+ COALESCE(SUM(c.output_tokens), 0) AS outputTokens
36
+ FROM routine_runs r
37
+ LEFT JOIN routine_cost c ON c.run_id = r.run_id
38
+ WHERE r.started_at >= ?
39
+ GROUP BY r.routine_id
40
+ ORDER BY usd DESC
41
+ LIMIT ?
42
+ `).all(since, limit);
43
+ return rows;
44
+ }
45
+ export function dailyCostSeries(db, days = 14) {
46
+ const buckets = [];
47
+ const now = new Date();
48
+ for (let i = days - 1; i >= 0; i--) {
49
+ const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
50
+ const start = day.toISOString();
51
+ const end = new Date(day.getTime() + 86_400_000).toISOString();
52
+ const row = db.prepare(`
53
+ SELECT COALESCE(SUM(c.usd), 0) AS usd, COUNT(DISTINCT r.run_id) AS runs
54
+ FROM routine_runs r
55
+ LEFT JOIN routine_cost c ON c.run_id = r.run_id
56
+ WHERE r.started_at >= ? AND r.started_at < ?
57
+ `).get(start, end);
58
+ buckets.push({
59
+ date: day.toISOString().slice(0, 10),
60
+ usd: row.usd,
61
+ runs: row.runs,
62
+ });
63
+ }
64
+ return buckets;
65
+ }
@@ -0,0 +1,9 @@
1
+ import Database from 'better-sqlite3';
2
+ export interface OpenOptions {
3
+ path?: string;
4
+ readonly?: boolean;
5
+ }
6
+ export declare function openDb(opts?: OpenOptions): Database.Database;
7
+ export declare function closeDb(): void;
8
+ export declare function dbPath(): string;
9
+ export declare function currentSchemaVersion(): number;
@@ -0,0 +1,126 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import Database from 'better-sqlite3';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const DEFAULT_DB_PATH = join(__dirname, '..', '..', '..', 'data', 'fleet.db');
7
+ let _db = null;
8
+ let _currentPath = null;
9
+ const SCHEMA = Object.freeze([
10
+ `CREATE TABLE IF NOT EXISTS schema_version (
11
+ version INTEGER PRIMARY KEY
12
+ )`,
13
+ `CREATE TABLE IF NOT EXISTS routine_runs (
14
+ run_id TEXT PRIMARY KEY,
15
+ routine_id TEXT NOT NULL,
16
+ target TEXT,
17
+ started_at TEXT NOT NULL,
18
+ ended_at TEXT,
19
+ status TEXT NOT NULL,
20
+ exit_code INTEGER,
21
+ duration_ms INTEGER,
22
+ error TEXT,
23
+ runner_kind TEXT NOT NULL,
24
+ scheduler_kind TEXT NOT NULL,
25
+ triggered_by TEXT NOT NULL
26
+ )`,
27
+ `CREATE INDEX IF NOT EXISTS idx_runs_routine_started
28
+ ON routine_runs(routine_id, started_at DESC)`,
29
+ `CREATE INDEX IF NOT EXISTS idx_runs_status
30
+ ON routine_runs(status, started_at DESC)`,
31
+ `CREATE TABLE IF NOT EXISTS routine_run_events (
32
+ run_id TEXT NOT NULL,
33
+ seq INTEGER NOT NULL,
34
+ at TEXT NOT NULL,
35
+ kind TEXT NOT NULL,
36
+ payload TEXT NOT NULL,
37
+ PRIMARY KEY (run_id, seq),
38
+ FOREIGN KEY (run_id) REFERENCES routine_runs(run_id) ON DELETE CASCADE
39
+ )`,
40
+ `CREATE TABLE IF NOT EXISTS routine_cost (
41
+ run_id TEXT PRIMARY KEY,
42
+ input_tokens INTEGER NOT NULL DEFAULT 0,
43
+ output_tokens INTEGER NOT NULL DEFAULT 0,
44
+ cache_create_tokens INTEGER NOT NULL DEFAULT 0,
45
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
46
+ usd REAL NOT NULL DEFAULT 0,
47
+ FOREIGN KEY (run_id) REFERENCES routine_runs(run_id) ON DELETE CASCADE
48
+ )`,
49
+ `CREATE TABLE IF NOT EXISTS signal_cache (
50
+ repo TEXT NOT NULL,
51
+ kind TEXT NOT NULL,
52
+ state TEXT NOT NULL,
53
+ value TEXT,
54
+ detail TEXT NOT NULL DEFAULT '',
55
+ collected_at TEXT NOT NULL,
56
+ ttl_ms INTEGER NOT NULL,
57
+ PRIMARY KEY (repo, kind)
58
+ )`,
59
+ `CREATE TABLE IF NOT EXISTS signal_history (
60
+ repo TEXT NOT NULL,
61
+ kind TEXT NOT NULL,
62
+ state TEXT NOT NULL,
63
+ value TEXT,
64
+ detail TEXT NOT NULL DEFAULT '',
65
+ collected_at TEXT NOT NULL
66
+ )`,
67
+ `CREATE INDEX IF NOT EXISTS idx_signal_history
68
+ ON signal_history(repo, kind, collected_at DESC)`,
69
+ ]);
70
+ const CURRENT_VERSION = 1;
71
+ function ensureDirFor(path) {
72
+ const dir = dirname(path);
73
+ if (!existsSync(dir))
74
+ mkdirSync(dir, { recursive: true });
75
+ }
76
+ function runMigrations(db) {
77
+ db.pragma('journal_mode = WAL');
78
+ db.pragma('foreign_keys = ON');
79
+ db.pragma('synchronous = NORMAL');
80
+ db.exec('BEGIN');
81
+ try {
82
+ for (const stmt of SCHEMA)
83
+ db.exec(stmt);
84
+ const row = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
85
+ if (!row) {
86
+ db.prepare('INSERT INTO schema_version(version) VALUES (?)').run(CURRENT_VERSION);
87
+ }
88
+ else if (row.version !== CURRENT_VERSION) {
89
+ db.prepare('UPDATE schema_version SET version = ?').run(CURRENT_VERSION);
90
+ }
91
+ db.exec('COMMIT');
92
+ }
93
+ catch (err) {
94
+ db.exec('ROLLBACK');
95
+ throw err;
96
+ }
97
+ }
98
+ export function openDb(opts = {}) {
99
+ const path = opts.path ?? DEFAULT_DB_PATH;
100
+ if (_db && _currentPath === path)
101
+ return _db;
102
+ if (_db) {
103
+ _db.close();
104
+ _db = null;
105
+ }
106
+ ensureDirFor(path);
107
+ const db = new Database(path, { readonly: opts.readonly ?? false });
108
+ if (!opts.readonly)
109
+ runMigrations(db);
110
+ _db = db;
111
+ _currentPath = path;
112
+ return db;
113
+ }
114
+ export function closeDb() {
115
+ if (_db) {
116
+ _db.close();
117
+ _db = null;
118
+ _currentPath = null;
119
+ }
120
+ }
121
+ export function dbPath() {
122
+ return _currentPath ?? DEFAULT_DB_PATH;
123
+ }
124
+ export function currentSchemaVersion() {
125
+ return CURRENT_VERSION;
126
+ }
@@ -0,0 +1,2 @@
1
+ import type { Routine } from './schema.js';
2
+ export declare function builtInDefaultRoutines(): Routine[];
@@ -0,0 +1,72 @@
1
+ export function builtInDefaultRoutines() {
2
+ return [
3
+ {
4
+ id: 'nightly-audit',
5
+ name: 'Nightly fleet audit',
6
+ description: 'Runs `/auto-audit:tick` against each registered repo, capturing findings and opening PRs for high-severity fixes.',
7
+ schedule: { kind: 'calendar', onCalendar: '*-*-* 02:00:00', randomizedDelaySec: 600, persistent: true },
8
+ enabled: false,
9
+ targets: [],
10
+ perTarget: true,
11
+ task: {
12
+ kind: 'claude-cli',
13
+ prompt: 'Run /auto-audit:tick against this repo. Report any HIGH-severity findings as bullet points; be terse.',
14
+ outputFormat: 'json',
15
+ tokenCap: 150_000,
16
+ wallClockMs: 20 * 60 * 1000,
17
+ maxUsd: 2,
18
+ },
19
+ notify: [{ kind: 'stdout', on: 'failure', config: {} }],
20
+ tags: ['security', 'audit'],
21
+ },
22
+ {
23
+ id: 'weekly-dep-drift',
24
+ name: 'Weekly dep drift',
25
+ description: 'Detects outdated dependencies and packages >1 major behind. Produces a consolidated markdown report.',
26
+ schedule: { kind: 'calendar', onCalendar: 'Mon *-*-* 06:00:00', randomizedDelaySec: 300, persistent: true },
27
+ enabled: false,
28
+ targets: [],
29
+ perTarget: true,
30
+ task: {
31
+ kind: 'shell',
32
+ argv: ['npm', 'outdated', '--json'],
33
+ wallClockMs: 5 * 60 * 1000,
34
+ },
35
+ notify: [{ kind: 'stdout', on: 'always', config: {} }],
36
+ tags: ['deps'],
37
+ },
38
+ {
39
+ id: 'stale-pr-nag',
40
+ name: 'Stale PR nag',
41
+ description: 'Lists open PRs older than 7 days across the fleet.',
42
+ schedule: { kind: 'calendar', onCalendar: 'Fri *-*-* 16:00:00', randomizedDelaySec: 0, persistent: true },
43
+ enabled: false,
44
+ targets: [],
45
+ perTarget: true,
46
+ task: {
47
+ kind: 'shell',
48
+ argv: ['gh', 'pr', 'list', '--state', 'open', '--json', 'number,title,updatedAt,author,url', '--limit', '50'],
49
+ wallClockMs: 60_000,
50
+ },
51
+ notify: [{ kind: 'stdout', on: 'always', config: {} }],
52
+ tags: ['git', 'nag'],
53
+ },
54
+ {
55
+ id: 'deploy-readiness',
56
+ name: 'Deploy readiness',
57
+ description: 'Aggregates fleet signals into a ship/block verdict. Manual trigger only.',
58
+ schedule: { kind: 'manual' },
59
+ enabled: true,
60
+ targets: [],
61
+ perTarget: false,
62
+ task: {
63
+ kind: 'mcp-call',
64
+ tool: 'fleet_status',
65
+ args: { summary: true },
66
+ wallClockMs: 60_000,
67
+ },
68
+ notify: [],
69
+ tags: ['deploy', 'release'],
70
+ },
71
+ ];
72
+ }
@@ -0,0 +1,59 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { NotifierAdapter, RunnerAdapter, SchedulerAdapter } from '../../adapters/types.js';
3
+ import type { Routine, RunEvent, RunStatus } from './schema.js';
4
+ import { RoutineStore } from './store.js';
5
+ export type RunTrigger = 'manual' | 'scheduled' | 'api';
6
+ export interface RunPersistencePayload {
7
+ runId: string;
8
+ routineId: string;
9
+ target: string | null;
10
+ startedAt: string;
11
+ endedAt?: string;
12
+ status: RunStatus;
13
+ exitCode?: number;
14
+ durationMs?: number;
15
+ error?: string;
16
+ runnerKind: Routine['task']['kind'];
17
+ schedulerKind: SchedulerAdapter['id'] | 'none';
18
+ triggeredBy: RunTrigger;
19
+ }
20
+ export interface RecentRun {
21
+ runId: string;
22
+ routineId: string;
23
+ target: string | null;
24
+ startedAt: string;
25
+ endedAt: string | null;
26
+ status: RunStatus;
27
+ exitCode: number | null;
28
+ durationMs: number | null;
29
+ error: string | null;
30
+ usd: number | null;
31
+ inputTokens: number | null;
32
+ outputTokens: number | null;
33
+ }
34
+ export interface EngineOptions {
35
+ store: RoutineStore;
36
+ db: Database.Database;
37
+ runners: RunnerAdapter[];
38
+ scheduler?: SchedulerAdapter | null;
39
+ notifiers?: NotifierAdapter[];
40
+ logsDir?: string;
41
+ }
42
+ export declare class RoutineEngine {
43
+ private readonly opts;
44
+ private readonly runners;
45
+ constructor(opts: EngineOptions);
46
+ get store(): RoutineStore;
47
+ get db(): Database.Database;
48
+ runOnce(routineId: string, target?: {
49
+ repo: string | null;
50
+ repoPath: string | null;
51
+ }, trigger?: RunTrigger, signal?: AbortSignal): AsyncIterable<RunEvent>;
52
+ recentRuns(routineId: string, limit?: number): RecentRun[];
53
+ costSinceDays(routineId: string, days?: number): {
54
+ usd: number;
55
+ runs: number;
56
+ };
57
+ register(routine: Routine): Promise<Routine>;
58
+ unregister(routineId: string): Promise<boolean>;
59
+ }
@@ -0,0 +1,175 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ function initRunRow(db, p) {
3
+ db.prepare(`
4
+ INSERT INTO routine_runs (run_id, routine_id, target, started_at, status, runner_kind, scheduler_kind, triggered_by)
5
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6
+ `).run(p.runId, p.routineId, p.target, p.startedAt, p.status, p.runnerKind, p.schedulerKind, p.triggeredBy);
7
+ }
8
+ function finishRunRow(db, runId, update) {
9
+ db.prepare(`
10
+ UPDATE routine_runs
11
+ SET ended_at = ?, status = ?, exit_code = ?, duration_ms = ?, error = ?
12
+ WHERE run_id = ?
13
+ `).run(update.endedAt, update.status, update.exitCode, update.durationMs, update.error ?? null, runId);
14
+ }
15
+ function appendEvent(db, runId, seq, event) {
16
+ const at = 'at' in event ? event.at : new Date().toISOString();
17
+ db.prepare(`
18
+ INSERT INTO routine_run_events (run_id, seq, at, kind, payload)
19
+ VALUES (?, ?, ?, ?, ?)
20
+ `).run(runId, seq, at, event.kind, JSON.stringify(event));
21
+ }
22
+ function upsertCost(db, runId, c) {
23
+ db.prepare(`
24
+ INSERT INTO routine_cost (run_id, input_tokens, output_tokens, cache_create_tokens, cache_read_tokens, usd)
25
+ VALUES (?, ?, ?, ?, ?, ?)
26
+ ON CONFLICT(run_id) DO UPDATE SET
27
+ input_tokens = excluded.input_tokens,
28
+ output_tokens = excluded.output_tokens,
29
+ cache_create_tokens = excluded.cache_create_tokens,
30
+ cache_read_tokens = excluded.cache_read_tokens,
31
+ usd = excluded.usd
32
+ `).run(runId, c.inputTokens, c.outputTokens, c.cacheCreateTokens, c.cacheReadTokens, c.usd);
33
+ }
34
+ export class RoutineEngine {
35
+ opts;
36
+ runners;
37
+ constructor(opts) {
38
+ this.opts = opts;
39
+ this.runners = new Map(opts.runners.map(r => [r.id, r]));
40
+ }
41
+ get store() {
42
+ return this.opts.store;
43
+ }
44
+ get db() {
45
+ return this.opts.db;
46
+ }
47
+ async *runOnce(routineId, target = { repo: null, repoPath: null }, trigger = 'manual', signal = new AbortController().signal) {
48
+ const routine = this.opts.store.get(routineId);
49
+ if (!routine)
50
+ throw new Error(`routine not found: ${routineId}`);
51
+ const runner = this.runners.get(routine.task.kind);
52
+ if (!runner)
53
+ throw new Error(`no runner registered for task kind: ${routine.task.kind}`);
54
+ const runId = randomUUID();
55
+ const startedAt = new Date().toISOString();
56
+ initRunRow(this.opts.db, {
57
+ runId,
58
+ routineId,
59
+ target: target.repo,
60
+ startedAt,
61
+ status: 'running',
62
+ runnerKind: routine.task.kind,
63
+ schedulerKind: this.opts.scheduler?.id ?? 'none',
64
+ triggeredBy: trigger,
65
+ });
66
+ const ctx = {
67
+ repo: target.repo,
68
+ repoPath: target.repoPath,
69
+ runId,
70
+ routineId,
71
+ startedAt,
72
+ logsDir: this.opts.logsDir ?? '/var/log/fleet',
73
+ env: { FLEET_ROUTINE_ID: routineId, FLEET_RUN_ID: runId },
74
+ };
75
+ let seq = 0;
76
+ let lastExitCode = -1;
77
+ let lastDurationMs = 0;
78
+ let lastStatus = 'failed';
79
+ let lastError;
80
+ try {
81
+ for await (const event of runner.run(routine.task, ctx, signal)) {
82
+ appendEvent(this.opts.db, runId, seq++, event);
83
+ if (event.kind === 'cost')
84
+ upsertCost(this.opts.db, runId, event);
85
+ if (event.kind === 'end') {
86
+ lastExitCode = event.exitCode;
87
+ lastDurationMs = event.durationMs;
88
+ lastStatus = event.status;
89
+ lastError = event.error;
90
+ }
91
+ yield event;
92
+ }
93
+ }
94
+ catch (err) {
95
+ lastError = err.message;
96
+ appendEvent(this.opts.db, runId, seq++, {
97
+ kind: 'end',
98
+ status: 'failed',
99
+ exitCode: -1,
100
+ durationMs: 0,
101
+ at: new Date().toISOString(),
102
+ error: lastError,
103
+ });
104
+ lastStatus = 'failed';
105
+ }
106
+ finally {
107
+ finishRunRow(this.opts.db, runId, {
108
+ endedAt: new Date().toISOString(),
109
+ status: lastStatus,
110
+ exitCode: lastExitCode,
111
+ durationMs: lastDurationMs,
112
+ error: lastError,
113
+ });
114
+ if (this.opts.notifiers?.length) {
115
+ const shouldNotify = routine.notify.some(n => n.on === 'always' || (n.on === 'failure' && lastStatus !== 'ok') || (n.on === 'success' && lastStatus === 'ok'));
116
+ if (shouldNotify) {
117
+ const subject = `fleet: ${routine.id} ${lastStatus}`;
118
+ const body = `run ${runId} for ${routine.id} ended ${lastStatus} (exit=${lastExitCode}, ${lastDurationMs}ms)`;
119
+ await Promise.allSettled(this.opts.notifiers.map(n => n.notify(subject, body, { routineId, runId, status: lastStatus })));
120
+ }
121
+ }
122
+ }
123
+ }
124
+ recentRuns(routineId, limit = 20) {
125
+ const rows = this.opts.db.prepare(`
126
+ SELECT r.run_id, r.routine_id, r.target, r.started_at, r.ended_at, r.status, r.exit_code, r.duration_ms, r.error,
127
+ c.usd, c.input_tokens, c.output_tokens
128
+ FROM routine_runs r
129
+ LEFT JOIN routine_cost c ON c.run_id = r.run_id
130
+ WHERE r.routine_id = ?
131
+ ORDER BY r.started_at DESC
132
+ LIMIT ?
133
+ `).all(routineId, limit);
134
+ return rows.map(row => ({
135
+ runId: row.run_id,
136
+ routineId: row.routine_id,
137
+ target: row.target,
138
+ startedAt: row.started_at,
139
+ endedAt: row.ended_at,
140
+ status: row.status,
141
+ exitCode: row.exit_code,
142
+ durationMs: row.duration_ms,
143
+ error: row.error,
144
+ usd: row.usd,
145
+ inputTokens: row.input_tokens,
146
+ outputTokens: row.output_tokens,
147
+ }));
148
+ }
149
+ costSinceDays(routineId, days = 30) {
150
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
151
+ const row = this.opts.db.prepare(`
152
+ SELECT COALESCE(SUM(c.usd), 0) AS usd, COUNT(DISTINCT c.run_id) AS runs
153
+ FROM routine_cost c
154
+ JOIN routine_runs r ON r.run_id = c.run_id
155
+ WHERE r.routine_id = ? AND r.started_at >= ?
156
+ `).get(routineId, since);
157
+ return { usd: row.usd, runs: row.runs };
158
+ }
159
+ async register(routine) {
160
+ const stored = this.opts.store.upsert(routine);
161
+ if (this.opts.scheduler && stored.schedule.kind !== 'manual') {
162
+ await this.opts.scheduler.upsert(stored);
163
+ }
164
+ return stored;
165
+ }
166
+ async unregister(routineId) {
167
+ if (this.opts.scheduler) {
168
+ try {
169
+ await this.opts.scheduler.remove(routineId);
170
+ }
171
+ catch { /* ignore */ }
172
+ }
173
+ return this.opts.store.remove(routineId);
174
+ }
175
+ }
@@ -0,0 +1,13 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type IncidentKind = 'routine-failed' | 'routine-timeout' | 'signal-error' | 'signal-warn';
3
+ export interface Incident {
4
+ at: string;
5
+ kind: IncidentKind;
6
+ subject: string;
7
+ detail: string;
8
+ }
9
+ export interface IncidentQueryOptions {
10
+ sinceDays?: number;
11
+ limit?: number;
12
+ }
13
+ export declare function loadIncidents(db: Database.Database, opts?: IncidentQueryOptions): Incident[];
@@ -0,0 +1,35 @@
1
+ export function loadIncidents(db, opts = {}) {
2
+ const sinceDays = opts.sinceDays ?? 7;
3
+ const limit = opts.limit ?? 100;
4
+ const since = new Date(Date.now() - sinceDays * 86_400_000).toISOString();
5
+ const routineRows = db.prepare(`
6
+ SELECT started_at AS at, routine_id AS subject, status, error, target
7
+ FROM routine_runs
8
+ WHERE started_at >= ? AND status IN ('failed', 'timeout', 'aborted')
9
+ ORDER BY started_at DESC
10
+ LIMIT ?
11
+ `).all(since, limit);
12
+ const incidents = routineRows.map(r => ({
13
+ at: r.at,
14
+ kind: r.status === 'timeout' ? 'routine-timeout' : 'routine-failed',
15
+ subject: `${r.subject}${r.target ? ` · ${r.target}` : ''}`,
16
+ detail: r.error ?? r.status,
17
+ }));
18
+ const signalRows = db.prepare(`
19
+ SELECT collected_at AS at, repo, kind AS signal_kind, state, detail
20
+ FROM signal_history
21
+ WHERE collected_at >= ? AND state IN ('error', 'warn')
22
+ ORDER BY collected_at DESC
23
+ LIMIT ?
24
+ `).all(since, limit);
25
+ for (const row of signalRows) {
26
+ incidents.push({
27
+ at: row.at,
28
+ kind: row.state === 'error' ? 'signal-error' : 'signal-warn',
29
+ subject: `${row.repo} · ${row.signal_kind}`,
30
+ detail: row.detail,
31
+ });
32
+ }
33
+ incidents.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
34
+ return incidents.slice(0, limit);
35
+ }