@crvouga/dev-fleet 0.3.0 → 0.4.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 (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. package/src/worker-readiness.ts +10 -1
@@ -107,6 +107,14 @@
107
107
  "description": "The port the worker T3 server listens on, normally 3773."
108
108
  }
109
109
  }
110
+ },
111
+ "toolProfiles": {
112
+ "type": "array",
113
+ "uniqueItems": true,
114
+ "items": {
115
+ "enum": ["web", "go", "rust", "jvm"]
116
+ },
117
+ "description": "Tool profiles this worker installs on top of the fleet-wide ones: web (pnpm, Deno, Yarn), go, rust (with CMake and Ninja), jvm (Temurin). Each is a locked tool set dev-fleet ships."
110
118
  }
111
119
  }
112
120
  }
@@ -191,6 +199,49 @@
191
199
  "type": "string",
192
200
  "format": "email",
193
201
  "description": "Read-only Beszel account visitors are signed in as."
202
+ },
203
+ "backups": {
204
+ "type": "object",
205
+ "additionalProperties": false,
206
+ "required": ["schedule", "keep", "storage"],
207
+ "description": "Scheduled off-machine backups of the monitoring hub. fleet-sync sets them through the hub's API, with the keys of the secrets monitoring-backup-access-key and monitoring-backup-secret-key; without this block the hub's backups are only read, and beszel:backup warns.",
208
+ "properties": {
209
+ "schedule": {
210
+ "type": "string",
211
+ "pattern": "^[0-9*/,-]+( [0-9*/,-]+){4}$",
212
+ "description": "A cron expression in the hub's time zone, for example 0 3 * * *."
213
+ },
214
+ "keep": {
215
+ "type": "integer",
216
+ "minimum": 1,
217
+ "maximum": 365,
218
+ "description": "How many scheduled backups the hub keeps."
219
+ },
220
+ "storage": {
221
+ "type": "object",
222
+ "additionalProperties": false,
223
+ "required": ["bucket", "endpoint", "region"],
224
+ "description": "S3-compatible storage off the manager. Never its keys.",
225
+ "properties": {
226
+ "bucket": {
227
+ "type": "string",
228
+ "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$"
229
+ },
230
+ "endpoint": {
231
+ "type": "string",
232
+ "pattern": "^https://[A-Za-z0-9.-]+(:[0-9]+)?$"
233
+ },
234
+ "region": {
235
+ "type": "string",
236
+ "pattern": "^[a-z0-9-]{1,32}$"
237
+ },
238
+ "forcePathStyle": {
239
+ "type": "boolean",
240
+ "description": "Path-style requests, for services that need them (MinIO)."
241
+ }
242
+ }
243
+ }
244
+ }
194
245
  }
195
246
  }
196
247
  }
@@ -263,9 +314,26 @@
263
314
  },
264
315
  "neverSleep": {
265
316
  "const": true
317
+ },
318
+ "toolProfiles": {
319
+ "type": "array",
320
+ "uniqueItems": true,
321
+ "items": {
322
+ "enum": ["web", "go", "rust", "jvm"]
323
+ },
324
+ "description": "Tool profiles every worker installs on top of the base tools: web (pnpm, Deno, Yarn), go, rust (with CMake and Ninja), jvm (Temurin). Each worker passes a tool:<name> check for each of their tools."
325
+ },
326
+ "tools": {
327
+ "type": "array",
328
+ "uniqueItems": true,
329
+ "items": {
330
+ "type": "string",
331
+ "pattern": "^[A-Za-z0-9@:/._-]{1,75}$"
332
+ },
333
+ "description": "Single tools every worker installs: names of tools dev-fleet's mise.toml pins (for example fd or ripgrep). A tool it does not pin is refused; ask for it in an issue."
266
334
  }
267
335
  },
268
- "description": "Policy every worker must meet. All but the two logins and the capabilities are fixed."
336
+ "description": "Policy every worker must meet. All but the two logins, the capabilities and the tools are fixed."
269
337
  },
270
338
  "source": {
271
339
  "type": "object",
@@ -475,12 +543,121 @@
475
543
  "pattern": "^[A-Za-z0-9_]+$"
476
544
  }
477
545
  }
546
+ },
547
+ "monitoring-backup-access-key": {
548
+ "type": "object",
549
+ "additionalProperties": false,
550
+ "required": ["path", "field"],
551
+ "properties": {
552
+ "path": {
553
+ "type": "string",
554
+ "pattern": "^[A-Za-z0-9._/-]+$"
555
+ },
556
+ "field": {
557
+ "type": "string",
558
+ "pattern": "^[A-Za-z0-9_]+$"
559
+ }
560
+ },
561
+ "description": "Only with services.monitoring.backups: the access key of the hub's backup storage."
562
+ },
563
+ "monitoring-backup-secret-key": {
564
+ "type": "object",
565
+ "additionalProperties": false,
566
+ "required": ["path", "field"],
567
+ "properties": {
568
+ "path": {
569
+ "type": "string",
570
+ "pattern": "^[A-Za-z0-9._/-]+$"
571
+ },
572
+ "field": {
573
+ "type": "string",
574
+ "pattern": "^[A-Za-z0-9_]+$"
575
+ }
576
+ },
577
+ "description": "Only with services.monitoring.backups: the secret key of the hub's backup storage."
578
+ },
579
+ "notify-webhook-url": {
580
+ "type": "object",
581
+ "additionalProperties": false,
582
+ "required": ["path", "field"],
583
+ "properties": {
584
+ "path": {
585
+ "type": "string",
586
+ "pattern": "^[A-Za-z0-9._/-]+$"
587
+ },
588
+ "field": {
589
+ "type": "string",
590
+ "pattern": "^[A-Za-z0-9_]+$"
591
+ }
592
+ },
593
+ "description": "Only with the webhook notify target: the URL fleet-watch POSTs JSON to."
594
+ },
595
+ "notify-ntfy-url": {
596
+ "type": "object",
597
+ "additionalProperties": false,
598
+ "required": ["path", "field"],
599
+ "properties": {
600
+ "path": {
601
+ "type": "string",
602
+ "pattern": "^[A-Za-z0-9._/-]+$"
603
+ },
604
+ "field": {
605
+ "type": "string",
606
+ "pattern": "^[A-Za-z0-9_]+$"
607
+ }
608
+ },
609
+ "description": "Only with the ntfy notify target: the ntfy topic URL fleet-watch publishes to."
478
610
  }
479
611
  }
480
612
  }
481
613
  }
482
614
  }
483
615
  ]
616
+ },
617
+ "git": {
618
+ "type": "object",
619
+ "additionalProperties": false,
620
+ "required": ["name", "email"],
621
+ "description": "The Git identity every node, the manager included, commits with, and the GitHub credential helper it pushes with. fleet-sync writes them in ~/.config/git/config and measures them as worker:git-identity and worker:git-push-auth.",
622
+ "properties": {
623
+ "name": {
624
+ "type": "string",
625
+ "pattern": "^[^\"\\\\\\n\\r]{1,100}$",
626
+ "description": "user.name of every commit."
627
+ },
628
+ "email": {
629
+ "type": "string",
630
+ "format": "email",
631
+ "description": "user.email of every commit, for example the account's noreply address."
632
+ },
633
+ "verifyRepository": {
634
+ "type": "string",
635
+ "pattern": "^https://github\\.com/[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$",
636
+ "description": "A GitHub repository the fleet's account can push to, ideally private. Each node lists it through git with its own credentials."
637
+ }
638
+ }
639
+ },
640
+ "notify": {
641
+ "description": "Where fleet-watch sends a notification when a machine becomes unhealthy or recovers. Each target's address is a secret: notify-webhook-url (a JSON POST) or notify-ntfy-url (an ntfy topic URL).",
642
+ "type": "object",
643
+ "additionalProperties": false,
644
+ "required": ["targets"],
645
+ "properties": {
646
+ "targets": {
647
+ "type": "array",
648
+ "minItems": 1,
649
+ "uniqueItems": true,
650
+ "items": {
651
+ "enum": ["webhook", "ntfy"]
652
+ }
653
+ },
654
+ "repeatHours": {
655
+ "description": "Notify again about a machine that stays unhealthy this often. 0 never repeats; the default is 24.",
656
+ "type": "integer",
657
+ "minimum": 0,
658
+ "maximum": 720
659
+ }
660
+ }
484
661
  }
485
662
  }
486
663
  }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "check.schema.json",
4
+ "title": "One check of a dev-fleet report",
5
+ "description": "The id is listed in schema/check-ids.json.",
6
+ "type": "object",
7
+ "required": ["id", "status", "summary"],
8
+ "properties": {
9
+ "id": { "type": "string", "pattern": "^[a-z0-9-]+(:[A-Za-z0-9:._-]+)?$" },
10
+ "status": { "enum": ["pass", "warn", "fail", "skip"] },
11
+ "summary": { "type": "string" },
12
+ "required": { "type": "boolean" },
13
+ "phaseDurationMs": { "type": "number" },
14
+ "remedy": {
15
+ "type": "object",
16
+ "required": ["action"],
17
+ "properties": {
18
+ "action": { "type": "string" },
19
+ "command": { "type": "string" }
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "error.schema.json",
4
+ "title": "A dev-fleet error, version 1",
5
+ "description": "What a command prints with --json when it fails before a report: raw subprocess output and exception details are withheld.",
6
+ "type": "object",
7
+ "required": ["version", "ok", "code", "error"],
8
+ "properties": {
9
+ "version": { "const": 1 },
10
+ "ok": { "const": false },
11
+ "code": { "type": "string" },
12
+ "error": { "type": "string" }
13
+ }
14
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "fleet-report.schema.json",
4
+ "title": "The fleet-report output, version 1",
5
+ "type": "object",
6
+ "required": [
7
+ "version",
8
+ "command",
9
+ "ok",
10
+ "since",
11
+ "runs",
12
+ "succeeded",
13
+ "successRate",
14
+ "meanDurationMs",
15
+ "failingChecks",
16
+ "notReady",
17
+ "entries"
18
+ ],
19
+ "properties": {
20
+ "version": { "const": 1 },
21
+ "command": { "const": "fleet-report" },
22
+ "ok": { "type": "boolean" },
23
+ "since": { "type": "string" },
24
+ "runs": { "type": "integer" },
25
+ "succeeded": { "type": "integer" },
26
+ "successRate": { "type": ["number", "null"] },
27
+ "meanDurationMs": { "type": ["number", "null"] },
28
+ "failingChecks": {
29
+ "type": "array",
30
+ "items": {
31
+ "type": "object",
32
+ "required": ["id", "runs"],
33
+ "properties": {
34
+ "id": { "type": "string" },
35
+ "runs": { "type": "integer" }
36
+ }
37
+ }
38
+ },
39
+ "notReady": {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "object",
43
+ "required": ["node", "ms"],
44
+ "properties": {
45
+ "node": { "type": "string" },
46
+ "ms": { "type": "number" }
47
+ }
48
+ }
49
+ },
50
+ "entries": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object",
54
+ "required": [
55
+ "startedAt",
56
+ "finishedAt",
57
+ "durationMs",
58
+ "trigger",
59
+ "revision",
60
+ "ok",
61
+ "exitCode",
62
+ "failing",
63
+ "notReady"
64
+ ],
65
+ "properties": {
66
+ "startedAt": { "type": "string" },
67
+ "finishedAt": { "type": "string" },
68
+ "durationMs": { "type": "number" },
69
+ "trigger": { "enum": ["operator", "schedule"] },
70
+ "revision": { "type": ["string", "null"] },
71
+ "ok": { "type": "boolean" },
72
+ "exitCode": { "type": "integer" },
73
+ "failing": { "type": "array", "items": { "type": "string" } },
74
+ "notReady": { "type": "array", "items": { "type": "string" } }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,112 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "fleet-sync.schema.json",
4
+ "title": "The fleet-sync report, version 3",
5
+ "description": "What devfleet fleet-sync prints with --json. A preview (--dry-run) adds plan.",
6
+ "type": "object",
7
+ "required": [
8
+ "version",
9
+ "command",
10
+ "execution",
11
+ "ok",
12
+ "applied",
13
+ "desiredRevision",
14
+ "checks",
15
+ "nodes",
16
+ "candidates",
17
+ "dashboardUrl"
18
+ ],
19
+ "properties": {
20
+ "version": { "const": 3 },
21
+ "command": { "const": "fleet-sync" },
22
+ "execution": { "const": "manager-push" },
23
+ "ok": { "type": "boolean" },
24
+ "applied": { "type": "boolean" },
25
+ "desiredRevision": { "type": ["string", "null"] },
26
+ "checks": { "type": "array", "items": { "$ref": "check.schema.json" } },
27
+ "candidates": { "type": "integer" },
28
+ "dashboardUrl": { "type": ["string", "null"] },
29
+ "planFingerprint": { "type": "string" },
30
+ "nodes": {
31
+ "type": "array",
32
+ "items": {
33
+ "type": "object",
34
+ "required": [
35
+ "node",
36
+ "checks",
37
+ "reachability",
38
+ "desiredRevision",
39
+ "appliedRevision",
40
+ "durationMs"
41
+ ],
42
+ "properties": {
43
+ "node": { "type": "string" },
44
+ "role": { "enum": ["manager", "worker"] },
45
+ "reachability": {
46
+ "enum": ["online", "offline", "missing", "conflict", "unknown"]
47
+ },
48
+ "desiredRevision": { "type": ["string", "null"] },
49
+ "appliedRevision": { "type": ["string", "null"] },
50
+ "durationMs": { "type": "number" },
51
+ "checks": {
52
+ "type": "array",
53
+ "items": { "$ref": "check.schema.json" }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ "plan": {
59
+ "type": "object",
60
+ "required": ["state", "revision", "known", "tools", "restarts", "nodes"],
61
+ "properties": {
62
+ "state": { "type": "string" },
63
+ "known": { "type": "boolean" },
64
+ "revision": {
65
+ "type": "object",
66
+ "required": ["from", "to"],
67
+ "properties": {
68
+ "from": { "type": ["string", "null"] },
69
+ "to": { "type": "string" }
70
+ }
71
+ },
72
+ "tools": {
73
+ "type": "array",
74
+ "items": {
75
+ "type": "object",
76
+ "required": ["tool", "from", "to"],
77
+ "properties": {
78
+ "tool": { "type": "string" },
79
+ "from": { "type": ["string", "null"] },
80
+ "to": { "type": ["string", "null"] }
81
+ }
82
+ }
83
+ },
84
+ "restarts": {
85
+ "type": "array",
86
+ "items": {
87
+ "type": "object",
88
+ "required": ["service", "reason", "endsRunningTurns"],
89
+ "properties": {
90
+ "service": { "type": "string" },
91
+ "reason": { "type": "string" },
92
+ "endsRunningTurns": { "type": "boolean" }
93
+ }
94
+ }
95
+ },
96
+ "nodes": {
97
+ "type": "array",
98
+ "items": {
99
+ "type": "object",
100
+ "required": ["node", "from", "to", "changes"],
101
+ "properties": {
102
+ "node": { "type": "string" },
103
+ "from": { "type": ["string", "null"] },
104
+ "to": { "type": "string" },
105
+ "changes": { "type": "boolean" }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "report.schema.json",
4
+ "title": "A dev-fleet diagnostic report, version 1",
5
+ "description": "What fleet-status, fleet-state and the other diagnostic commands print with --json.",
6
+ "type": "object",
7
+ "required": ["version", "command", "ok", "generatedAt", "nodes", "summary"],
8
+ "properties": {
9
+ "version": { "const": 1 },
10
+ "command": { "type": "string" },
11
+ "ok": { "type": "boolean" },
12
+ "generatedAt": { "type": "string" },
13
+ "nodes": {
14
+ "type": "array",
15
+ "items": {
16
+ "type": "object",
17
+ "required": ["node", "checks"],
18
+ "properties": {
19
+ "node": { "type": "string" },
20
+ "checks": {
21
+ "type": "array",
22
+ "items": { "$ref": "check.schema.json" }
23
+ }
24
+ }
25
+ }
26
+ },
27
+ "summary": {
28
+ "type": "object",
29
+ "required": ["pass", "warn", "fail", "skip"],
30
+ "properties": {
31
+ "pass": { "type": "integer" },
32
+ "warn": { "type": "integer" },
33
+ "fail": { "type": "integer" },
34
+ "skip": { "type": "integer" }
35
+ }
36
+ }
37
+ }
38
+ }
@@ -5,6 +5,7 @@ import { record } from './config';
5
5
  import { codingAuth, serviceAuth } from './service-auth';
6
6
  import { workstationChecks } from './workstation-checks';
7
7
  import { toolchainChecks } from './toolchain-checks';
8
+ import { workerSelection } from '../bootstrap/worker-tools';
8
9
  import type { Capability, Check, Runner, CommandResult } from './types';
9
10
 
10
11
  export async function authCheck(
@@ -24,6 +25,8 @@ export async function authCheck(
24
25
  root,
25
26
  run,
26
27
  definition.workers.some((node) => node.id === nodeId)
28
+ ? workerSelection(definition, nodeId)
29
+ : undefined
27
30
  );
28
31
  return basicAuth(capability, run, root);
29
32
  }
@@ -63,7 +63,7 @@ export function agentGuide(release: Release) {
63
63
  'devfleet --interactive (explicit opt-in; TTY required, disabled in CI)',
64
64
  groups: {
65
65
  'fleet-*':
66
- 'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
66
+ 'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-report, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
67
67
  't3-*': 'Declared T3 provider instances: t3-status, t3-apply, t3-cleanup',
68
68
  cliproxy: 'The CLI proxy service on the manager; arguments are forwarded',
69
69
  },
@@ -89,9 +89,11 @@ export function agentGuide(release: Release) {
89
89
  '130': 'Cancelled',
90
90
  },
91
91
  errors:
92
- '{version:1,ok:false,code,error}; raw subprocess output and exception details are withheld',
92
+ 'schema/output/error.schema.json: {version:1,ok:false,code,error}; raw subprocess output and exception details are withheld',
93
93
  reports:
94
- '{version:1,command,ok,generatedAt,nodes:[{node,checks:[{id,status,summary,remedy?}]}],summary,coverage}',
94
+ 'Each output has a JSON schema in this package; parse by it and by its version. Diagnostic reports (fleet-status, fleet-state, fleet-repair): schema/output/report.schema.json, version 1. fleet-sync: schema/output/fleet-sync.schema.json, version 3 (a --dry-run adds plan). fleet-report: schema/output/fleet-report.schema.json, version 1.',
95
+ checks:
96
+ 'schema/check-ids.json lists every check ID with what a passing check means; * stands for a name. Each failing check carries its own remedy.',
95
97
  statuses: ['pass', 'warn', 'fail', 'skip'],
96
98
  coverage:
97
99
  'Default fleet-status tests the manager and all configured nodes. --local tests only this manager; --tailnet and --workers cover Tailscale computers. A missing roster fails explicitly. T3/VS Code end-to-end checks remain manual.',
@@ -0,0 +1,126 @@
1
+ import { FleetUsageError } from '../errors';
2
+ import { readHistory, type SyncRun } from '../state/history';
3
+ import type { CommandOutcome } from './contracts';
4
+
5
+ export interface FleetReportOptions {
6
+ since?: string;
7
+ json?: boolean;
8
+ }
9
+
10
+ const UNITS: Record<string, number> = {
11
+ m: 60_000,
12
+ h: 3_600_000,
13
+ d: 86_400_000,
14
+ };
15
+
16
+ /** `30d`, `12h` or `90m`: how far back the report looks. */
17
+ export function parseSince(value = '30d'): number {
18
+ const match = /^([1-9]\d{0,4})([mhd])$/.exec(value);
19
+ if (!match)
20
+ throw new FleetUsageError(
21
+ '--since takes a duration such as 30d, 12h or 90m.'
22
+ );
23
+ return Number(match[1]) * UNITS[match[2]!]!;
24
+ }
25
+
26
+ export interface Reliability {
27
+ runs: number;
28
+ succeeded: number;
29
+ successRate: number | null;
30
+ meanDurationMs: number | null;
31
+ failingChecks: { id: string; runs: number }[];
32
+ notReady: { node: string; ms: number }[];
33
+ }
34
+
35
+ /** How long each machine was not ready: from the end of a run that left it
36
+ * not ready to the end of the next run that found it ready (or until now).
37
+ */
38
+ function notReadyTime(runs: SyncRun[], now: Date): Map<string, number> {
39
+ const since = new Map<string, number>();
40
+ const total = new Map<string, number>();
41
+ const add = (node: string, until: number) => {
42
+ total.set(node, (total.get(node) ?? 0) + until - since.get(node)!);
43
+ since.delete(node);
44
+ };
45
+ for (const run of runs) {
46
+ const at = Date.parse(run.finishedAt);
47
+ for (const node of [...since.keys()])
48
+ if (!run.notReady.includes(node)) add(node, at);
49
+ for (const node of run.notReady) if (!since.has(node)) since.set(node, at);
50
+ }
51
+ for (const node of [...since.keys()]) add(node, now.getTime());
52
+ return total;
53
+ }
54
+
55
+ export function reliability(runs: SyncRun[], now: Date): Reliability {
56
+ const succeeded = runs.filter((run) => run.ok).length;
57
+ const counts = new Map<string, number>();
58
+ for (const run of runs)
59
+ for (const id of run.failing) counts.set(id, (counts.get(id) ?? 0) + 1);
60
+ return {
61
+ runs: runs.length,
62
+ succeeded,
63
+ successRate: runs.length ? succeeded / runs.length : null,
64
+ meanDurationMs: runs.length
65
+ ? Math.round(
66
+ runs.reduce((sum, run) => sum + run.durationMs, 0) / runs.length
67
+ )
68
+ : null,
69
+ failingChecks: [...counts]
70
+ .map(([id, count]) => ({ id, runs: count }))
71
+ .sort((a, b) => b.runs - a.runs || a.id.localeCompare(b.id))
72
+ .slice(0, 10),
73
+ notReady: [...notReadyTime(runs, now)]
74
+ .map(([node, ms]) => ({ node, ms }))
75
+ .sort((a, b) => b.ms - a.ms || a.node.localeCompare(b.node)),
76
+ };
77
+ }
78
+
79
+ /** Read-only: the reliability of fleet-sync over a period, from the run
80
+ * history kept in the fleet state.
81
+ */
82
+ export function fleetReport(
83
+ options: FleetReportOptions,
84
+ env = process.env,
85
+ now = new Date()
86
+ ): CommandOutcome {
87
+ const since = options.since ?? '30d';
88
+ const from = now.getTime() - parseSince(since);
89
+ const runs = readHistory(env).runs.filter(
90
+ (run) => Date.parse(run.finishedAt) >= from
91
+ );
92
+ const summary = reliability(runs, now);
93
+ const data = {
94
+ version: 1,
95
+ command: 'fleet-report',
96
+ ok: true,
97
+ since,
98
+ ...summary,
99
+ entries: runs,
100
+ };
101
+ return { exitCode: 0, data, text: render(since, summary) };
102
+ }
103
+
104
+ function minutes(ms: number): string {
105
+ return `${Math.round(ms / 60_000)} min`;
106
+ }
107
+
108
+ function render(since: string, summary: Reliability): string {
109
+ if (!summary.runs) return `No fleet-sync run in the last ${since}.`;
110
+ const lines = [
111
+ `fleet-sync over the last ${since}: ${summary.runs} runs, ${summary.succeeded} succeeded (${Math.round((summary.successRate ?? 0) * 100)}%), ${Math.round((summary.meanDurationMs ?? 0) / 1000)} s on average.`,
112
+ ];
113
+ if (summary.failingChecks.length)
114
+ lines.push(
115
+ 'Most frequent failing checks:',
116
+ ...summary.failingChecks.map(
117
+ (check) => ` ${check.id} ${check.runs} runs`
118
+ )
119
+ );
120
+ if (summary.notReady.length)
121
+ lines.push(
122
+ 'Time not ready:',
123
+ ...summary.notReady.map((node) => ` ${node.node} ${minutes(node.ms)}`)
124
+ );
125
+ return lines.join('\n');
126
+ }