@crvouga/dev-fleet 0.2.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.
- package/README.md +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import type { Standing } from '../state/sync';
|
|
3
|
+
import type { Runner } from '../types';
|
|
4
|
+
|
|
5
|
+
/** A tool whose pin changes between the applied and the selected revision. */
|
|
6
|
+
export interface ToolChange {
|
|
7
|
+
tool: string;
|
|
8
|
+
from: string | null;
|
|
9
|
+
to: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Something a sync restarts, and whether that ends work in progress. */
|
|
13
|
+
export interface Restart {
|
|
14
|
+
service: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
endsRunningTurns: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What `fleet-sync --dry-run` says a sync would change. It is read from
|
|
20
|
+
* Git (the two revisions' tool pins and the files between them) and the
|
|
21
|
+
* fleet state; nothing on a machine is changed to find it out.
|
|
22
|
+
*/
|
|
23
|
+
export interface SyncPlan {
|
|
24
|
+
/** How the fleet state stood with the bucket when the plan was made. */
|
|
25
|
+
state: Standing;
|
|
26
|
+
revision: { from: string | null; to: string };
|
|
27
|
+
/** False when the applied revision could not be read: the changes are
|
|
28
|
+
* then unknown, not absent.
|
|
29
|
+
*/
|
|
30
|
+
known: boolean;
|
|
31
|
+
tools: ToolChange[];
|
|
32
|
+
restarts: Restart[];
|
|
33
|
+
nodes: { node: string; from: string | null; to: string; changes: boolean }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The coding agents' CLIs: a new version is used by the next session, and a
|
|
37
|
+
* running one keeps the binary it started with.
|
|
38
|
+
*/
|
|
39
|
+
const AGENT_TOOLS = [
|
|
40
|
+
'claude',
|
|
41
|
+
'codex',
|
|
42
|
+
'gemini',
|
|
43
|
+
'copilot',
|
|
44
|
+
'aqua:anomalyco/opencode',
|
|
45
|
+
'http:grok',
|
|
46
|
+
'http:antigravity',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function section(text: string, name: string): string {
|
|
50
|
+
const start = text.search(new RegExp(`^\\[${name}\\]\\s*$`, 'm'));
|
|
51
|
+
if (start < 0) return '';
|
|
52
|
+
const rest = text.slice(start).split('\n').slice(1);
|
|
53
|
+
const end = rest.findIndex((line) => /^\[/.test(line));
|
|
54
|
+
return (end < 0 ? rest : rest.slice(0, end)).join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The exact pins under [tools], and T3's under [vars]. */
|
|
58
|
+
export function pins(miseToml: string): Map<string, string> {
|
|
59
|
+
const found = new Map<string, string>();
|
|
60
|
+
for (const line of section(miseToml, 'tools').split('\n')) {
|
|
61
|
+
const match =
|
|
62
|
+
/^\s*"?([A-Za-z0-9@:/._-]+)"?\s*=\s*(?:"([^"]+)"|\{.*?\bversion\s*=\s*"([^"]+)")/.exec(
|
|
63
|
+
line
|
|
64
|
+
);
|
|
65
|
+
if (match) found.set(match[1]!, (match[2] ?? match[3])!);
|
|
66
|
+
}
|
|
67
|
+
const t3 = /^\s*t3_version\s*=\s*"([^"]+)"/m.exec(section(miseToml, 'vars'));
|
|
68
|
+
if (t3) found.set('t3', t3[1]!);
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function toolChanges(
|
|
73
|
+
before: Map<string, string>,
|
|
74
|
+
after: Map<string, string>
|
|
75
|
+
): ToolChange[] {
|
|
76
|
+
const names = [...new Set([...before.keys(), ...after.keys()])].sort();
|
|
77
|
+
return names
|
|
78
|
+
.filter((tool) => before.get(tool) !== after.get(tool))
|
|
79
|
+
.map((tool) => ({
|
|
80
|
+
tool,
|
|
81
|
+
from: before.get(tool) ?? null,
|
|
82
|
+
to: after.get(tool) ?? null,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What restarts follow from the changes, as the playbooks and the toolchain
|
|
87
|
+
* setup act on them.
|
|
88
|
+
*/
|
|
89
|
+
export function restarts(tools: ToolChange[], files: string[]): Restart[] {
|
|
90
|
+
const found: Restart[] = [];
|
|
91
|
+
if (tools.some((change) => change.tool === 't3'))
|
|
92
|
+
found.push({
|
|
93
|
+
service: 't3',
|
|
94
|
+
reason:
|
|
95
|
+
'T3 is upgraded and restarted on every machine; its running agent turns end.',
|
|
96
|
+
endsRunningTurns: true,
|
|
97
|
+
});
|
|
98
|
+
else if (
|
|
99
|
+
files.some((file) =>
|
|
100
|
+
/^(agents\/|ansible\/ubuntu\/(t3-policy\.yml|t3\/))/.test(file)
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
found.push({
|
|
104
|
+
service: 't3',
|
|
105
|
+
reason:
|
|
106
|
+
'The managed T3 policy may change: a T3 that has not loaded it is restarted, and its running agent turns end.',
|
|
107
|
+
endsRunningTurns: true,
|
|
108
|
+
});
|
|
109
|
+
const agents = tools.filter((change) => AGENT_TOOLS.includes(change.tool));
|
|
110
|
+
if (agents.length)
|
|
111
|
+
found.push({
|
|
112
|
+
service: 'agent-clis',
|
|
113
|
+
reason: `New sessions use the new ${agents.map((change) => change.tool).join(', ')}; running turns keep the version they started with.`,
|
|
114
|
+
endsRunningTurns: false,
|
|
115
|
+
});
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function read(
|
|
120
|
+
run: Runner,
|
|
121
|
+
cwd: string,
|
|
122
|
+
args: string[]
|
|
123
|
+
): Promise<string | null> {
|
|
124
|
+
const result = await run(['git', ...args], { cwd, timeoutMs: 20_000 });
|
|
125
|
+
return result.ok ? result.stdout : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read-only: the plan of a sync from `from` (the revision the fleet last
|
|
129
|
+
* applied) to `to`. Git objects come from the checkout this runs in or the
|
|
130
|
+
* manager's cache of the source repository; nothing is fetched.
|
|
131
|
+
*/
|
|
132
|
+
export async function syncPlan(
|
|
133
|
+
input: {
|
|
134
|
+
from: string | null;
|
|
135
|
+
to: string;
|
|
136
|
+
state: Standing;
|
|
137
|
+
nodes: string[];
|
|
138
|
+
sources: string[];
|
|
139
|
+
},
|
|
140
|
+
run: Runner
|
|
141
|
+
): Promise<SyncPlan> {
|
|
142
|
+
let changes: { tools: ToolChange[]; files: string[] } | null =
|
|
143
|
+
input.from === input.to ? { tools: [], files: [] } : null;
|
|
144
|
+
for (const cwd of input.from && !changes ? input.sources : []) {
|
|
145
|
+
const before = await read(run, cwd, ['show', `${input.from}:mise.toml`]);
|
|
146
|
+
const after = await read(run, cwd, ['show', `${input.to}:mise.toml`]);
|
|
147
|
+
const files = await read(run, cwd, [
|
|
148
|
+
'diff',
|
|
149
|
+
'--name-only',
|
|
150
|
+
input.from!,
|
|
151
|
+
input.to,
|
|
152
|
+
]);
|
|
153
|
+
if (before === null || after === null || files === null) continue;
|
|
154
|
+
changes = {
|
|
155
|
+
tools: toolChanges(pins(before), pins(after)),
|
|
156
|
+
files: files.split('\n').filter(Boolean),
|
|
157
|
+
};
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const differs = input.from !== input.to;
|
|
161
|
+
return {
|
|
162
|
+
state: input.state,
|
|
163
|
+
revision: { from: input.from, to: input.to },
|
|
164
|
+
known: changes !== null,
|
|
165
|
+
tools: changes?.tools ?? [],
|
|
166
|
+
restarts: changes ? restarts(changes.tools, changes.files) : [],
|
|
167
|
+
nodes: input.nodes.map((node) => ({
|
|
168
|
+
node,
|
|
169
|
+
from: input.from,
|
|
170
|
+
to: input.to,
|
|
171
|
+
changes: differs,
|
|
172
|
+
})),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const planSources = (workspace: string, root: string): string[] => [
|
|
177
|
+
workspace,
|
|
178
|
+
join(root, 'source'),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
/** The plan, for people: one line per change. */
|
|
182
|
+
export function planText(plan: SyncPlan): string {
|
|
183
|
+
const short = (revision: string | null) => revision?.slice(0, 12) ?? 'none';
|
|
184
|
+
const lines = [
|
|
185
|
+
plan.revision.from === plan.revision.to
|
|
186
|
+
? `Plan: the fleet stays at ${short(plan.revision.to)}.`
|
|
187
|
+
: `Plan: ${short(plan.revision.from)} → ${short(plan.revision.to)} on ${plan.nodes.map((node) => node.node).join(', ') || 'no machine'}.`,
|
|
188
|
+
];
|
|
189
|
+
if (!plan.known)
|
|
190
|
+
lines.push(
|
|
191
|
+
' The applied revision is not in a local repository: tool and restart changes are unknown.'
|
|
192
|
+
);
|
|
193
|
+
for (const change of plan.tools)
|
|
194
|
+
lines.push(
|
|
195
|
+
` ${change.tool} ${change.from ?? 'not installed'} → ${change.to ?? 'removed'}`
|
|
196
|
+
);
|
|
197
|
+
for (const restart of plan.restarts)
|
|
198
|
+
lines.push(` Restarts ${restart.service}: ${restart.reason}`);
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { verifyT3Linked } from '../t3-linked-check';
|
|
2
|
-
import { T3_RESTARTED_SUMMARY } from '../platform/t3-policy';
|
|
3
1
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import {
|
|
2
|
+
import { IDES } from '../ide';
|
|
3
|
+
import { enabled, workerChecks } from '../ide/orchestrate';
|
|
4
|
+
import { idesOf } from '../ide/definition';
|
|
5
|
+
import { rememberEnvironment } from '../t3/pairing';
|
|
5
6
|
import { authorizePrivilege } from './fleet-sync-privilege';
|
|
6
7
|
import { authorizeVault } from './fleet-sync-vault';
|
|
7
8
|
import { managerFailure } from '../manager-failure';
|
|
@@ -92,7 +93,11 @@ export async function selected(
|
|
|
92
93
|
),
|
|
93
94
|
},
|
|
94
95
|
run,
|
|
95
|
-
deployWithMonitoring(payload, canonical, run,
|
|
96
|
+
deployWithMonitoring(payload, canonical, run, {
|
|
97
|
+
...ui,
|
|
98
|
+
workspace: settings.workspace,
|
|
99
|
+
roster: settings.config,
|
|
100
|
+
})
|
|
96
101
|
).finally(() => ui.progress.stop());
|
|
97
102
|
if (settle) report.checks.push(await settle(report));
|
|
98
103
|
return outcome(report, planFingerprint, started);
|
|
@@ -157,6 +162,9 @@ function deployWithMonitoring(
|
|
|
157
162
|
ui: {
|
|
158
163
|
progress: SyncProgress;
|
|
159
164
|
interactiveAuthorization: boolean;
|
|
165
|
+
/** The manager's checkout, and the roster an identity is recorded in. */
|
|
166
|
+
workspace: string;
|
|
167
|
+
roster: string;
|
|
160
168
|
}
|
|
161
169
|
): DeployNode {
|
|
162
170
|
return async (node, revision) => {
|
|
@@ -185,7 +193,9 @@ function deployWithMonitoring(
|
|
|
185
193
|
if (ui.interactiveAuthorization) ui.progress.stop();
|
|
186
194
|
const before = await authorizeVault(node, vault, run);
|
|
187
195
|
ui.progress.step(node.id, 'Converging (remote lock, install, verify)');
|
|
188
|
-
const result = await bootstrapRemote(node, payload, run
|
|
196
|
+
const result = await bootstrapRemote(node, payload, run, {
|
|
197
|
+
progress: (line) => ui.progress.step(node.id, line),
|
|
198
|
+
});
|
|
189
199
|
// A fresh node gets vault from the bootstrap; log it in now. Its provider
|
|
190
200
|
// checks in this run predate the login, so the next sync verifies them.
|
|
191
201
|
result.checks.push(
|
|
@@ -194,16 +204,15 @@ function deployWithMonitoring(
|
|
|
194
204
|
// Workers are Linux by definition.
|
|
195
205
|
ui.progress.step(node.id, 'Verifying SSH, OS and T3');
|
|
196
206
|
result.checks.push(...(await inspectBootstrap(node, run, 'linux')));
|
|
197
|
-
result.checks.push(...(await verifyT3Endpoint(node, run)));
|
|
198
|
-
// A worker T3 restarted by this sync (idle policy heal) needs a minute or
|
|
199
|
-
// two before the manager's link reconnects.
|
|
200
|
-
const restarted = result.checks.some(
|
|
201
|
-
(check) => check.summary === T3_RESTARTED_SUMMARY
|
|
202
|
-
);
|
|
203
207
|
result.checks.push(
|
|
204
|
-
await
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
...(await workerChecks(enabled(idesOf(canonical), IDES), node, {
|
|
209
|
+
run,
|
|
210
|
+
workspace: ui.workspace,
|
|
211
|
+
earlier: result.checks,
|
|
212
|
+
remember: async (id, identity) => {
|
|
213
|
+
await rememberEnvironment(ui.roster, id, identity);
|
|
214
|
+
},
|
|
215
|
+
}))
|
|
207
216
|
);
|
|
208
217
|
if (
|
|
209
218
|
result.checks.some(
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
1
3
|
import { join } from 'node:path';
|
|
2
4
|
import { currentPlatform } from '../platform';
|
|
3
5
|
import type { Platform } from '../platform/types';
|
|
6
|
+
import { PACKAGE_ROOT, packageInfo, type PackageInfo } from '../package-info';
|
|
7
|
+
import {
|
|
8
|
+
readHistory,
|
|
9
|
+
recordSchedule,
|
|
10
|
+
type ScheduleRecord,
|
|
11
|
+
} from '../state/history';
|
|
12
|
+
import { scheduleCheck, SCHEDULE_INTERVAL_SECONDS } from '../schedule-check';
|
|
4
13
|
import type { Runner } from '../types';
|
|
5
14
|
import type { CommandOutcome } from './contracts';
|
|
6
15
|
import { machineRoot } from './fleet-sync';
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
/** The launchd label and systemd unit already on running managers. */
|
|
18
|
+
export const SCHEDULE_LABEL = 'personal-fleet-sync';
|
|
19
|
+
const LABEL = SCHEDULE_LABEL;
|
|
11
20
|
export type ScheduleAction =
|
|
12
21
|
'enable' | 'pause' | 'resume' | 'disable' | 'status';
|
|
13
22
|
interface ScheduleInput {
|
|
@@ -15,33 +24,145 @@ interface ScheduleInput {
|
|
|
15
24
|
revision: string;
|
|
16
25
|
config: string;
|
|
17
26
|
authorized: boolean;
|
|
27
|
+
/** The package this CLI runs from; the schedule installs the same one. */
|
|
28
|
+
release?: Release;
|
|
29
|
+
/** Where the schedule is recorded in the fleet state; tests replace it. */
|
|
30
|
+
record?: (schedule: ScheduleRecord) => void;
|
|
31
|
+
/** The manager's caches; tests name their own. */
|
|
32
|
+
root?: string;
|
|
33
|
+
now?: Date;
|
|
18
34
|
}
|
|
35
|
+
|
|
36
|
+
/** The published package a schedule installs and runs: its name, exact
|
|
37
|
+
* version and the entry point its manifest declares.
|
|
38
|
+
*/
|
|
39
|
+
export interface Release extends PackageInfo {
|
|
40
|
+
bin: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function release(root = PACKAGE_ROOT): Release {
|
|
44
|
+
let bin: unknown = null;
|
|
45
|
+
try {
|
|
46
|
+
const manifest = JSON.parse(
|
|
47
|
+
readFileSync(join(root, 'package.json'), 'utf8')
|
|
48
|
+
) as { bin?: unknown };
|
|
49
|
+
bin = typeof manifest.bin === 'string' ? manifest.bin : null;
|
|
50
|
+
if (typeof manifest.bin === 'object' && manifest.bin !== null)
|
|
51
|
+
bin = (manifest.bin as Record<string, unknown>)['devfleet'] ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
bin = null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...packageInfo(root),
|
|
57
|
+
bin: typeof bin === 'string' ? bin.replace(/^\.\//, '') : null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Only a published release can be scheduled: it names an exact version and
|
|
62
|
+
* the commit it was built from, so every unattended run is the same code.
|
|
63
|
+
*/
|
|
64
|
+
export function publishedRelease(release: Release): boolean {
|
|
65
|
+
return (
|
|
66
|
+
/^\d+\.\d+\.\d+$/.test(release.version) &&
|
|
67
|
+
release.revision !== null &&
|
|
68
|
+
release.bin !== null &&
|
|
69
|
+
!release.bin.split('/').includes('..')
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What launchd (or the systemd timer) runs: the installed package's own
|
|
74
|
+
* entry point under Node, with the tools fleet-sync installs on PATH. No path
|
|
75
|
+
* into the project or the package the operator ran is written into it.
|
|
76
|
+
*/
|
|
77
|
+
export function scheduledCommand(
|
|
78
|
+
release: Release,
|
|
79
|
+
revision: string,
|
|
80
|
+
prefix: string
|
|
81
|
+
): string[] {
|
|
82
|
+
return [
|
|
83
|
+
'/usr/bin/env',
|
|
84
|
+
'node',
|
|
85
|
+
join(prefix, 'node_modules', release.name, release.bin ?? ''),
|
|
86
|
+
'fleet-sync',
|
|
87
|
+
'--revision',
|
|
88
|
+
revision,
|
|
89
|
+
'--apply',
|
|
90
|
+
'--yes',
|
|
91
|
+
'--non-interactive',
|
|
92
|
+
'--json',
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function scheduledEnvironment(home = homedir()): Record<string, string> {
|
|
97
|
+
return {
|
|
98
|
+
PATH: [
|
|
99
|
+
join(home, '.local/share/mise/shims'),
|
|
100
|
+
join(home, '.local/bin'),
|
|
101
|
+
'/opt/homebrew/bin',
|
|
102
|
+
'/usr/local/bin',
|
|
103
|
+
'/usr/bin',
|
|
104
|
+
'/bin',
|
|
105
|
+
].join(':'),
|
|
106
|
+
DEVFLEET_TRIGGER: 'schedule',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
19
110
|
export async function managerSchedule(
|
|
20
111
|
input: ScheduleInput,
|
|
21
112
|
run: Runner,
|
|
22
113
|
platform: Platform = currentPlatform()
|
|
23
114
|
): Promise<CommandOutcome> {
|
|
24
|
-
if (input.action === 'status')
|
|
25
|
-
return {
|
|
26
|
-
exitCode: 0,
|
|
27
|
-
data: {
|
|
28
|
-
version: 2,
|
|
29
|
-
command: 'fleet-sync',
|
|
30
|
-
execution: 'manager-push',
|
|
31
|
-
scheduling: scheduleStatus(platform),
|
|
32
|
-
},
|
|
33
|
-
};
|
|
115
|
+
if (input.action === 'status') return statusReport(platform, input.now);
|
|
34
116
|
if (!input.authorized)
|
|
35
117
|
return required('Manager scheduling changes require --apply --yes.');
|
|
36
118
|
if (input.action === 'pause' || input.action === 'disable')
|
|
37
119
|
return removeSchedule(input, platform);
|
|
38
120
|
return enableSchedule(input, run, platform);
|
|
39
121
|
}
|
|
122
|
+
|
|
123
|
+
function statusReport(platform: Platform, now = new Date()): CommandOutcome {
|
|
124
|
+
const history = readHistory();
|
|
125
|
+
const check = scheduleCheck(history, now);
|
|
126
|
+
return {
|
|
127
|
+
exitCode: 0,
|
|
128
|
+
data: {
|
|
129
|
+
version: 2,
|
|
130
|
+
command: 'fleet-sync',
|
|
131
|
+
execution: 'manager-push',
|
|
132
|
+
scheduling: scheduleStatus(platform),
|
|
133
|
+
intervalSeconds:
|
|
134
|
+
history.schedule?.intervalSeconds ?? SCHEDULE_INTERVAL_SECONDS,
|
|
135
|
+
runs: history.runs
|
|
136
|
+
.filter((entry) => entry.trigger === 'schedule')
|
|
137
|
+
.slice(-3)
|
|
138
|
+
.reverse(),
|
|
139
|
+
...(check ? { checks: [check] } : {}),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function remember(
|
|
145
|
+
input: ScheduleInput,
|
|
146
|
+
enabled: boolean,
|
|
147
|
+
intervalSeconds = SCHEDULE_INTERVAL_SECONDS
|
|
148
|
+
): void {
|
|
149
|
+
try {
|
|
150
|
+
(input.record ?? recordSchedule)({
|
|
151
|
+
enabled,
|
|
152
|
+
intervalSeconds,
|
|
153
|
+
changedAt: (input.now ?? new Date()).toISOString(),
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
// The schedule changed either way; the next run records it again.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
40
160
|
function removeSchedule(
|
|
41
161
|
input: ScheduleInput,
|
|
42
162
|
platform: Platform
|
|
43
163
|
): CommandOutcome {
|
|
44
164
|
const result = platform.removeScheduledJob(LABEL);
|
|
165
|
+
if (result.ok) remember(input, false);
|
|
45
166
|
return {
|
|
46
167
|
exitCode: result.ok ? 0 : 1,
|
|
47
168
|
data: {
|
|
@@ -60,15 +181,12 @@ async function enableSchedule(
|
|
|
60
181
|
run: Runner,
|
|
61
182
|
platform: Platform
|
|
62
183
|
): Promise<CommandOutcome> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
!/^\d+\.\d+\.\d+$/.test(PERSONAL_VERSION) ||
|
|
66
|
-
typeof PERSONAL_SOURCE_REVISION !== 'string'
|
|
67
|
-
)
|
|
184
|
+
const published = input.release ?? release();
|
|
185
|
+
if (!publishedRelease(published))
|
|
68
186
|
return required(
|
|
69
187
|
'Scheduling requires a published CLI with clean source provenance; dirty review artifacts cannot enable unattended deployment.'
|
|
70
188
|
);
|
|
71
|
-
const root = machineRoot();
|
|
189
|
+
const root = input.root ?? machineRoot();
|
|
72
190
|
const prefix = join(root, 'runtime');
|
|
73
191
|
const installed = await run(
|
|
74
192
|
[
|
|
@@ -79,7 +197,7 @@ async function enableSchedule(
|
|
|
79
197
|
'--ignore-scripts',
|
|
80
198
|
'--no-audit',
|
|
81
199
|
'--no-fund',
|
|
82
|
-
|
|
200
|
+
`${published.name}@${published.version}`,
|
|
83
201
|
],
|
|
84
202
|
{ timeoutMs: 120_000 }
|
|
85
203
|
);
|
|
@@ -89,23 +207,12 @@ async function enableSchedule(
|
|
|
89
207
|
);
|
|
90
208
|
const result = platform.installScheduledJob({
|
|
91
209
|
label: LABEL,
|
|
92
|
-
intervalSeconds:
|
|
93
|
-
command:
|
|
94
|
-
process.execPath,
|
|
95
|
-
join(prefix, 'node_modules/dev-fleet/cli.mjs'),
|
|
96
|
-
'fleet-sync',
|
|
97
|
-
'--config',
|
|
98
|
-
input.config,
|
|
99
|
-
'--revision',
|
|
100
|
-
input.revision,
|
|
101
|
-
'--apply',
|
|
102
|
-
'--yes',
|
|
103
|
-
'--non-interactive',
|
|
104
|
-
'--json',
|
|
105
|
-
],
|
|
210
|
+
intervalSeconds: SCHEDULE_INTERVAL_SECONDS,
|
|
211
|
+
command: scheduledCommand(published, input.revision, prefix),
|
|
106
212
|
logDir: join(root, 'logs'),
|
|
107
|
-
env:
|
|
213
|
+
env: scheduledEnvironment(),
|
|
108
214
|
});
|
|
215
|
+
if (result.ok) remember(input, true);
|
|
109
216
|
return {
|
|
110
217
|
exitCode: result.ok ? 0 : 1,
|
|
111
218
|
data: {
|