flowviant 0.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.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Fleet daemon. Install ONCE with a fleet credential; manage everything from
3
+ * Flowviant. The daemon polls GET /api/v2/fleet/agents, reconciles one persistent
4
+ * git worktree + worker loop per roster agent, rotates each worker's short-lived
5
+ * MCP token, and only spawns Claude when the server says an agent has work.
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import {
13
+ VERSION,
14
+ FLEET_URL,
15
+ FLEET_TOKEN,
16
+ USER_AGENT,
17
+ MCP_URL,
18
+ SAFE,
19
+ POLL_SECONDS,
20
+ IDLE_SECONDS,
21
+ RECONCILE_SECONDS,
22
+ REFRESH_BEFORE_SECONDS,
23
+ LIVE,
24
+ } from './config.mjs';
25
+ import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
26
+ import {
27
+ sleep,
28
+ mcpConfigFor,
29
+ runTurn,
30
+ sawSentinel,
31
+ blockedId,
32
+ SYSTEM_SINGLE,
33
+ SINGLE_KICKOFF,
34
+ SINGLE_RESUME,
35
+ } from './claude.mjs';
36
+ import { git, repoRootOrDie, detectBaseRef, resetWorktree } from './git.mjs';
37
+ import { runLiveWorker } from './live.mjs';
38
+ import { preflight } from './preflight.mjs';
39
+
40
+ async function fetchRoster(haveIds) {
41
+ const url = new URL(FLEET_URL);
42
+ if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
43
+ // An explicit User-Agent is required: Node's default ("node"/empty) trips
44
+ // Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
45
+ const res = await fetch(url, {
46
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
47
+ });
48
+ if (res.status === 401 || res.status === 403) {
49
+ // Fleet credential revoked/expired — retrying can't recover; signal exit.
50
+ const e = new Error(`fleet credential rejected (${res.status})`);
51
+ e.auth = true;
52
+ throw e;
53
+ }
54
+ if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
55
+ const body = await res.json();
56
+ return body.data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
57
+ }
58
+
59
+ // One roster agent's loop: persistent worktree, one intent per turn, reset to
60
+ // base between tasks (fresh conversation), resume in place while on a blocker.
61
+ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
62
+ let resuming = false;
63
+ let needsReset = true; // reset to base before a FRESH task, not on idle polls
64
+ let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
65
+ const enter = (p, fn, msg) => {
66
+ if (phase !== p) {
67
+ phase = p;
68
+ fn(`${label} ${msg}`);
69
+ }
70
+ };
71
+ while (isAlive()) {
72
+ const token = getToken(agentId);
73
+ if (!token) {
74
+ await sleep(IDLE_SECONDS);
75
+ continue;
76
+ }
77
+ // Idle = no claimable work (the server tells us via the roster poll). Don't
78
+ // spawn Claude just to find nothing — that's a wasted API call. A blocked
79
+ // task (resuming) still polls, so its resolution gets picked up.
80
+ if (!resuming && !getHasWork(agentId)) {
81
+ enter('idle', info, 'idle — no work assigned');
82
+ await sleep(IDLE_SECONDS);
83
+ continue;
84
+ }
85
+ if (!resuming && needsReset) {
86
+ resetWorktree(cwd, baseRef); // clean slate for a new task
87
+ needsReset = false;
88
+ }
89
+ const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
90
+ let out = '';
91
+ try {
92
+ out = await runTurn({
93
+ prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF,
94
+ resume: resuming,
95
+ system: SYSTEM_SINGLE,
96
+ cwd,
97
+ mcpConfig,
98
+ label,
99
+ onSpawn: (ch) => onChild?.(ch),
100
+ });
101
+ } finally {
102
+ rmSync(dir, { recursive: true, force: true });
103
+ onChild?.(null);
104
+ }
105
+ if (!isAlive()) break;
106
+ if (blockedId(out)) {
107
+ enter('blocked', warn, `${c.yellow('paused')}${c.dim(' — waiting on your review/answer in Flowviant')}`);
108
+ resuming = true;
109
+ await sleep(POLL_SECONDS);
110
+ continue;
111
+ }
112
+ if (sawSentinel(out, 'NOTHING')) {
113
+ enter('idle', info, 'idle — no work assigned');
114
+ resuming = false;
115
+ await sleep(IDLE_SECONDS);
116
+ continue;
117
+ }
118
+ if (sawSentinel(out, 'DONE')) {
119
+ ok(`${label} ${c.dim('finished a task — PR opened for your review')}`);
120
+ phase = '';
121
+ resuming = false;
122
+ needsReset = true;
123
+ continue;
124
+ }
125
+ // No sentinel — the turn didn't complete the protocol. Almost always the
126
+ // flowviant MCP failed to surface its tools (usually a stale worker token).
127
+ // Drop the cached token so the next poll re-mints a fresh one, then retry —
128
+ // don't fake a blocker or a completion.
129
+ enter('reconnect', warn, `${c.yellow('no result')}${c.dim(' — refreshing token, retrying')}`);
130
+ onTokenSuspect?.(agentId);
131
+ resuming = false;
132
+ needsReset = true;
133
+ await sleep(IDLE_SECONDS);
134
+ }
135
+ info(`${label} stopped`);
136
+ }
137
+
138
+ export async function runFleetDaemon() {
139
+ console.log('');
140
+ console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`fleet daemon · v${VERSION}`)}`);
141
+ console.log(` ${c.dim('──────────────────────────────────────────────')}`);
142
+ const repoRoot = repoRootOrDie();
143
+ const baseRef = detectBaseRef(repoRoot);
144
+ info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
145
+ info(`repo · ${repoRoot}`);
146
+ info(`base · ${baseRef}`);
147
+ info(`server · ${FLEET_URL}`);
148
+ console.log('');
149
+ preflight({ needGit: true });
150
+
151
+ const baseDir = mkdtempSync(join(tmpdir(), 'flowviant-fleet-'));
152
+ const tokenByAgent = new Map(); // agentId -> latest worker token
153
+ const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
154
+ const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
155
+ let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
156
+ let mcpUrl = MCP_URL;
157
+ const workers = new Map(); // agentId -> { state, promise, wt, label }
158
+
159
+ const teardown = () => {
160
+ for (const [, w] of workers) {
161
+ w.state.alive = false;
162
+ try {
163
+ w.state.child?.kill('SIGKILL');
164
+ } catch {
165
+ /* best-effort */
166
+ }
167
+ try {
168
+ git(['worktree', 'remove', '--force', w.wt], repoRoot);
169
+ } catch {
170
+ /* best-effort */
171
+ }
172
+ }
173
+ try {
174
+ rmSync(baseDir, { recursive: true, force: true });
175
+ } catch {
176
+ /* best-effort */
177
+ }
178
+ };
179
+ process.on('SIGINT', () => {
180
+ console.log('');
181
+ note('shutting down — stopping workers and freeing worktrees…');
182
+ teardown();
183
+ process.exit(130);
184
+ });
185
+
186
+ // Merge jobs (Flowvy-commanded): approved PRs to squash-merge to main on the
187
+ // user's own gh. `merging` guards against re-processing a job mid-flight.
188
+ const MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-done');
189
+ const merging = new Set();
190
+ const reportMerged = async (intentId) => {
191
+ try {
192
+ await fetch(MERGE_DONE_URL, {
193
+ method: 'POST',
194
+ headers: {
195
+ Authorization: `Bearer ${FLEET_TOKEN}`,
196
+ 'User-Agent': USER_AGENT,
197
+ 'Content-Type': 'application/json',
198
+ },
199
+ body: JSON.stringify({ intentId }),
200
+ });
201
+ } catch {
202
+ /* best-effort — the job reappears next poll if this failed */
203
+ }
204
+ };
205
+ const processMergeJobs = (jobs) => {
206
+ for (const job of jobs ?? []) {
207
+ if (merging.has(job.id)) continue;
208
+ merging.add(job.id);
209
+ (async () => {
210
+ try {
211
+ note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
212
+ let merged = false;
213
+ try {
214
+ execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
215
+ cwd: repoRoot,
216
+ stdio: ['ignore', 'pipe', 'pipe'],
217
+ });
218
+ merged = true;
219
+ } catch (e) {
220
+ const err = e.stderr?.toString?.() || e.message || '';
221
+ if (/already merged|not open|closed/i.test(err)) merged = true;
222
+ else if (/conflict|not mergeable|CONFLICTING/i.test(err))
223
+ warn(`"${job.title}" has a merge conflict — rebase the branch, then it'll merge.`);
224
+ else warn(`merge failed for "${job.title}": ${err.split('\n')[0]} — will retry`);
225
+ }
226
+ if (merged) {
227
+ await reportMerged(job.id);
228
+ ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
229
+ }
230
+ } finally {
231
+ merging.delete(job.id);
232
+ }
233
+ })();
234
+ }
235
+ };
236
+
237
+ let connected = false; // log the first successful poll once
238
+ let rosterSig = null; // last roster membership, to log changes only
239
+ let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
240
+ let joinCount = 0; // for stable per-agent label colours
241
+
242
+ // Which agents to tell the server we already hold a good token for. We keep
243
+ // our token (omit a re-mint) UNLESS it's near expiry AND the worker is idle
244
+ // (no child mid-turn) — then we drop it from `have` to force a fresh token,
245
+ // safely between turns so we never swap a credential out from under a run.
246
+ const buildHave = () =>
247
+ [...tokenByAgent.keys()].filter((id) => {
248
+ const ageS = (Date.now() - (mintedAt.get(id) ?? 0)) / 1000;
249
+ const nearExpiry = ageS > leaseTtlSeconds - REFRESH_BEFORE_SECONDS;
250
+ const midTurn = workers.get(id)?.state.child != null;
251
+ return !nearExpiry || midTurn;
252
+ });
253
+
254
+ // Reconcile loop: poll roster, start new workers, stop removed ones.
255
+ for (;;) {
256
+ let roster;
257
+ try {
258
+ roster = await fetchRoster(buildHave());
259
+ } catch (e) {
260
+ if (e.auth) {
261
+ fail(`${e.message} — credential revoked or invalid. Shutting down.`);
262
+ teardown();
263
+ process.exit(1);
264
+ }
265
+ warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
266
+ await sleep(RECONCILE_SECONDS);
267
+ continue;
268
+ }
269
+ if (!connected) {
270
+ connected = true;
271
+ ok('Connected to Flowviant — watching your roster.');
272
+ }
273
+ if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
274
+ if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
275
+ processMergeJobs(roster.mergeJobs);
276
+ const rosterIds = new Set(roster.agents.map((a) => a.agentId));
277
+
278
+ // Announce roster size only when it changes (not every poll).
279
+ const sig = [...rosterIds].sort().join(',');
280
+ if (sig !== rosterSig) {
281
+ rosterSig = sig;
282
+ if (rosterIds.size === 0) {
283
+ warn('No agents on your roster yet.');
284
+ info('Add agents in Flowviant → Cockpit → Fleet; they spin up here automatically.');
285
+ } else {
286
+ note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
287
+ }
288
+ }
289
+ // Heartbeat so a quiet/empty daemon visibly stays alive.
290
+ if (rosterIds.size === 0 && Date.now() - idleBeatAt > 60_000) {
291
+ idleBeatAt = Date.now();
292
+ info('idle — waiting for agents…');
293
+ }
294
+
295
+ for (const a of roster.agents) {
296
+ if (a.token) {
297
+ tokenByAgent.set(a.agentId, a.token);
298
+ mintedAt.set(a.agentId, Date.now());
299
+ }
300
+ hasWorkByAgent.set(a.agentId, !!a.hasWork);
301
+ if (!workers.has(a.agentId)) {
302
+ const wt = join(baseDir, `agent-${a.agentId}`);
303
+ try {
304
+ if (!existsSync(wt)) git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
305
+ } catch (e) {
306
+ fail(`could not create worktree for "${a.name}": ${e.message}`);
307
+ continue;
308
+ }
309
+ const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
310
+ const label = colorFn(`[${a.name}]`);
311
+ const state = { alive: true, child: null };
312
+ ok(`${label} ${c.dim(`online — worktree ready${LIVE ? ' · live session' : ''}`)}`);
313
+ const workerFn = LIVE ? runLiveWorker : runFleetWorker;
314
+ const promise = workerFn({
315
+ agentId: a.agentId,
316
+ label,
317
+ cwd: wt,
318
+ baseRef,
319
+ getToken: (id) => tokenByAgent.get(id),
320
+ getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
321
+ getMcpUrl: () => mcpUrl,
322
+ isAlive: () => state.alive,
323
+ onChild: (ch) => {
324
+ state.child = ch;
325
+ },
326
+ // A turn that couldn't reach the MCP server: forget the cached token so
327
+ // the next reconcile poll re-mints a fresh one (self-heals a token that
328
+ // was rotated/expired out from under a running session).
329
+ onTokenSuspect: (id) => {
330
+ tokenByAgent.delete(id);
331
+ mintedAt.delete(id);
332
+ },
333
+ });
334
+ workers.set(a.agentId, { state, promise, wt, label });
335
+ }
336
+ }
337
+
338
+ // Stop workers whose agent left the roster (removed in the app).
339
+ for (const [id, w] of [...workers]) {
340
+ if (!rosterIds.has(id)) {
341
+ warn(`${w.label} removed — stopping it now, freeing its worktree.`);
342
+ w.state.alive = false;
343
+ // Immediate teardown (Q6=B): kill the in-flight Claude process now; its
344
+ // task was already requeued server-side on removal.
345
+ try {
346
+ w.state.child?.kill('SIGKILL');
347
+ } catch {
348
+ /* best-effort */
349
+ }
350
+ try {
351
+ git(['worktree', 'remove', '--force', w.wt], repoRoot);
352
+ } catch {
353
+ /* best-effort */
354
+ }
355
+ workers.delete(id);
356
+ tokenByAgent.delete(id);
357
+ hasWorkByAgent.delete(id);
358
+ }
359
+ }
360
+
361
+ await sleep(RECONCILE_SECONDS);
362
+ }
363
+ }
@@ -0,0 +1,44 @@
1
+ /** Git worktree helpers (fleet & static-fleet modes). */
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ export function git(args, cwd) {
6
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
7
+ }
8
+
9
+ export function repoRootOrDie() {
10
+ try {
11
+ return git(['rev-parse', '--show-toplevel'], process.cwd());
12
+ } catch {
13
+ console.error('error: fleet mode must run inside a git repo.');
14
+ process.exit(1);
15
+ }
16
+ }
17
+
18
+ export function detectBaseRef(repoRoot) {
19
+ try {
20
+ return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
21
+ } catch {
22
+ /* origin/HEAD not set */
23
+ }
24
+ try {
25
+ return `origin/${git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)}`;
26
+ } catch {
27
+ return 'HEAD';
28
+ }
29
+ }
30
+
31
+ export function resetWorktree(wt, baseRef) {
32
+ try {
33
+ git(['fetch', 'origin', '--quiet'], wt);
34
+ } catch {
35
+ /* offline / no remote — reset to whatever we have */
36
+ }
37
+ try {
38
+ git(['checkout', '--detach', baseRef], wt);
39
+ git(['reset', '--hard', baseRef], wt);
40
+ git(['clean', '-fd'], wt);
41
+ } catch (e) {
42
+ console.error(` (worktree reset to ${baseRef} failed: ${e.message})`);
43
+ }
44
+ }