@crvouga/dev-fleet 0.3.0 → 0.5.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 (97) hide show
  1. package/README.md +25 -17
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +12 -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/platforms.json +111 -0
  9. package/ansible/ubuntu/authorize-sudo.sh +7 -16
  10. package/ansible/ubuntu/baseline.yml +6 -28
  11. package/ansible/ubuntu/disk-gc.py +211 -7
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +68 -4
  14. package/ansible/ubuntu/t3-desktop.yml +2 -2
  15. package/ansible/ubuntu/tailscale.yml +20 -9
  16. package/ansible/ubuntu/tasks/browser.yml +56 -0
  17. package/ansible/ubuntu/tasks/docker.yml +52 -0
  18. package/ansible/ubuntu/tasks/dpkg.yml +46 -0
  19. package/ansible/ubuntu/tasks/install.yml +38 -0
  20. package/ansible/ubuntu/tasks/packages.yml +4 -55
  21. package/ansible/ubuntu/tasks/release.yml +25 -0
  22. package/ansible/ubuntu/tasks/resources.yml +7 -6
  23. package/ansible/ubuntu/tasks/services.yml +13 -0
  24. package/ansible/ubuntu/tasks/snapshot.yml +50 -0
  25. package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
  26. package/bootstrap/doctor.ts +32 -6
  27. package/bootstrap/fleet-remote-bootstrap.sh +49 -45
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +1 -1
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/checkpoint.py +1 -1
  33. package/docs/authentication.md +90 -0
  34. package/docs/consumer-setup.md +32 -30
  35. package/docs/declared.md +6 -5
  36. package/docs/machine-setup.md +22 -12
  37. package/docs/operations.md +87 -31
  38. package/docs/runbooks.md +377 -0
  39. package/docs/state.md +13 -7
  40. package/docs/support-matrix.md +36 -0
  41. package/docs/ubuntu-sync.md +16 -13
  42. package/docs/worker-contract.md +28 -1
  43. package/examples/fleet.json +19 -1
  44. package/llms.txt +2 -0
  45. package/mise.toml +7 -0
  46. package/package.json +2 -2
  47. package/schema/check-ids.json +526 -0
  48. package/schema/fleet.schema.json +185 -4
  49. package/schema/output/check.schema.json +23 -0
  50. package/schema/output/error.schema.json +14 -0
  51. package/schema/output/fleet-report.schema.json +79 -0
  52. package/schema/output/fleet-sync.schema.json +112 -0
  53. package/schema/output/report.schema.json +38 -0
  54. package/src/auth-checks.ts +3 -0
  55. package/src/cli/agent-guide.ts +5 -3
  56. package/src/cli/fleet-report.ts +126 -0
  57. package/src/cli/fleet-sync-manager.ts +11 -0
  58. package/src/cli/fleet-sync-plan.ts +200 -0
  59. package/src/cli/fleet-sync-remote.ts +4 -2
  60. package/src/cli/fleet-sync-schedule.ts +142 -35
  61. package/src/cli/fleet-sync.ts +90 -8
  62. package/src/cli/fleet-watch.ts +198 -0
  63. package/src/cli/fleet.ts +4 -0
  64. package/src/cli/register-fleet.ts +51 -0
  65. package/src/cli/registry.ts +13 -0
  66. package/src/cli/schedule-logs.ts +36 -0
  67. package/src/clock.ts +63 -0
  68. package/src/definition.ts +160 -12
  69. package/src/git-identity.ts +160 -0
  70. package/src/machine/platform.ts +48 -28
  71. package/src/machine/support-matrix.ts +76 -0
  72. package/src/machine-health.ts +51 -4
  73. package/src/manager-health.ts +205 -0
  74. package/src/manager-sync.ts +27 -9
  75. package/src/monitoring/hub-backup.ts +63 -17
  76. package/src/monitoring/manager-hub.ts +44 -2
  77. package/src/network-definition.ts +1 -1
  78. package/src/node-converge-linux.ts +16 -5
  79. package/src/node-converge.ts +1 -1
  80. package/src/notify.ts +239 -0
  81. package/src/platform/debian-version.ts +62 -0
  82. package/src/platform/ubuntu-audit.ts +11 -4
  83. package/src/platform/ubuntu-release.ts +41 -0
  84. package/src/remote-bootstrap.ts +89 -25
  85. package/src/remote-operation.ts +213 -0
  86. package/src/schedule-check.ts +40 -0
  87. package/src/secrets.ts +26 -8
  88. package/src/ssh.ts +10 -2
  89. package/src/state/history.ts +172 -0
  90. package/src/state/store.ts +11 -4
  91. package/src/tailscale.ts +51 -0
  92. package/src/toolchain-checks.ts +8 -3
  93. package/src/worker-capability-checks.ts +167 -0
  94. package/src/worker-capacity.ts +19 -7
  95. package/src/worker-host-checks.ts +68 -10
  96. package/src/worker-policy.ts +33 -0
  97. 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
  }
@@ -202,13 +253,17 @@
202
253
  "required": ["release", "architectures"],
203
254
  "properties": {
204
255
  "release": {
205
- "const": "26.04"
256
+ "type": "string",
257
+ "pattern": "^[0-9]{2}\\.[0-9]{2}$",
258
+ "description": "An Ubuntu release ansible/ubuntu/policy.json accepts (docs/support-matrix.md)."
206
259
  },
207
260
  "architectures": {
208
- "const": ["amd64", "arm64"]
261
+ "type": "array",
262
+ "items": { "enum": ["amd64", "arm64"] },
263
+ "description": "Exactly the architectures the policy names for that release."
209
264
  }
210
265
  },
211
- "description": "The supported worker release and architectures (fixed)."
266
+ "description": "The worker release and its architectures, as the Ubuntu policy of the pinned dev-fleet names them."
212
267
  },
213
268
  "worker": {
214
269
  "type": "object",
@@ -263,9 +318,26 @@
263
318
  },
264
319
  "neverSleep": {
265
320
  "const": true
321
+ },
322
+ "toolProfiles": {
323
+ "type": "array",
324
+ "uniqueItems": true,
325
+ "items": {
326
+ "enum": ["web", "go", "rust", "jvm"]
327
+ },
328
+ "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."
329
+ },
330
+ "tools": {
331
+ "type": "array",
332
+ "uniqueItems": true,
333
+ "items": {
334
+ "type": "string",
335
+ "pattern": "^[A-Za-z0-9@:/._-]{1,75}$"
336
+ },
337
+ "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
338
  }
267
339
  },
268
- "description": "Policy every worker must meet. All but the two logins and the capabilities are fixed."
340
+ "description": "Policy every worker must meet. All but the two logins, the capabilities and the tools are fixed."
269
341
  },
270
342
  "source": {
271
343
  "type": "object",
@@ -475,12 +547,121 @@
475
547
  "pattern": "^[A-Za-z0-9_]+$"
476
548
  }
477
549
  }
550
+ },
551
+ "monitoring-backup-access-key": {
552
+ "type": "object",
553
+ "additionalProperties": false,
554
+ "required": ["path", "field"],
555
+ "properties": {
556
+ "path": {
557
+ "type": "string",
558
+ "pattern": "^[A-Za-z0-9._/-]+$"
559
+ },
560
+ "field": {
561
+ "type": "string",
562
+ "pattern": "^[A-Za-z0-9_]+$"
563
+ }
564
+ },
565
+ "description": "Only with services.monitoring.backups: the access key of the hub's backup storage."
566
+ },
567
+ "monitoring-backup-secret-key": {
568
+ "type": "object",
569
+ "additionalProperties": false,
570
+ "required": ["path", "field"],
571
+ "properties": {
572
+ "path": {
573
+ "type": "string",
574
+ "pattern": "^[A-Za-z0-9._/-]+$"
575
+ },
576
+ "field": {
577
+ "type": "string",
578
+ "pattern": "^[A-Za-z0-9_]+$"
579
+ }
580
+ },
581
+ "description": "Only with services.monitoring.backups: the secret key of the hub's backup storage."
582
+ },
583
+ "notify-webhook-url": {
584
+ "type": "object",
585
+ "additionalProperties": false,
586
+ "required": ["path", "field"],
587
+ "properties": {
588
+ "path": {
589
+ "type": "string",
590
+ "pattern": "^[A-Za-z0-9._/-]+$"
591
+ },
592
+ "field": {
593
+ "type": "string",
594
+ "pattern": "^[A-Za-z0-9_]+$"
595
+ }
596
+ },
597
+ "description": "Only with the webhook notify target: the URL fleet-watch POSTs JSON to."
598
+ },
599
+ "notify-ntfy-url": {
600
+ "type": "object",
601
+ "additionalProperties": false,
602
+ "required": ["path", "field"],
603
+ "properties": {
604
+ "path": {
605
+ "type": "string",
606
+ "pattern": "^[A-Za-z0-9._/-]+$"
607
+ },
608
+ "field": {
609
+ "type": "string",
610
+ "pattern": "^[A-Za-z0-9_]+$"
611
+ }
612
+ },
613
+ "description": "Only with the ntfy notify target: the ntfy topic URL fleet-watch publishes to."
478
614
  }
479
615
  }
480
616
  }
481
617
  }
482
618
  }
483
619
  ]
620
+ },
621
+ "git": {
622
+ "type": "object",
623
+ "additionalProperties": false,
624
+ "required": ["name", "email"],
625
+ "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.",
626
+ "properties": {
627
+ "name": {
628
+ "type": "string",
629
+ "pattern": "^[^\"\\\\\\n\\r]{1,100}$",
630
+ "description": "user.name of every commit."
631
+ },
632
+ "email": {
633
+ "type": "string",
634
+ "format": "email",
635
+ "description": "user.email of every commit, for example the account's noreply address."
636
+ },
637
+ "verifyRepository": {
638
+ "type": "string",
639
+ "pattern": "^https://github\\.com/[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$",
640
+ "description": "A GitHub repository the fleet's account can push to, ideally private. Each node lists it through git with its own credentials."
641
+ }
642
+ }
643
+ },
644
+ "notify": {
645
+ "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).",
646
+ "type": "object",
647
+ "additionalProperties": false,
648
+ "required": ["targets"],
649
+ "properties": {
650
+ "targets": {
651
+ "type": "array",
652
+ "minItems": 1,
653
+ "uniqueItems": true,
654
+ "items": {
655
+ "enum": ["webhook", "ntfy"]
656
+ }
657
+ },
658
+ "repeatHours": {
659
+ "description": "Notify again about a machine that stays unhealthy this often. 0 never repeats; the default is 24.",
660
+ "type": "integer",
661
+ "minimum": 0,
662
+ "maximum": 720
663
+ }
664
+ }
484
665
  }
485
666
  }
486
667
  }
@@ -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
+ }