flowviant 0.27.0 → 0.28.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.
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.27.0';
7
+ export const VERSION = '0.28.0';
8
8
 
9
9
  // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
10
10
  // inherits your interactive `~/.claude/settings.json` default. That matters: a
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Cloudflare DevOps — the daemon runs the user's own `wrangler`. Broker-not-
3
+ * host: no cloud credential ever reaches Flowviant. A deploy-authorized daemon
4
+ * claims deploy jobs off the roster, runs build → push prod secrets → deploy →
5
+ * verify, and reports the outcome. It also reports its parsed
6
+ * .flowviant/deploy.json so the app can list targets, and (basic) observes
7
+ * out-of-band deployments.
8
+ *
9
+ * Every log line that leaves the machine passes through the env scrubber —
10
+ * wrangler output routinely echoes secrets.
11
+ */
12
+
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { spawn } from 'node:child_process';
15
+ import { join } from 'node:path';
16
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
17
+ import { c, note, ok, warn } from './ui.mjs';
18
+ import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
19
+
20
+ const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
21
+
22
+ async function post(tail, body) {
23
+ const res = await fetch(deployUrl(tail), {
24
+ method: 'POST',
25
+ headers: {
26
+ Authorization: `Bearer ${FLEET_TOKEN}`,
27
+ 'User-Agent': USER_AGENT,
28
+ 'Content-Type': 'application/json',
29
+ },
30
+ signal: AbortSignal.timeout(30_000),
31
+ body: JSON.stringify(body),
32
+ });
33
+ const json = await res.json().catch(() => ({}));
34
+ if (!res.ok || json?.success === false) {
35
+ throw new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
36
+ }
37
+ return json?.data;
38
+ }
39
+
40
+ /** Read + parse .flowviant/deploy.json from the repo root. Returns [] if none. */
41
+ export function readDeployConfig(repoRoot) {
42
+ const path = join(repoRoot, '.flowviant', 'deploy.json');
43
+ if (!existsSync(path)) return [];
44
+ try {
45
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
46
+ const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
47
+ // Keep only fields the server + runner need; the daemon holds the commands.
48
+ return targets
49
+ .filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
50
+ .slice(0, 20);
51
+ } catch (e) {
52
+ warn(`deploy: .flowviant/deploy.json is not valid JSON — ${e.message}`);
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** Report the parsed config to the server (only when it changed). */
58
+ let lastConfigJson = null;
59
+ export async function reportDeployConfig(repoRoot) {
60
+ const targets = readDeployConfig(repoRoot);
61
+ const json = JSON.stringify(targets);
62
+ if (json === lastConfigJson) return;
63
+ // Strip commands/secrets before the server sees the config (metadata only).
64
+ const meta = targets.map((t) => ({
65
+ id: t.id,
66
+ label: t.label,
67
+ provider: t.provider || 'cloudflare',
68
+ command: t.command,
69
+ build: t.build,
70
+ commands: t.commands,
71
+ healthcheck: t.healthcheck,
72
+ healthStatus: t.healthStatus,
73
+ pushSecrets: t.pushSecrets,
74
+ }));
75
+ try {
76
+ await post('deploy-config', { pubkey: myPubB64(), targets: meta });
77
+ lastConfigJson = json;
78
+ } catch (e) {
79
+ warn(`deploy: could not report config — ${e.message}`);
80
+ }
81
+ }
82
+
83
+ /** Run a shell command ASYNC (never blocks the daemon's event loop — the
84
+ * reconcile poll + the deploy heartbeat must keep firing during a long
85
+ * deploy). Captures combined + scrubbed output; resolves {ok,out,code}. */
86
+ function run(command, { cwd, env, input }) {
87
+ return new Promise((resolve) => {
88
+ const child = spawn(command, { cwd, env, shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
89
+ let buf = '';
90
+ const cap = (d) => {
91
+ buf += d.toString();
92
+ if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
93
+ };
94
+ child.stdout.on('data', cap);
95
+ child.stderr.on('data', cap);
96
+ const timer = setTimeout(() => {
97
+ try {
98
+ child.kill('SIGKILL');
99
+ } catch {
100
+ /* already gone */
101
+ }
102
+ }, 30 * 60_000);
103
+ if (input != null) {
104
+ try {
105
+ child.stdin.write(input);
106
+ child.stdin.end();
107
+ } catch {
108
+ /* stdin closed */
109
+ }
110
+ } else {
111
+ child.stdin.end();
112
+ }
113
+ child.on('close', (code) => {
114
+ clearTimeout(timer);
115
+ resolve({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
116
+ });
117
+ child.on('error', (e) => {
118
+ clearTimeout(timer);
119
+ resolve({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
120
+ });
121
+ });
122
+ }
123
+
124
+ const tailLines = (s, n = 40) => s.split('\n').filter(Boolean).slice(-n);
125
+
126
+ /** Health-check a deployed target: GET the URL, expect `status`. Retries a few
127
+ * times for propagation lag. */
128
+ async function verifyHealth(url, status) {
129
+ for (let i = 0; i < 4; i++) {
130
+ try {
131
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
132
+ if (res.status === status) return true;
133
+ } catch {
134
+ /* not up yet */
135
+ }
136
+ await new Promise((r) => setTimeout(r, 3000));
137
+ }
138
+ return false;
139
+ }
140
+
141
+ const claiming = new Set(); // in-flight guard (single-flight per daemon process)
142
+
143
+ /**
144
+ * Process queued deploy jobs from the roster. `ctx` = { repoRoot, baseRef,
145
+ * myPubB64 }. Each job: claim → build → push prod secrets → deploy → verify →
146
+ * report. Runs concurrently but one-per-jobId.
147
+ */
148
+ export function processDeployJobs(jobs, ctx) {
149
+ if (!Array.isArray(jobs) || !jobs.length) return;
150
+ for (const job of jobs) {
151
+ if (claiming.has(job.id)) continue;
152
+ claiming.add(job.id);
153
+ void (async () => {
154
+ let beat = null;
155
+ try {
156
+ const claimed = await post('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => null);
157
+ if (!claimed?.claimed) return; // another daemon won the claim
158
+ // Keep the claim fresh while we run — a long deploy must never be
159
+ // re-queued out from under us (that would double-deploy). The async
160
+ // run() below keeps the event loop free so this fires.
161
+ beat = setInterval(() => {
162
+ void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => {});
163
+ }, 60_000);
164
+ const targets = readDeployConfig(ctx.repoRoot);
165
+ const target = targets.find((t) => t.id === job.targetId);
166
+ if (!target) {
167
+ await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
168
+ return;
169
+ }
170
+ note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
171
+ const outcome = await runDeploy(job, target, ctx);
172
+ await report(job, ctx, outcome);
173
+ if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
174
+ else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
175
+ } catch (e) {
176
+ warn(`deploy job ${job.id} errored: ${e.message}`);
177
+ await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
178
+ } finally {
179
+ if (beat) clearInterval(beat);
180
+ claiming.delete(job.id);
181
+ }
182
+ })();
183
+ }
184
+ }
185
+
186
+ async function runDeploy(job, target, ctx) {
187
+ const env = { ...process.env, ...deployCreds() }; // inject infra creds; never a file
188
+ const logs = [];
189
+ // Rollback is a single wrangler command; deploy is build → secrets → deploy.
190
+ if (job.kind === 'rollback') {
191
+ const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
192
+ const r = await run(cmd, { cwd: ctx.repoRoot, env });
193
+ logs.push(...tailLines(r.out));
194
+ return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
195
+ }
196
+
197
+ if (target.build) {
198
+ const b = await run(target.build, { cwd: ctx.repoRoot, env });
199
+ logs.push(...tailLines(b.out));
200
+ if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
201
+ }
202
+
203
+ // Push prod app secrets to the provider's secret store (never written local).
204
+ // `name` is always a validated vault key (alnum/underscore) or skipped, and
205
+ // the VALUE goes only via stdin — never argv (no prod plaintext in ps).
206
+ if (job.env === 'prod' && Array.isArray(target.pushSecrets) && target.pushSecrets.length) {
207
+ const secrets = appSecretsFor('prod');
208
+ for (const name of target.pushSecrets) {
209
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !(name in secrets)) {
210
+ warn(`deploy: pushSecret "${name}" invalid or not in the vault at prod scope — skipping`);
211
+ continue;
212
+ }
213
+ const putCmd = target.commands?.['secretPut']
214
+ ? target.commands['secretPut'].replace('{name}', name)
215
+ : `npx wrangler secret put ${name} --env production`;
216
+ const s = await run(putCmd, { cwd: ctx.repoRoot, env, input: secrets[name] });
217
+ if (!s.ok) return { ok: false, message: `pushing secret ${name} failed`, logs };
218
+ }
219
+ }
220
+
221
+ const cmd = target.commands?.[job.env] || target.command;
222
+ const d = await run(cmd, { cwd: ctx.repoRoot, env });
223
+ logs.push(...tailLines(d.out));
224
+ if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
225
+
226
+ // Extract the wrangler version/deployment id if present.
227
+ const idMatch = d.out.match(/Current Version ID:\s*([0-9a-f-]+)/i);
228
+ const deploymentId = idMatch ? idMatch[1] : null;
229
+
230
+ let healthOk = null;
231
+ if (target.healthcheck) {
232
+ healthOk = await verifyHealth(target.healthcheck, target.healthStatus ?? 200);
233
+ }
234
+ return {
235
+ ok: true,
236
+ healthOk,
237
+ deploymentId,
238
+ message: healthOk === false ? 'deployed, but health check failed' : 'deployed',
239
+ logs,
240
+ };
241
+ }
242
+
243
+ function report(job, ctx, outcome) {
244
+ return post('deploy-report', {
245
+ jobId: job.id,
246
+ pubkey: ctx.myPubB64(),
247
+ ok: !!outcome.ok,
248
+ deploymentId: outcome.deploymentId ?? null,
249
+ healthOk: outcome.healthOk ?? null,
250
+ message: scrub(outcome.message || ''),
251
+ }).catch((e) => warn(`deploy: could not report outcome — ${e.message}`));
252
+ }
package/bin/lib/env.mjs CHANGED
@@ -64,18 +64,27 @@ export async function sodiumReady() {
64
64
  /** 6-emoji key fingerprint — algorithm MUST match the web's pubkeyEmoji
65
65
  * (EnvironmentSettings.tsx) so the human can compare terminal ↔ approve card. */
66
66
  // MUST stay byte-identical to the web's pubkeyEmoji (EnvironmentSettings.tsx) —
67
- // the human compares the two. 32 glyphs × 8 positions 40 bits; each position
68
- // mixes the whole key so no byte is mute (a compromised-server pubkey swap must
69
- // grind a full collision, not just the tail).
70
- const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🐝','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
67
+ // the human compares the two strings. 32 glyphs × 8 positions, effective ~40
68
+ // bits. Two FNV-1a rolling hashes over the whole key + a murmur3 finalizer per
69
+ // glyph (a plain additive sum collapsed the space to ~10 bits — grindable).
70
+ const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🦂','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
71
71
  export function pubkeyEmoji(pubkeyB64) {
72
+ let h1 = 0x811c9dc5 >>> 0;
73
+ let h2 = 0xc2b2ae35 >>> 0;
74
+ for (let i = 0; i < pubkeyB64.length; i++) {
75
+ const ch = pubkeyB64.charCodeAt(i);
76
+ h1 = Math.imul(h1 ^ ch, 0x01000193) >>> 0;
77
+ h2 = Math.imul(h2 ^ ch, 0x85ebca6b) >>> 0;
78
+ }
72
79
  let out = '';
73
80
  for (let i = 0; i < 8; i++) {
74
- let acc = i + 1;
75
- for (let j = 0; j < pubkeyB64.length; j++) {
76
- acc = (acc * 31 + pubkeyB64.charCodeAt(j) * (i + 2)) % 1_000_003;
77
- }
78
- out += FP_EMOJI[acc % FP_EMOJI.length];
81
+ let x = (((i < 4 ? h1 : h2) + i * 0x9e3779b1) >>> 0);
82
+ x ^= x >>> 16;
83
+ x = Math.imul(x, 0x7feb352d) >>> 0;
84
+ x ^= x >>> 15;
85
+ x = Math.imul(x, 0x846ca68b) >>> 0;
86
+ x ^= x >>> 16;
87
+ out += FP_EMOJI[x & 31];
79
88
  }
80
89
  return out;
81
90
  }
@@ -186,13 +195,15 @@ function readCache(projectId) {
186
195
  }
187
196
  }
188
197
 
189
- /** Offline start: materialize from the encrypted cache before the first poll. */
198
+ /** Offline start: materialize from the encrypted cache before the first poll.
199
+ * Also seeds knownTargetFiles so stale-file cleanup survives a restart. */
190
200
  export async function loadCachedEnv(projectId) {
191
201
  await ensureKeypair();
192
202
  const cached = readCache(projectId);
193
203
  if (!cached) return false;
194
204
  values = cached.values ?? [];
195
205
  bundleVersion = cached.bundleVersion ?? -1;
206
+ knownTargetFiles = new Set(cached.knownFiles ?? values.map((v) => v.targetFile));
196
207
  cachedProjectId = projectId;
197
208
  return values.length > 0;
198
209
  }
@@ -253,9 +264,16 @@ function isTrackedInGit(wt, relPath) {
253
264
  }
254
265
  }
255
266
 
256
- // Per-worktree: the target files we last materialized, so a file that lost all
257
- // its keys (or a key that moved files) gets its stale copy removed.
267
+ // Per-worktree: the target files we last materialized THIS SESSION.
258
268
  const lastFilesByWorktree = new Map();
269
+ // Project-global union of every target file we've ever materialized — PERSISTED
270
+ // in the cache and seeded on load, so a file whose key was deleted while the
271
+ // daemon was down still gets its stale plaintext copy cleaned on the next
272
+ // materialize (lastFilesByWorktree alone is empty after a restart, and
273
+ // `git clean -fd` never removes an info/exclude'd file).
274
+ let knownTargetFiles = new Set();
275
+
276
+ const MATERIALIZE_HEADER = '# Materialized by flowviant env sync';
259
277
 
260
278
  /** Render KEY=value with values that contain newlines/= safely quoted so one
261
279
  * value can't fabricate another key line. */
@@ -266,14 +284,47 @@ function renderEnvFile(list) {
266
284
  const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
267
285
  return `${v.name}="${esc}"`;
268
286
  });
269
- return `# Materialized by flowviant env sync — DO NOT COMMIT.\n${lines.join('\n')}\n`;
287
+ return `${MATERIALIZE_HEADER} — DO NOT COMMIT.\n${lines.join('\n')}\n`;
270
288
  }
271
289
 
272
- /** Write the decrypted env into ONE worktree. Never call on the wiki worktree. */
290
+ /** Delete a materialized file from a worktree, but ONLY if it's ours (carries
291
+ * our header) and not git-tracked — never touch a file we didn't write. */
292
+ function removeStaleEnvFile(wt, rel) {
293
+ if (isTrackedInGit(wt, rel)) return;
294
+ const abs = join(wt, rel);
295
+ try {
296
+ if (existsSync(abs) && readFileSync(abs, 'utf8').startsWith(MATERIALIZE_HEADER)) {
297
+ rmSync(abs, { force: true });
298
+ }
299
+ } catch {
300
+ /* best-effort */
301
+ }
302
+ }
303
+
304
+ /** Deploy-scope credentials (e.g. CLOUDFLARE_API_TOKEN) as a NAME→value map —
305
+ * injected into the deploy command's process env, NEVER written to a file. */
306
+ export function deployCreds() {
307
+ const out = {};
308
+ for (const v of values) if (v.scope === 'deploy' && v.value) out[v.name] = v.value;
309
+ return out;
310
+ }
311
+
312
+ /** app-scope secrets for one environment as a NAME→value map — for pushing to
313
+ * the provider's secret store on a prod deploy (`wrangler secret put`). */
314
+ export function appSecretsFor(env) {
315
+ const out = {};
316
+ for (const v of values) if (v.scope === 'app' && v.env === env && v.value) out[v.name] = v.value;
317
+ return out;
318
+ }
319
+
320
+ /** Write the decrypted env into ONE worktree. Never call on the wiki worktree.
321
+ * v1 materializes the 'dev' app env (agent test runs + local preview); prod
322
+ * app secrets go to the provider at deploy, deploy creds are injected only. */
273
323
  export function materializeInto(wt) {
274
324
  if (!wt || !existsSync(wt)) return;
275
325
  const byFile = new Map();
276
326
  for (const v of values) {
327
+ if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
277
328
  if (!isSafeTarget(v.targetFile)) continue;
278
329
  const list = byFile.get(v.targetFile) ?? [];
279
330
  list.push(v);
@@ -305,18 +356,15 @@ export function materializeInto(wt) {
305
356
  }
306
357
  }
307
358
 
308
- // Remove files we materialized last time that have no keys now (all deleted,
309
- // or every key moved elsewhere) — a stale secret file must not linger.
310
- const prevFiles = lastFilesByWorktree.get(wt) ?? [];
311
- for (const stale of prevFiles) {
312
- if (!written.includes(stale) && !isTrackedInGit(wt, stale)) {
313
- try {
314
- rmSync(join(wt, stale), { force: true });
315
- } catch {
316
- /* best-effort */
317
- }
318
- }
359
+ // Remove any file we ever materialized (this session OR a prior one, via the
360
+ // persisted knownTargetFiles) that has no keys now — a deleted secret's
361
+ // plaintext file must not linger, even across a daemon restart.
362
+ const writtenSet = new Set(written);
363
+ const candidates = new Set([...(lastFilesByWorktree.get(wt) ?? []), ...knownTargetFiles]);
364
+ for (const stale of candidates) {
365
+ if (!writtenSet.has(stale)) removeStaleEnvFile(wt, stale);
319
366
  }
367
+ for (const f of written) knownTargetFiles.add(f);
320
368
  lastFilesByWorktree.set(wt, written);
321
369
  if (written.length) excludeInWorktree(wt, written);
322
370
  }
@@ -359,7 +407,18 @@ export async function handleRosterEnv(env, { projectId } = {}) {
359
407
  // wedge registration until restart.
360
408
  if (env.status === 'none' && !registeredOnce) {
361
409
  const label = hostname() || 'daemon';
362
- await post('register', { pubkey: myPubB64(), label });
410
+ try {
411
+ await post('register', { pubkey: myPubB64(), label });
412
+ } catch (e) {
413
+ // A 429 = the project is at its machine cap; retrying every poll would
414
+ // just hammer it. Stop for this session (a restart re-tries).
415
+ if (/\(429/.test(e.message)) {
416
+ registeredOnce = true;
417
+ warn('env: this project is at its machine limit — env access not requested. Ask an admin to remove an old machine.');
418
+ return { changed: false };
419
+ }
420
+ throw e; // transient — retry next poll (registeredOnce still false)
421
+ }
363
422
  registeredOnce = true;
364
423
  const fp = pubkeyEmoji(myPubB64());
365
424
  info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
@@ -405,7 +464,7 @@ export async function handleRosterEnv(env, { projectId } = {}) {
405
464
  for (const k of bundle.keys) {
406
465
  try {
407
466
  const plain = openSealed(k.ciphertext, projectPub, projectPriv);
408
- opened.push({ name: k.name, env: k.env, targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
467
+ opened.push({ name: k.name, env: k.env, scope: k.scope ?? 'app', targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
409
468
  } catch {
410
469
  allOpened = false;
411
470
  warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
@@ -443,7 +502,10 @@ export async function handleRosterEnv(env, { projectId } = {}) {
443
502
  if (needSync) {
444
503
  values = opened;
445
504
  bundleVersion = bundle.bundleVersion;
446
- if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion });
505
+ // Fold the current target files into the persisted known set so stale
506
+ // cleanup survives a restart (a key deleted while down still gets swept).
507
+ for (const v of values) knownTargetFiles.add(v.targetFile);
508
+ if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
447
509
  ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
448
510
  return { changed: true };
449
511
  }
package/bin/lib/fleet.mjs CHANGED
@@ -59,8 +59,10 @@ import {
59
59
  envQueryParams,
60
60
  handleRosterEnv,
61
61
  materializeInto,
62
+ myPubB64,
62
63
  scrub as envScrub,
63
64
  } from './env.mjs';
65
+ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
64
66
 
65
67
  async function fetchRoster(haveIds) {
66
68
  const url = new URL(FLEET_URL);
@@ -905,6 +907,14 @@ export async function runFleetDaemon() {
905
907
  }
906
908
  });
907
909
 
910
+ // Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
911
+ // runs queued deploy jobs (the server only sends deployJobs to authorized
912
+ // machines). Config report is cheap + dedup'd; jobs are single-flight.
913
+ if (roster.env?.deployAuthorized) {
914
+ void reportDeployConfig(repoRoot);
915
+ processDeployJobs(roster.deployJobs, { repoRoot, baseRef, myPubB64 });
916
+ }
917
+
908
918
  // Stop workers whose agent left the roster (removed in the app).
909
919
  for (const [id, w] of [...workers]) {
910
920
  if (!rosterIds.has(id)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {