@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.
- package/README.md +183 -251
- package/dist/adapters/detector/index.d.ts +8 -0
- package/dist/adapters/detector/index.js +54 -0
- package/dist/adapters/notifier/index.d.ts +2 -0
- package/dist/adapters/notifier/index.js +2 -0
- package/dist/adapters/notifier/stdout.d.ts +2 -0
- package/dist/adapters/notifier/stdout.js +8 -0
- package/dist/adapters/notifier/webhook.d.ts +9 -0
- package/dist/adapters/notifier/webhook.js +38 -0
- package/dist/adapters/runner/claude-cli.d.ts +7 -0
- package/dist/adapters/runner/claude-cli.js +231 -0
- package/dist/adapters/runner/mcp-call.d.ts +8 -0
- package/dist/adapters/runner/mcp-call.js +82 -0
- package/dist/adapters/runner/shell.d.ts +2 -0
- package/dist/adapters/runner/shell.js +103 -0
- package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
- package/dist/adapters/scheduler/systemd-timer.js +149 -0
- package/dist/adapters/signals/ci-status.d.ts +2 -0
- package/dist/adapters/signals/ci-status.js +79 -0
- package/dist/adapters/signals/container-up.d.ts +5 -0
- package/dist/adapters/signals/container-up.js +54 -0
- package/dist/adapters/signals/git-clean.d.ts +2 -0
- package/dist/adapters/signals/git-clean.js +55 -0
- package/dist/adapters/signals/index.d.ts +6 -0
- package/dist/adapters/signals/index.js +7 -0
- package/dist/adapters/types.d.ts +52 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +43 -2
- package/dist/commands/add.js +0 -6
- package/dist/commands/boot-start.d.ts +1 -0
- package/dist/commands/boot-start.js +51 -0
- package/dist/commands/deploy.js +13 -0
- package/dist/commands/deps.js +5 -0
- package/dist/commands/egress.d.ts +1 -0
- package/dist/commands/egress.js +106 -0
- package/dist/commands/freeze.d.ts +4 -0
- package/dist/commands/freeze.js +64 -0
- package/dist/commands/logs.d.ts +1 -1
- package/dist/commands/logs.js +237 -8
- package/dist/commands/patch-systemd.d.ts +1 -0
- package/dist/commands/patch-systemd.js +126 -0
- package/dist/commands/rollback.d.ts +1 -0
- package/dist/commands/rollback.js +58 -0
- package/dist/commands/routine-run.d.ts +1 -0
- package/dist/commands/routine-run.js +122 -0
- package/dist/commands/routines.d.ts +1 -0
- package/dist/commands/routines.js +25 -0
- package/dist/commands/secrets.js +449 -16
- package/dist/commands/status.js +7 -3
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +16 -40
- package/dist/core/boot-refresh.d.ts +57 -0
- package/dist/core/boot-refresh.js +116 -0
- package/dist/core/deps/actors/pr-creator.js +11 -9
- package/dist/core/deps/collectors/docker-running.js +2 -2
- package/dist/core/deps/collectors/github-pr.js +5 -2
- package/dist/core/deps/collectors/npm.js +10 -5
- package/dist/core/deps/collectors/vulnerability.js +10 -6
- package/dist/core/deps/reporters/motd.js +1 -1
- package/dist/core/deps/reporters/telegram.js +2 -29
- package/dist/core/docker.js +45 -15
- package/dist/core/egress.d.ts +41 -0
- package/dist/core/egress.js +161 -0
- package/dist/core/exec.d.ts +7 -1
- package/dist/core/exec.js +25 -17
- package/dist/core/git.d.ts +1 -0
- package/dist/core/git.js +36 -23
- package/dist/core/github.js +27 -8
- package/dist/core/health.d.ts +3 -0
- package/dist/core/health.js +15 -3
- package/dist/core/logs-multi.d.ts +73 -0
- package/dist/core/logs-multi.js +163 -0
- package/dist/core/logs-policy.d.ts +55 -0
- package/dist/core/logs-policy.js +148 -0
- package/dist/core/nginx.js +8 -4
- package/dist/core/notify.d.ts +15 -0
- package/dist/core/notify.js +55 -0
- package/dist/core/registry.d.ts +25 -0
- package/dist/core/registry.js +57 -10
- package/dist/core/routines/cost-queries.d.ts +24 -0
- package/dist/core/routines/cost-queries.js +65 -0
- package/dist/core/routines/db.d.ts +9 -0
- package/dist/core/routines/db.js +126 -0
- package/dist/core/routines/defaults.d.ts +2 -0
- package/dist/core/routines/defaults.js +72 -0
- package/dist/core/routines/engine.d.ts +59 -0
- package/dist/core/routines/engine.js +175 -0
- package/dist/core/routines/incidents.d.ts +13 -0
- package/dist/core/routines/incidents.js +35 -0
- package/dist/core/routines/schema.d.ts +418 -0
- package/dist/core/routines/schema.js +113 -0
- package/dist/core/routines/signals-collector.d.ts +35 -0
- package/dist/core/routines/signals-collector.js +114 -0
- package/dist/core/routines/store.d.ts +316 -0
- package/dist/core/routines/store.js +99 -0
- package/dist/core/routines/test-utils.d.ts +2 -0
- package/dist/core/routines/test-utils.js +13 -0
- package/dist/core/secrets-audit.d.ts +21 -0
- package/dist/core/secrets-audit.js +60 -0
- package/dist/core/secrets-metadata.d.ts +39 -0
- package/dist/core/secrets-metadata.js +82 -0
- package/dist/core/secrets-motd.d.ts +20 -0
- package/dist/core/secrets-motd.js +72 -0
- package/dist/core/secrets-ops.d.ts +3 -1
- package/dist/core/secrets-ops.js +78 -13
- package/dist/core/secrets-providers.d.ts +50 -0
- package/dist/core/secrets-providers.js +291 -0
- package/dist/core/secrets-rotation.d.ts +52 -0
- package/dist/core/secrets-rotation.js +165 -0
- package/dist/core/secrets-snapshots.d.ts +26 -0
- package/dist/core/secrets-snapshots.js +95 -0
- package/dist/core/secrets-validate.js +2 -1
- package/dist/core/secrets.d.ts +12 -1
- package/dist/core/secrets.js +35 -24
- package/dist/core/self-update.d.ts +41 -0
- package/dist/core/self-update.js +73 -0
- package/dist/core/systemd.js +29 -12
- package/dist/core/telegram.d.ts +6 -0
- package/dist/core/telegram.js +32 -0
- package/dist/core/validate.d.ts +7 -0
- package/dist/core/validate.js +42 -0
- package/dist/index.js +0 -4
- package/dist/mcp/deps-tools.js +9 -1
- package/dist/mcp/git-tools.js +4 -4
- package/dist/mcp/server.js +193 -8
- package/dist/templates/systemd.js +3 -3
- package/dist/templates/unseal.js +5 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +14 -5
- package/dist/tui/exec-bridge.js +26 -12
- package/dist/tui/hooks/use-fleet-data.js +5 -2
- package/dist/tui/hooks/use-health.js +5 -2
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +133 -8
- package/dist/tui/routines/RoutinesApp.d.ts +8 -0
- package/dist/tui/routines/RoutinesApp.js +277 -0
- package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
- package/dist/tui/routines/components/AlertsPanel.js +22 -0
- package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
- package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
- package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
- package/dist/tui/routines/components/CommandPalette.js +21 -0
- package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
- package/dist/tui/routines/components/LiveRunPanel.js +107 -0
- package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
- package/dist/tui/routines/components/RoutineForm.js +254 -0
- package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
- package/dist/tui/routines/components/SignalsGrid.js +34 -0
- package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
- package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
- package/dist/tui/routines/format.d.ts +7 -0
- package/dist/tui/routines/format.js +51 -0
- package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
- package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
- package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
- package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
- package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
- package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
- package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
- package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
- package/dist/tui/routines/hooks/use-security.d.ts +33 -0
- package/dist/tui/routines/hooks/use-security.js +110 -0
- package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
- package/dist/tui/routines/hooks/use-signals.js +60 -0
- package/dist/tui/routines/runtime.d.ts +20 -0
- package/dist/tui/routines/runtime.js +40 -0
- package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
- package/dist/tui/routines/tabs/CostTab.js +24 -0
- package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
- package/dist/tui/routines/tabs/DashboardTab.js +10 -0
- package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
- package/dist/tui/routines/tabs/GitTab.js +39 -0
- package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/LogsTab.js +58 -0
- package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/OpsTab.js +34 -0
- package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
- package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
- package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
- package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
- package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
- package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
- package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SecurityTab.js +31 -0
- package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SettingsTab.js +61 -0
- package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
- package/dist/tui/routines/tabs/TimelineTab.js +26 -0
- package/dist/tui/state.js +16 -1
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +120 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +14 -1
- package/dist/tui/views/AppDetail.js +40 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +42 -12
- package/dist/tui/views/LogsView.js +38 -10
- package/dist/tui/views/MultiLogsView.d.ts +2 -0
- package/dist/tui/views/MultiLogsView.js +165 -0
- package/dist/tui/views/SecretEdit.js +18 -7
- package/dist/tui/views/SecretsView.js +55 -39
- package/dist/ui/prompt.d.ts +52 -0
- package/dist/ui/prompt.js +169 -0
- package/package.json +33 -5
- package/dist/commands/motd.d.ts +0 -1
- package/dist/commands/motd.js +0 -10
- package/dist/templates/motd.d.ts +0 -1
- package/dist/templates/motd.js +0 -7
- package/dist/tui/components/AppList.d.ts +0 -12
- package/dist/tui/components/AppList.js +0 -32
- package/dist/tui/hooks/use-keyboard.d.ts +0 -1
- package/dist/tui/hooks/use-keyboard.js +0 -44
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
export function createWebhookNotifier(opts) {
|
|
3
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
4
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
5
|
+
return {
|
|
6
|
+
id: 'webhook',
|
|
7
|
+
async notify(subject, body, meta) {
|
|
8
|
+
const payload = JSON.stringify({ subject, body, ...meta, at: new Date().toISOString() });
|
|
9
|
+
const headers = {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
...opts.headers,
|
|
12
|
+
};
|
|
13
|
+
if (opts.secret) {
|
|
14
|
+
const sig = createHmac('sha256', opts.secret).update(payload).digest('hex');
|
|
15
|
+
headers['X-Fleet-Signature'] = `sha256=${sig}`;
|
|
16
|
+
}
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetcher(opts.url, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers,
|
|
23
|
+
body: payload,
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
process.stderr.write(`[webhook] ${opts.url} returned ${res.status}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
process.stderr.write(`[webhook] ${opts.url} failed: ${err.message}\n`);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import lockfile from 'proper-lockfile';
|
|
5
|
+
function summariseArgs(args) {
|
|
6
|
+
const s = JSON.stringify(args ?? null);
|
|
7
|
+
return s.length > 200 ? `${s.slice(0, 197)}...` : s;
|
|
8
|
+
}
|
|
9
|
+
function ensureDir(path) {
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
mkdirSync(path, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
export function createClaudeCliRunner(opts = {}) {
|
|
14
|
+
const binary = opts.binary ?? 'claude';
|
|
15
|
+
const lockRoot = opts.lockRoot ?? '/var/lib/fleet/locks';
|
|
16
|
+
const configRoot = opts.configRoot ?? '/var/lib/fleet/claude-configs';
|
|
17
|
+
return {
|
|
18
|
+
id: 'claude-cli',
|
|
19
|
+
supports(task) {
|
|
20
|
+
return task.kind === 'claude-cli';
|
|
21
|
+
},
|
|
22
|
+
async *run(task, ctx, signal) {
|
|
23
|
+
if (task.kind !== 'claude-cli')
|
|
24
|
+
throw new Error('claude-cli runner received wrong task kind');
|
|
25
|
+
ensureDir(lockRoot);
|
|
26
|
+
ensureDir(configRoot);
|
|
27
|
+
const lockTarget = join(lockRoot, 'claude-cli');
|
|
28
|
+
ensureDir(lockTarget);
|
|
29
|
+
const startedAt = new Date().toISOString();
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
yield { kind: 'start', routineId: ctx.routineId, target: ctx.repo ?? null, at: startedAt };
|
|
32
|
+
let release = null;
|
|
33
|
+
try {
|
|
34
|
+
release = await lockfile.lock(lockTarget, { stale: 30 * 60 * 1000, retries: 0 });
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
yield {
|
|
38
|
+
kind: 'end',
|
|
39
|
+
status: 'failed',
|
|
40
|
+
exitCode: -1,
|
|
41
|
+
durationMs: Date.now() - startTime,
|
|
42
|
+
at: new Date().toISOString(),
|
|
43
|
+
error: `mutex busy: another claude-cli routine is running (${err.message})`,
|
|
44
|
+
};
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const perRoutineConfigDir = join(configRoot, ctx.routineId);
|
|
48
|
+
ensureDir(perRoutineConfigDir);
|
|
49
|
+
const args = [
|
|
50
|
+
'-p', task.prompt,
|
|
51
|
+
'--output-format', 'stream-json',
|
|
52
|
+
'--verbose',
|
|
53
|
+
];
|
|
54
|
+
if (task.model)
|
|
55
|
+
args.push('--model', task.model);
|
|
56
|
+
if (task.appendSystem)
|
|
57
|
+
args.push('--append-system-prompt', task.appendSystem);
|
|
58
|
+
if (task.allowedTools && task.allowedTools.length > 0) {
|
|
59
|
+
args.push('--allowed-tools', task.allowedTools.join(','));
|
|
60
|
+
}
|
|
61
|
+
const env = {
|
|
62
|
+
...process.env,
|
|
63
|
+
...ctx.env,
|
|
64
|
+
CLAUDE_CONFIG_DIR: perRoutineConfigDir,
|
|
65
|
+
};
|
|
66
|
+
const child = spawn(binary, args, {
|
|
67
|
+
cwd: ctx.repoPath ?? undefined,
|
|
68
|
+
env,
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
const buffer = [];
|
|
72
|
+
let resolveWait = null;
|
|
73
|
+
const waitForEvent = () => new Promise(r => { resolveWait = r; });
|
|
74
|
+
const push = (ev) => {
|
|
75
|
+
buffer.push(ev);
|
|
76
|
+
if (resolveWait) {
|
|
77
|
+
resolveWait();
|
|
78
|
+
resolveWait = null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
let cumulativeUsd = 0;
|
|
82
|
+
let cumulativeInput = 0;
|
|
83
|
+
let cumulativeOutput = 0;
|
|
84
|
+
let cumulativeCacheCreate = 0;
|
|
85
|
+
let cumulativeCacheRead = 0;
|
|
86
|
+
let capBreached = null;
|
|
87
|
+
const killChain = () => {
|
|
88
|
+
child.kill('SIGTERM');
|
|
89
|
+
setTimeout(() => { if (!child.killed)
|
|
90
|
+
child.kill('SIGKILL'); }, 5000);
|
|
91
|
+
};
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
capBreached = capBreached ?? 'wall-clock timeout';
|
|
94
|
+
killChain();
|
|
95
|
+
}, task.wallClockMs);
|
|
96
|
+
const onAbort = () => { capBreached = capBreached ?? 'aborted by caller'; killChain(); };
|
|
97
|
+
signal.addEventListener('abort', onAbort);
|
|
98
|
+
let stdoutTail = '';
|
|
99
|
+
child.stdout?.setEncoding('utf-8');
|
|
100
|
+
child.stderr?.setEncoding('utf-8');
|
|
101
|
+
child.stdout?.on('data', (chunk) => {
|
|
102
|
+
stdoutTail += chunk;
|
|
103
|
+
let newlineIdx;
|
|
104
|
+
while ((newlineIdx = stdoutTail.indexOf('\n')) >= 0) {
|
|
105
|
+
const line = stdoutTail.slice(0, newlineIdx);
|
|
106
|
+
stdoutTail = stdoutTail.slice(newlineIdx + 1);
|
|
107
|
+
if (!line.trim())
|
|
108
|
+
continue;
|
|
109
|
+
let evt;
|
|
110
|
+
try {
|
|
111
|
+
evt = JSON.parse(line);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
push({ kind: 'stdout', chunk: `${line}\n` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
118
|
+
for (const part of evt.message.content) {
|
|
119
|
+
if (part.type === 'tool_use' && part.name) {
|
|
120
|
+
push({ kind: 'tool-call', name: part.name, argsPreview: summariseArgs(part.input) });
|
|
121
|
+
}
|
|
122
|
+
else if (part.type === 'text') {
|
|
123
|
+
const text = part.text;
|
|
124
|
+
if (typeof text === 'string')
|
|
125
|
+
push({ kind: 'stdout', chunk: text });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (evt.type === 'result') {
|
|
130
|
+
const usage = evt.usage ?? {};
|
|
131
|
+
cumulativeInput = Math.max(cumulativeInput, usage.input_tokens ?? 0);
|
|
132
|
+
cumulativeOutput = Math.max(cumulativeOutput, usage.output_tokens ?? 0);
|
|
133
|
+
cumulativeCacheCreate = Math.max(cumulativeCacheCreate, usage.cache_creation_input_tokens ?? 0);
|
|
134
|
+
cumulativeCacheRead = Math.max(cumulativeCacheRead, usage.cache_read_input_tokens ?? 0);
|
|
135
|
+
cumulativeUsd = Math.max(cumulativeUsd, evt.total_cost_usd ?? 0);
|
|
136
|
+
push({
|
|
137
|
+
kind: 'cost',
|
|
138
|
+
inputTokens: cumulativeInput,
|
|
139
|
+
outputTokens: cumulativeOutput,
|
|
140
|
+
cacheCreateTokens: cumulativeCacheCreate,
|
|
141
|
+
cacheReadTokens: cumulativeCacheRead,
|
|
142
|
+
usd: cumulativeUsd,
|
|
143
|
+
});
|
|
144
|
+
const totalTokens = cumulativeInput + cumulativeOutput + cumulativeCacheCreate;
|
|
145
|
+
if (totalTokens > task.tokenCap) {
|
|
146
|
+
capBreached = capBreached ?? `token cap exceeded: ${totalTokens} > ${task.tokenCap}`;
|
|
147
|
+
killChain();
|
|
148
|
+
}
|
|
149
|
+
if (cumulativeUsd > task.maxUsd) {
|
|
150
|
+
capBreached = capBreached ?? `cost cap exceeded: $${cumulativeUsd.toFixed(4)} > $${task.maxUsd}`;
|
|
151
|
+
killChain();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
child.stderr?.on('data', (chunk) => push({ kind: 'stderr', chunk }));
|
|
157
|
+
let exitCode = -1;
|
|
158
|
+
let exitSignal = null;
|
|
159
|
+
let done = false;
|
|
160
|
+
const exited = new Promise(resolveExit => {
|
|
161
|
+
child.on('close', (code, sig) => {
|
|
162
|
+
if (stdoutTail.trim()) {
|
|
163
|
+
try {
|
|
164
|
+
const evt = JSON.parse(stdoutTail);
|
|
165
|
+
if (evt.type === 'result') {
|
|
166
|
+
const usage = evt.usage ?? {};
|
|
167
|
+
cumulativeInput = Math.max(cumulativeInput, usage.input_tokens ?? 0);
|
|
168
|
+
cumulativeOutput = Math.max(cumulativeOutput, usage.output_tokens ?? 0);
|
|
169
|
+
cumulativeUsd = Math.max(cumulativeUsd, evt.total_cost_usd ?? 0);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch { /* not JSON */ }
|
|
173
|
+
stdoutTail = '';
|
|
174
|
+
}
|
|
175
|
+
exitCode = code ?? -1;
|
|
176
|
+
exitSignal = sig;
|
|
177
|
+
done = true;
|
|
178
|
+
if (resolveWait) {
|
|
179
|
+
resolveWait();
|
|
180
|
+
resolveWait = null;
|
|
181
|
+
}
|
|
182
|
+
resolveExit();
|
|
183
|
+
});
|
|
184
|
+
child.on('error', err => {
|
|
185
|
+
push({ kind: 'stderr', chunk: `spawn error: ${err.message}\n` });
|
|
186
|
+
done = true;
|
|
187
|
+
if (resolveWait) {
|
|
188
|
+
resolveWait();
|
|
189
|
+
resolveWait = null;
|
|
190
|
+
}
|
|
191
|
+
resolveExit();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
while (!done || buffer.length > 0) {
|
|
196
|
+
if (buffer.length === 0 && !done)
|
|
197
|
+
await waitForEvent();
|
|
198
|
+
while (buffer.length > 0) {
|
|
199
|
+
const ev = buffer.shift();
|
|
200
|
+
if (ev)
|
|
201
|
+
yield ev;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await exited;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
signal.removeEventListener('abort', onAbort);
|
|
209
|
+
if (release) {
|
|
210
|
+
try {
|
|
211
|
+
await release();
|
|
212
|
+
}
|
|
213
|
+
catch { /* already released */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const durationMs = Date.now() - startTime;
|
|
217
|
+
const status = capBreached === 'aborted by caller' ? 'aborted'
|
|
218
|
+
: capBreached ? 'timeout'
|
|
219
|
+
: exitSignal ? 'timeout'
|
|
220
|
+
: exitCode === 0 ? 'ok' : 'failed';
|
|
221
|
+
yield {
|
|
222
|
+
kind: 'end',
|
|
223
|
+
status,
|
|
224
|
+
exitCode,
|
|
225
|
+
durationMs,
|
|
226
|
+
at: new Date().toISOString(),
|
|
227
|
+
...(capBreached ? { error: capBreached } : {}),
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
function extractText(content) {
|
|
4
|
+
if (!Array.isArray(content))
|
|
5
|
+
return '';
|
|
6
|
+
const chunks = [];
|
|
7
|
+
for (const part of content) {
|
|
8
|
+
if (part.type === 'text' && typeof part.text === 'string')
|
|
9
|
+
chunks.push(part.text);
|
|
10
|
+
}
|
|
11
|
+
return chunks.join('');
|
|
12
|
+
}
|
|
13
|
+
export function createMcpCallRunner(opts = {}) {
|
|
14
|
+
const command = opts.command ?? 'fleet';
|
|
15
|
+
const args = opts.args ?? ['mcp'];
|
|
16
|
+
return {
|
|
17
|
+
id: 'mcp-call',
|
|
18
|
+
supports(task) {
|
|
19
|
+
return task.kind === 'mcp-call';
|
|
20
|
+
},
|
|
21
|
+
async *run(task, ctx, signal) {
|
|
22
|
+
if (task.kind !== 'mcp-call')
|
|
23
|
+
throw new Error('mcp-call runner received wrong task kind');
|
|
24
|
+
const startedAt = new Date().toISOString();
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
yield { kind: 'start', routineId: ctx.routineId, target: ctx.repo ?? null, at: startedAt };
|
|
27
|
+
const transport = new StdioClientTransport({ command, args });
|
|
28
|
+
const client = new Client({ name: opts.clientName ?? 'fleet-routine', version: opts.clientVersion ?? '1.0.0' }, { capabilities: {} });
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
void transport.close().catch(() => { });
|
|
31
|
+
}, task.wallClockMs);
|
|
32
|
+
const onAbort = () => {
|
|
33
|
+
void transport.close().catch(() => { });
|
|
34
|
+
};
|
|
35
|
+
signal.addEventListener('abort', onAbort);
|
|
36
|
+
let errorText;
|
|
37
|
+
let status = 'failed';
|
|
38
|
+
let exitCode = 1;
|
|
39
|
+
try {
|
|
40
|
+
await client.connect(transport);
|
|
41
|
+
const result = await client.callTool({ name: task.tool, arguments: task.args });
|
|
42
|
+
const payload = result;
|
|
43
|
+
const text = extractText(payload.content);
|
|
44
|
+
if (text)
|
|
45
|
+
yield { kind: 'stdout', chunk: `${text}\n` };
|
|
46
|
+
yield { kind: 'tool-call', name: task.tool, argsPreview: JSON.stringify(task.args).slice(0, 200) };
|
|
47
|
+
if (payload.isError) {
|
|
48
|
+
status = 'failed';
|
|
49
|
+
errorText = text || 'mcp tool returned isError';
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
status = 'ok';
|
|
53
|
+
exitCode = 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
errorText = err.message;
|
|
58
|
+
status = signal.aborted ? 'aborted' : 'failed';
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
signal.removeEventListener('abort', onAbort);
|
|
63
|
+
try {
|
|
64
|
+
await client.close();
|
|
65
|
+
}
|
|
66
|
+
catch { /* already closed */ }
|
|
67
|
+
try {
|
|
68
|
+
await transport.close();
|
|
69
|
+
}
|
|
70
|
+
catch { /* already closed */ }
|
|
71
|
+
}
|
|
72
|
+
yield {
|
|
73
|
+
kind: 'end',
|
|
74
|
+
status,
|
|
75
|
+
exitCode,
|
|
76
|
+
durationMs: Date.now() - startTime,
|
|
77
|
+
at: new Date().toISOString(),
|
|
78
|
+
...(errorText ? { error: errorText } : {}),
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function createShellRunner() {
|
|
3
|
+
return {
|
|
4
|
+
id: 'shell',
|
|
5
|
+
supports(task) {
|
|
6
|
+
return task.kind === 'shell';
|
|
7
|
+
},
|
|
8
|
+
async *run(task, ctx, signal) {
|
|
9
|
+
if (task.kind !== 'shell')
|
|
10
|
+
throw new Error('shell runner received non-shell task');
|
|
11
|
+
const startedAt = new Date().toISOString();
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
yield {
|
|
14
|
+
kind: 'start',
|
|
15
|
+
routineId: ctx.routineId,
|
|
16
|
+
target: ctx.repo ?? null,
|
|
17
|
+
at: startedAt,
|
|
18
|
+
};
|
|
19
|
+
const [cmd, ...args] = task.argv;
|
|
20
|
+
const env = { ...process.env, ...ctx.env, ...(task.env ?? {}) };
|
|
21
|
+
const child = spawn(cmd, args, {
|
|
22
|
+
cwd: ctx.repoPath ?? undefined,
|
|
23
|
+
env,
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
});
|
|
26
|
+
const buffer = [];
|
|
27
|
+
let resolve = null;
|
|
28
|
+
const waitForEvent = () => new Promise(r => { resolve = r; });
|
|
29
|
+
const push = (ev) => {
|
|
30
|
+
buffer.push(ev);
|
|
31
|
+
if (resolve) {
|
|
32
|
+
resolve();
|
|
33
|
+
resolve = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
child.kill('SIGTERM');
|
|
38
|
+
setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
39
|
+
}, task.wallClockMs);
|
|
40
|
+
const onAbort = () => {
|
|
41
|
+
child.kill('SIGTERM');
|
|
42
|
+
setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
43
|
+
};
|
|
44
|
+
signal.addEventListener('abort', onAbort);
|
|
45
|
+
child.stdout?.setEncoding('utf-8');
|
|
46
|
+
child.stderr?.setEncoding('utf-8');
|
|
47
|
+
child.stdout?.on('data', chunk => push({ kind: 'stdout', chunk }));
|
|
48
|
+
child.stderr?.on('data', chunk => push({ kind: 'stderr', chunk }));
|
|
49
|
+
let exitCode = -1;
|
|
50
|
+
let exitSignal = null;
|
|
51
|
+
let done = false;
|
|
52
|
+
const exited = new Promise(resolveExit => {
|
|
53
|
+
child.on('close', (code, sig) => {
|
|
54
|
+
exitCode = code ?? -1;
|
|
55
|
+
exitSignal = sig;
|
|
56
|
+
done = true;
|
|
57
|
+
if (resolve) {
|
|
58
|
+
resolve();
|
|
59
|
+
resolve = null;
|
|
60
|
+
}
|
|
61
|
+
resolveExit();
|
|
62
|
+
});
|
|
63
|
+
child.on('error', err => {
|
|
64
|
+
push({ kind: 'stderr', chunk: `spawn error: ${err.message}\n` });
|
|
65
|
+
done = true;
|
|
66
|
+
if (resolve) {
|
|
67
|
+
resolve();
|
|
68
|
+
resolve = null;
|
|
69
|
+
}
|
|
70
|
+
resolveExit();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
while (!done || buffer.length > 0) {
|
|
75
|
+
if (buffer.length === 0 && !done)
|
|
76
|
+
await waitForEvent();
|
|
77
|
+
while (buffer.length > 0) {
|
|
78
|
+
const ev = buffer.shift();
|
|
79
|
+
if (ev)
|
|
80
|
+
yield ev;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await exited;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
signal.removeEventListener('abort', onAbort);
|
|
88
|
+
}
|
|
89
|
+
const durationMs = Date.now() - startTime;
|
|
90
|
+
const status = signal.aborted ? 'aborted'
|
|
91
|
+
: exitSignal === 'SIGTERM' || exitSignal === 'SIGKILL' ? 'timeout'
|
|
92
|
+
: exitCode === 0 ? 'ok' : 'failed';
|
|
93
|
+
yield {
|
|
94
|
+
kind: 'end',
|
|
95
|
+
status,
|
|
96
|
+
exitCode,
|
|
97
|
+
durationMs,
|
|
98
|
+
at: new Date().toISOString(),
|
|
99
|
+
...(exitSignal ? { error: `signal=${exitSignal}` } : {}),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Routine } from '../../core/routines/schema.js';
|
|
2
|
+
import type { SchedulerAdapter } from '../types.js';
|
|
3
|
+
export interface SystemdTimerOptions {
|
|
4
|
+
fleetBinary?: string;
|
|
5
|
+
runAsUser?: string;
|
|
6
|
+
unitDir?: string;
|
|
7
|
+
}
|
|
8
|
+
interface RenderedUnit {
|
|
9
|
+
timerPath: string;
|
|
10
|
+
servicePath: string;
|
|
11
|
+
timerUnit: string;
|
|
12
|
+
serviceUnit: string;
|
|
13
|
+
unitName: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function renderUnits(routine: Routine, opts?: SystemdTimerOptions): RenderedUnit | null;
|
|
16
|
+
export declare function createSystemdTimerAdapter(opts?: SystemdTimerOptions): SchedulerAdapter;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execSafe } from '../../core/exec.js';
|
|
3
|
+
const UNIT_DIR = '/etc/systemd/system';
|
|
4
|
+
const UNIT_PREFIX = 'fleet-routine-';
|
|
5
|
+
export function renderUnits(routine, opts = {}) {
|
|
6
|
+
if (routine.schedule.kind !== 'calendar')
|
|
7
|
+
return null;
|
|
8
|
+
const fleetBinary = opts.fleetBinary ?? '/usr/local/bin/fleet';
|
|
9
|
+
const unitDir = opts.unitDir ?? UNIT_DIR;
|
|
10
|
+
const unitName = `${UNIT_PREFIX}${routine.id}`;
|
|
11
|
+
const timerPath = `${unitDir}/${unitName}.timer`;
|
|
12
|
+
const servicePath = `${unitDir}/${unitName}.service`;
|
|
13
|
+
const onCalendar = routine.schedule.onCalendar;
|
|
14
|
+
const randomizedDelay = routine.schedule.randomizedDelaySec;
|
|
15
|
+
const persistent = routine.schedule.persistent;
|
|
16
|
+
const userDirective = opts.runAsUser ? `User=${opts.runAsUser}\n` : '';
|
|
17
|
+
const timerUnit = [
|
|
18
|
+
`[Unit]`,
|
|
19
|
+
`Description=Fleet routine ${routine.id} (${routine.name})`,
|
|
20
|
+
``,
|
|
21
|
+
`[Timer]`,
|
|
22
|
+
`OnCalendar=${onCalendar}`,
|
|
23
|
+
`RandomizedDelaySec=${randomizedDelay}`,
|
|
24
|
+
`Persistent=${persistent ? 'true' : 'false'}`,
|
|
25
|
+
`Unit=${unitName}.service`,
|
|
26
|
+
``,
|
|
27
|
+
`[Install]`,
|
|
28
|
+
`WantedBy=timers.target`,
|
|
29
|
+
``,
|
|
30
|
+
].join('\n');
|
|
31
|
+
const serviceUnit = [
|
|
32
|
+
`[Unit]`,
|
|
33
|
+
`Description=Fleet routine ${routine.id} run`,
|
|
34
|
+
`After=network-online.target`,
|
|
35
|
+
`Wants=network-online.target`,
|
|
36
|
+
``,
|
|
37
|
+
`[Service]`,
|
|
38
|
+
`Type=oneshot`,
|
|
39
|
+
userDirective,
|
|
40
|
+
`ExecStart=${fleetBinary} routine-run --id ${routine.id}`,
|
|
41
|
+
`NoNewPrivileges=true`,
|
|
42
|
+
`PrivateTmp=true`,
|
|
43
|
+
`ProtectSystem=strict`,
|
|
44
|
+
`ProtectHome=read-only`,
|
|
45
|
+
`ReadWritePaths=/var/log/fleet /var/lib/fleet`,
|
|
46
|
+
`LockPersonality=true`,
|
|
47
|
+
`RestrictSUIDSGID=true`,
|
|
48
|
+
`TimeoutStartSec=3600`,
|
|
49
|
+
`StandardOutput=journal`,
|
|
50
|
+
`StandardError=journal`,
|
|
51
|
+
``,
|
|
52
|
+
].filter(Boolean).join('\n');
|
|
53
|
+
return { timerPath, servicePath, timerUnit, serviceUnit, unitName };
|
|
54
|
+
}
|
|
55
|
+
function parseListTimers(stdout) {
|
|
56
|
+
const map = new Map();
|
|
57
|
+
const lines = stdout.split('\n');
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const match = line.match(/^(\S.*?)\s+(\S.*?\s+\S+)\s+(.*?)\s+(\S.*?)\s+(.*?)\s+(\S+\.timer)\s+(\S+\.service)/);
|
|
60
|
+
if (!match)
|
|
61
|
+
continue;
|
|
62
|
+
const [, nextRaw, , lastRaw, , , timerName] = match;
|
|
63
|
+
const baseName = timerName.replace(/\.timer$/, '');
|
|
64
|
+
map.set(baseName, {
|
|
65
|
+
next: nextRaw === 'n/a' || nextRaw === '-' ? null : nextRaw.trim(),
|
|
66
|
+
last: lastRaw === 'n/a' || lastRaw === '-' ? null : lastRaw.trim(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
function parseActiveStatus(unitName) {
|
|
72
|
+
const show = execSafe('systemctl', [
|
|
73
|
+
'show', `${unitName}.service`,
|
|
74
|
+
'--property=ActiveState,Result', '--no-pager',
|
|
75
|
+
]);
|
|
76
|
+
const active = /ActiveState=active|activating/.test(show.stdout);
|
|
77
|
+
const resultMatch = show.stdout.match(/Result=(\S+)/);
|
|
78
|
+
const result = resultMatch?.[1] ?? 'unknown';
|
|
79
|
+
const lastStatus = result === 'success' ? 'ok' : result === 'exit-code' || result === 'signal' ? 'failed' : 'unknown';
|
|
80
|
+
return { active, lastStatus };
|
|
81
|
+
}
|
|
82
|
+
export function createSystemdTimerAdapter(opts = {}) {
|
|
83
|
+
const unitDir = opts.unitDir ?? UNIT_DIR;
|
|
84
|
+
return {
|
|
85
|
+
id: 'systemd-timer',
|
|
86
|
+
available() {
|
|
87
|
+
const r = execSafe('systemctl', ['--version']);
|
|
88
|
+
return r.ok;
|
|
89
|
+
},
|
|
90
|
+
async upsert(routine) {
|
|
91
|
+
if (routine.schedule.kind === 'manual')
|
|
92
|
+
return;
|
|
93
|
+
const rendered = renderUnits(routine, opts);
|
|
94
|
+
if (!rendered)
|
|
95
|
+
return;
|
|
96
|
+
writeFileSync(rendered.servicePath, rendered.serviceUnit, { mode: 0o644 });
|
|
97
|
+
writeFileSync(rendered.timerPath, rendered.timerUnit, { mode: 0o644 });
|
|
98
|
+
const reload = execSafe('systemctl', ['daemon-reload']);
|
|
99
|
+
if (!reload.ok)
|
|
100
|
+
throw new Error(`daemon-reload failed: ${reload.stderr}`);
|
|
101
|
+
if (routine.enabled) {
|
|
102
|
+
const enable = execSafe('systemctl', ['enable', '--now', `${rendered.unitName}.timer`]);
|
|
103
|
+
if (!enable.ok)
|
|
104
|
+
throw new Error(`enable failed: ${enable.stderr}`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
execSafe('systemctl', ['disable', '--now', `${rendered.unitName}.timer`]);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async remove(routineId) {
|
|
111
|
+
const unitName = `${UNIT_PREFIX}${routineId}`;
|
|
112
|
+
const timerPath = `${unitDir}/${unitName}.timer`;
|
|
113
|
+
const servicePath = `${unitDir}/${unitName}.service`;
|
|
114
|
+
execSafe('systemctl', ['disable', '--now', `${unitName}.timer`]);
|
|
115
|
+
if (existsSync(timerPath))
|
|
116
|
+
unlinkSync(timerPath);
|
|
117
|
+
if (existsSync(servicePath))
|
|
118
|
+
unlinkSync(servicePath);
|
|
119
|
+
execSafe('systemctl', ['daemon-reload']);
|
|
120
|
+
},
|
|
121
|
+
async list() {
|
|
122
|
+
const timers = execSafe('systemctl', [
|
|
123
|
+
'list-timers', '--all', '--no-pager', '--no-legend', `${UNIT_PREFIX}*`,
|
|
124
|
+
]);
|
|
125
|
+
if (!timers.ok)
|
|
126
|
+
return [];
|
|
127
|
+
const parsed = parseListTimers(timers.stdout);
|
|
128
|
+
const entries = [];
|
|
129
|
+
for (const [unitName, timing] of parsed) {
|
|
130
|
+
const routineId = unitName.replace(new RegExp(`^${UNIT_PREFIX}`), '');
|
|
131
|
+
const { active, lastStatus } = parseActiveStatus(unitName);
|
|
132
|
+
entries.push({
|
|
133
|
+
routineId,
|
|
134
|
+
unitName,
|
|
135
|
+
nextRunAt: timing.next,
|
|
136
|
+
lastRunAt: timing.last,
|
|
137
|
+
lastStatus,
|
|
138
|
+
active,
|
|
139
|
+
persistent: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return entries;
|
|
143
|
+
},
|
|
144
|
+
async get(routineId) {
|
|
145
|
+
const all = await this.list();
|
|
146
|
+
return all.find(e => e.routineId === routineId) ?? null;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|