flowviant 0.97.0 → 0.100.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 +15 -13
- package/bin/cli.mjs +108 -28
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +114 -25
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1151 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -3
package/bin/lib/env.mjs
CHANGED
|
@@ -69,8 +69,19 @@
|
|
|
69
69
|
* caller.
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
|
-
import {
|
|
73
|
-
|
|
72
|
+
import {
|
|
73
|
+
readdirSync,
|
|
74
|
+
readFileSync,
|
|
75
|
+
mkdirSync,
|
|
76
|
+
lstatSync,
|
|
77
|
+
openSync,
|
|
78
|
+
writeSync,
|
|
79
|
+
fsyncSync,
|
|
80
|
+
closeSync,
|
|
81
|
+
linkSync,
|
|
82
|
+
unlinkSync,
|
|
83
|
+
} from 'node:fs';
|
|
84
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
74
85
|
import { homedir } from 'node:os';
|
|
75
86
|
import { join, dirname } from 'node:path';
|
|
76
87
|
// STILL SUMO, and deliberately not narrowed to the standard build in the same
|
|
@@ -170,7 +181,17 @@ function characterClasses(value) {
|
|
|
170
181
|
}
|
|
171
182
|
function worthRedacting(value) {
|
|
172
183
|
if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
|
|
173
|
-
|
|
184
|
+
// THE PLAIN-IDENTIFIER EXEMPTION DOES NOT COVER A CASE-AND-DIGIT MIX
|
|
185
|
+
// (2026-09-24, the audit). It was checked first and alone, so a generated
|
|
186
|
+
// password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
|
|
187
|
+
// character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
|
|
188
|
+
// [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
|
|
189
|
+
// this block's own promise that "a case mix" is redacted. A word, a hostname,
|
|
190
|
+
// a region or a version string does not draw on upper, lower AND digits at
|
|
191
|
+
// once; a minted secret almost always does.
|
|
192
|
+
if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
174
195
|
if (value.length < 12 && characterClasses(value) < 3) return false;
|
|
175
196
|
return true;
|
|
176
197
|
}
|
|
@@ -277,19 +298,87 @@ export async function ensureKeypair() {
|
|
|
277
298
|
};
|
|
278
299
|
return keypair;
|
|
279
300
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
writeFileSync(
|
|
301
|
+
const minted = sodium.crypto_box_keypair();
|
|
302
|
+
const winner = mintKeypairFile(
|
|
283
303
|
KEYPAIR_PATH,
|
|
284
304
|
JSON.stringify({
|
|
285
|
-
pub: sodium.to_base64(
|
|
286
|
-
priv: sodium.to_base64(
|
|
287
|
-
})
|
|
288
|
-
{ mode: 0o600 }
|
|
305
|
+
pub: sodium.to_base64(minted.publicKey, B64()),
|
|
306
|
+
priv: sodium.to_base64(minted.privateKey, B64()),
|
|
307
|
+
})
|
|
289
308
|
);
|
|
309
|
+
keypair = {
|
|
310
|
+
publicKey: sodium.from_base64(winner.pub, B64()),
|
|
311
|
+
privateKey: sodium.from_base64(winner.priv, B64()),
|
|
312
|
+
};
|
|
290
313
|
return keypair;
|
|
291
314
|
}
|
|
292
315
|
|
|
316
|
+
/**
|
|
317
|
+
* WRITE THE BOX'S IDENTITY ONCE, ATOMICALLY, AND LET THE FIRST WRITER WIN
|
|
318
|
+
* (2026-09-24, the audit). Returns the `{ pub, priv }` that is ON DISK after
|
|
319
|
+
* the call — ours, or the one a concurrent first start got there with.
|
|
320
|
+
*
|
|
321
|
+
* It was a plain `writeFileSync` reached by every process that saw ENOENT, so
|
|
322
|
+
* two daemons starting together on a fresh box (two projects, two units at
|
|
323
|
+
* boot) each minted a key and the LAST writer won: the first kept a key in
|
|
324
|
+
* memory the disk no longer held and arrived, after its next re-exec, as a
|
|
325
|
+
* different box. And a crash inside the write left a torn file that every later
|
|
326
|
+
* start refuses to read — the only-ENOENT-mints rule doing its job over a file
|
|
327
|
+
* nothing can fix.
|
|
328
|
+
*
|
|
329
|
+
* So the key goes to a private temp file, is fsync'd, and is LINKED into place:
|
|
330
|
+
* `link` fails with EEXIST instead of replacing, so exactly one identity is ever
|
|
331
|
+
* published and every loser reads the winner's. The file at the real path is
|
|
332
|
+
* either absent or complete — never half of one.
|
|
333
|
+
*/
|
|
334
|
+
export function mintKeypairFile(path, body) {
|
|
335
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
336
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
337
|
+
try {
|
|
338
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
339
|
+
try {
|
|
340
|
+
writeSync(fd, body);
|
|
341
|
+
fsyncSync(fd);
|
|
342
|
+
} finally {
|
|
343
|
+
closeSync(fd);
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
linkSync(tmp, path);
|
|
347
|
+
return JSON.parse(body);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
if (e?.code === 'EEXIST') {
|
|
350
|
+
// Somebody else published first. Theirs is the identity; ours is dropped.
|
|
351
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
352
|
+
}
|
|
353
|
+
// A filesystem with no hard links (some network and FUSE mounts answer
|
|
354
|
+
// EPERM/ENOTSUP) must not cost the box its identity — the plain write
|
|
355
|
+
// this replaced worked there. Fall back to an EXCLUSIVE create: still
|
|
356
|
+
// first-writer-wins, only without the torn-write guarantee.
|
|
357
|
+
if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV'].includes(e?.code)) throw e;
|
|
358
|
+
let fd2;
|
|
359
|
+
try {
|
|
360
|
+
fd2 = openSync(path, 'wx', 0o600);
|
|
361
|
+
} catch (e2) {
|
|
362
|
+
if (e2?.code !== 'EEXIST') throw e2;
|
|
363
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
writeSync(fd2, body);
|
|
367
|
+
fsyncSync(fd2);
|
|
368
|
+
} finally {
|
|
369
|
+
closeSync(fd2);
|
|
370
|
+
}
|
|
371
|
+
return JSON.parse(body);
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
try {
|
|
375
|
+
unlinkSync(tmp);
|
|
376
|
+
} catch {
|
|
377
|
+
/* already gone */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
293
382
|
export function myPubB64() {
|
|
294
383
|
return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
|
|
295
384
|
}
|
|
@@ -720,12 +809,35 @@ export function scanEnvForScrub(repoRoot, salt = PROJECT_ID) {
|
|
|
720
809
|
* ordinary word, a short flag and anything under the length floor are simply
|
|
721
810
|
* not in it. */
|
|
722
811
|
export function scrub(text) {
|
|
723
|
-
if (typeof text !== 'string' || !text
|
|
812
|
+
if (typeof text !== 'string' || !text) return text;
|
|
724
813
|
let out = text;
|
|
725
814
|
for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
|
|
726
|
-
return out;
|
|
815
|
+
return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
|
|
727
816
|
}
|
|
728
817
|
|
|
818
|
+
/**
|
|
819
|
+
* EVERY FLOWVIANT CREDENTIAL, CAUGHT BY SHAPE (2026-09-24, the audit).
|
|
820
|
+
*
|
|
821
|
+
* The value lists above are the CHECKOUT'S secrets and the process
|
|
822
|
+
* environment's, and neither holds the one secret that costs the project
|
|
823
|
+
* itself: the MACHINE CREDENTIAL. It sits in `~/.flowviant/credentials.json` —
|
|
824
|
+
* for EVERY project connected on this box, 0600 and readable by every turn,
|
|
825
|
+
* since a turn runs as this uid — and the server mints more of the same shape
|
|
826
|
+
* per turn (the work and capture tokens a tab's MCP server carries). A turn
|
|
827
|
+
* that read the store and wrote it into an artifact, a trace or an answer
|
|
828
|
+
* shipped it verbatim, because `scrub` had no value to match; and a design
|
|
829
|
+
* artifact runs scripts in a frame that may navigate itself, so the page that
|
|
830
|
+
* held it could carry it anywhere.
|
|
831
|
+
*
|
|
832
|
+
* Every one of them is `fva_` + 40 characters of nanoid (the server's
|
|
833
|
+
* `AGENT_TOKEN_PREFIX`), so a SHAPE catches them all, including ones this
|
|
834
|
+
* process never saw, with no store read and nothing to keep in sync. Twenty is
|
|
835
|
+
* the floor so the 12-character prefix the app shows for identification
|
|
836
|
+
* (`fva_` + 8) still reads as itself.
|
|
837
|
+
*/
|
|
838
|
+
const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
|
|
839
|
+
const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
|
|
840
|
+
|
|
729
841
|
/**
|
|
730
842
|
* The NAME of the first known secret whose value appears in `bytes` as an
|
|
731
843
|
* exact byte substring, or null. For the payloads `scrub` cannot touch: a
|
|
@@ -738,7 +850,12 @@ export function scrub(text) {
|
|
|
738
850
|
* it is used (artifacts.mjs).
|
|
739
851
|
*/
|
|
740
852
|
export function secretIn(bytes) {
|
|
741
|
-
if (!Buffer.isBuffer(bytes) || !bytes.length
|
|
853
|
+
if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
|
|
742
854
|
for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
|
|
855
|
+
// The token SHAPE too (see `scrub`). `latin1` maps every byte to one
|
|
856
|
+
// character, so the ASCII pattern matches exactly where the bytes do.
|
|
857
|
+
if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
|
|
858
|
+
return FLOWVIANT_TOKEN_NAME;
|
|
859
|
+
}
|
|
743
860
|
return null;
|
|
744
861
|
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -34,11 +34,16 @@ import {
|
|
|
34
34
|
LIVE,
|
|
35
35
|
AUTO_UPDATE,
|
|
36
36
|
CREDENTIAL,
|
|
37
|
+
learnProjectId,
|
|
38
|
+
storedCredentialInUse,
|
|
37
39
|
} from './config.mjs';
|
|
38
|
-
import { projectLabel, setStoredProjectName } from './credentials.mjs';
|
|
39
|
-
import {
|
|
40
|
+
import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
|
|
41
|
+
import { credentialRejected } from './authReject.mjs';
|
|
42
|
+
import { handleVersionSignal, cmpVersion } from './update.mjs';
|
|
43
|
+
import { emitMachineEvent, writeDaemonState } from './desktopContract.mjs';
|
|
40
44
|
import {
|
|
41
45
|
git,
|
|
46
|
+
gitNetAsync,
|
|
42
47
|
resetWorktree,
|
|
43
48
|
repoRootOrDie,
|
|
44
49
|
detectBaseRef,
|
|
@@ -88,6 +93,8 @@ import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
|
|
|
88
93
|
import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
|
|
89
94
|
import { repoState } from './repoState.mjs';
|
|
90
95
|
import { claudeAuthContext } from './claudeAuth.mjs';
|
|
96
|
+
import { readBaseTools, runnerToolCapabilities, toolReadout } from './projectTools.mjs';
|
|
97
|
+
import { runtimeOfferings } from './runtimeOfferings.mjs';
|
|
91
98
|
|
|
92
99
|
/** Said once per process — see the catch around `envQueryParams` below. */
|
|
93
100
|
let warnedEnvIdentity = false;
|
|
@@ -163,7 +170,8 @@ async function fetchRoster(
|
|
|
163
170
|
* rather than re-derived: `repoRootOrDie` is resolved once at startup and
|
|
164
171
|
* running git on every poll to re-learn a constant would be a syscall for
|
|
165
172
|
* a readout. */
|
|
166
|
-
repoRoot = null
|
|
173
|
+
repoRoot = null,
|
|
174
|
+
baseRef = null
|
|
167
175
|
) {
|
|
168
176
|
const url = new URL(FLEET_URL);
|
|
169
177
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
@@ -301,6 +309,18 @@ async function fetchRoster(
|
|
|
301
309
|
} catch {
|
|
302
310
|
/* best-effort — the poll must never fail on a readout */
|
|
303
311
|
}
|
|
312
|
+
if (repoRoot && baseRef) {
|
|
313
|
+
try {
|
|
314
|
+
const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
|
|
315
|
+
? runtimeOfferings(repoRoot, baseRef) : {};
|
|
316
|
+
for (const [param, report] of Object.entries(offerings)) {
|
|
317
|
+
if (Object.keys(report).length) {
|
|
318
|
+
const payload = JSON.stringify(report);
|
|
319
|
+
if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch { /* local readouts never fail the poll */ }
|
|
323
|
+
}
|
|
304
324
|
// THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
|
|
305
325
|
//
|
|
306
326
|
// It carried two more params while the secrets vault existed: `envv` (the
|
|
@@ -424,10 +444,16 @@ async function fetchRoster(
|
|
|
424
444
|
signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
|
|
425
445
|
});
|
|
426
446
|
if (res.status === 401 || res.status === 403) {
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
447
|
+
// Revoked or expired — retrying can't recover, so signal exit. But ONLY on
|
|
448
|
+
// the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
|
|
449
|
+
// very client class trips) is a retryable failure, and exiting on one took
|
|
450
|
+
// a machine offline for good over a credential that was still valid.
|
|
451
|
+
if (await credentialRejected(res)) {
|
|
452
|
+
const e = new Error(`fleet credential rejected (${res.status})`);
|
|
453
|
+
e.auth = true;
|
|
454
|
+
throw e;
|
|
455
|
+
}
|
|
456
|
+
throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
|
|
431
457
|
}
|
|
432
458
|
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
433
459
|
// THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
|
|
@@ -761,6 +787,36 @@ export async function maybeReportEnv(repoRoot) {
|
|
|
761
787
|
}
|
|
762
788
|
}
|
|
763
789
|
|
|
790
|
+
const TOOLS_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/tools-report');
|
|
791
|
+
let toolsReportSent = null;
|
|
792
|
+
let toolsReportUnsupported = false;
|
|
793
|
+
let toolsReportAt = 0;
|
|
794
|
+
/** Repo tool readiness is a per-box daemon report. Never include env values. */
|
|
795
|
+
export async function maybeReportTools(repoRoot, baseRef) {
|
|
796
|
+
if (Date.now() - toolsReportAt < 60_000) return;
|
|
797
|
+
toolsReportAt = Date.now();
|
|
798
|
+
if (toolsReportUnsupported) return;
|
|
799
|
+
const pubkey = myPubB64();
|
|
800
|
+
if (!pubkey) return;
|
|
801
|
+
let payload;
|
|
802
|
+
try {
|
|
803
|
+
const snapshot = readBaseTools(repoRoot, baseRef);
|
|
804
|
+
const installed = detectRuntimes().filter((r) => r.installed);
|
|
805
|
+
const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
|
|
806
|
+
payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
|
|
807
|
+
} catch { return; }
|
|
808
|
+
if (payload === toolsReportSent) return;
|
|
809
|
+
try {
|
|
810
|
+
const res = await fetch(TOOLS_REPORT_URL, {
|
|
811
|
+
method: 'POST',
|
|
812
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
|
|
813
|
+
signal: AbortSignal.timeout(15_000), body: payload,
|
|
814
|
+
});
|
|
815
|
+
if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
|
|
816
|
+
toolsReportSent = res.ok ? payload : null;
|
|
817
|
+
} catch { toolsReportSent = null; }
|
|
818
|
+
}
|
|
819
|
+
|
|
764
820
|
/**
|
|
765
821
|
* A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
|
|
766
822
|
*
|
|
@@ -891,10 +947,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
|
|
|
891
947
|
const took = holder.took;
|
|
892
948
|
if (took && typeof took === 'object' && !Array.isArray(took)) {
|
|
893
949
|
standbyKey = null;
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
: 'another machine';
|
|
950
|
+
// scrubbed before it reaches a terminal (2026-09-24, the audit) —
|
|
951
|
+
// see printable.mjs.
|
|
952
|
+
const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
|
|
898
953
|
const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
|
|
899
954
|
say(
|
|
900
955
|
`took this project's machine from ${from}` +
|
|
@@ -913,10 +968,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
|
|
|
913
968
|
// The NAME is all the response carries about the other box, so it is also
|
|
914
969
|
// the only thing "a distinct holder" can be keyed on. An unnamed holder
|
|
915
970
|
// keys on the empty string, which is stable — one announcement, not one
|
|
916
|
-
// per poll.
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
: null;
|
|
971
|
+
// per poll. Scrubbed before it reaches a terminal (2026-09-24, the
|
|
972
|
+
// audit) — see printable.mjs.
|
|
973
|
+
const name = safeName(holder.name)?.slice(0, 64) ?? null;
|
|
920
974
|
const key = name ?? '';
|
|
921
975
|
if (key !== standbyKey) {
|
|
922
976
|
standbyKey = key;
|
|
@@ -980,9 +1034,10 @@ export function createHolderWatch({ say = () => {} } = {}) {
|
|
|
980
1034
|
|
|
981
1035
|
/** The sentence an in-flight agent turn is settled with when the machine moves
|
|
982
1036
|
* out from under it. MEASURED, not inferred: the server named the box that
|
|
983
|
-
* took over, and an unnamed one says so rather than guessing.
|
|
1037
|
+
* took over, and an unnamed one says so rather than guessing. Scrubbed
|
|
1038
|
+
* before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
|
|
984
1039
|
export function displacedTurnSentence(by) {
|
|
985
|
-
const name =
|
|
1040
|
+
const name = safeName(by)?.slice(0, 64) ?? 'another machine';
|
|
986
1041
|
return `The project's machine moved to ${name} while this turn was running.`;
|
|
987
1042
|
}
|
|
988
1043
|
|
|
@@ -1048,7 +1103,9 @@ export async function standDownDisplaced({
|
|
|
1048
1103
|
t.unref?.();
|
|
1049
1104
|
}),
|
|
1050
1105
|
]);
|
|
1051
|
-
|
|
1106
|
+
// Scrubbed before it reaches this box's own console (2026-09-24, the
|
|
1107
|
+
// audit) — see printable.mjs.
|
|
1108
|
+
const name = safeName(by)?.slice(0, 64) ?? null;
|
|
1052
1109
|
/**
|
|
1053
1110
|
* TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
|
|
1054
1111
|
*
|
|
@@ -1064,8 +1121,7 @@ export async function standDownDisplaced({
|
|
|
1064
1121
|
* drift and neither call site can invent a third.
|
|
1065
1122
|
*/
|
|
1066
1123
|
const removed = kind === 'removed';
|
|
1067
|
-
const projectName =
|
|
1068
|
-
typeof project === 'string' && project.trim() ? project.trim().slice(0, 64) : 'this project';
|
|
1124
|
+
const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
|
|
1069
1125
|
log.warn(
|
|
1070
1126
|
removed
|
|
1071
1127
|
? `removed from ${projectName} in the app — stopping.`
|
|
@@ -1073,6 +1129,8 @@ export async function standDownDisplaced({
|
|
|
1073
1129
|
? `this project's machine moved to ${name} — standing down.`
|
|
1074
1130
|
: "this project's machine moved to another box — standing down."
|
|
1075
1131
|
);
|
|
1132
|
+
if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
|
|
1133
|
+
else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
|
|
1076
1134
|
// FIRST, and awaited: an unsettled turn is the one thing here that no later
|
|
1077
1135
|
// poll from anybody can fix — this process holds the only copy of the fact
|
|
1078
1136
|
// that it was running.
|
|
@@ -1104,7 +1162,11 @@ export async function standDownDisplaced({
|
|
|
1104
1162
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
1105
1163
|
// base between tasks (fresh conversation), resume in place while on a blocker.
|
|
1106
1164
|
|
|
1107
|
-
export async function runFleetDaemon() {
|
|
1165
|
+
export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
1166
|
+
let desktopProjectId = CREDENTIAL?.entry?.projectId ?? null;
|
|
1167
|
+
let wasServing = false;
|
|
1168
|
+
if (process.env.FLOWVIANT_UPDATE_TARGET && cmpVersion(VERSION, process.env.FLOWVIANT_UPDATE_TARGET) >= 0)
|
|
1169
|
+
emitMachineEvent({ event: 'update-applied', from: process.env.FLOWVIANT_UPDATE_FROM ?? null, to: VERSION });
|
|
1108
1170
|
console.log('');
|
|
1109
1171
|
console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
|
|
1110
1172
|
console.log(` ${c.dim('──────────────────────────────────────────────')}`);
|
|
@@ -1145,7 +1207,7 @@ export async function runFleetDaemon() {
|
|
|
1145
1207
|
// about to serve" must not require a network round trip to answer. Only when
|
|
1146
1208
|
// the credential came from the STORE: a --fleet/env token names no project
|
|
1147
1209
|
// until the roster does.
|
|
1148
|
-
if (CREDENTIAL?.entry) {
|
|
1210
|
+
if (CREDENTIAL?.entry && storedCredentialInUse()) {
|
|
1149
1211
|
info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
|
|
1150
1212
|
}
|
|
1151
1213
|
info(`repo · ${repoRoot}`);
|
|
@@ -1214,6 +1276,17 @@ export async function runFleetDaemon() {
|
|
|
1214
1276
|
}
|
|
1215
1277
|
if (instance.unguarded)
|
|
1216
1278
|
warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
|
|
1279
|
+
// The repo binding the start path's picker or confirm was answered with.
|
|
1280
|
+
// Persisted HERE, after the lock, so a refused start moves nothing: moving it
|
|
1281
|
+
// first stranded the daemon already serving that project's own checkout at
|
|
1282
|
+
// its next unattended restart. Best-effort — an unwritable store costs the
|
|
1283
|
+
// next start a question, never this one its machine.
|
|
1284
|
+
try {
|
|
1285
|
+
afterLock?.();
|
|
1286
|
+
} catch {
|
|
1287
|
+
/* the binding is asked again next time */
|
|
1288
|
+
}
|
|
1289
|
+
writeDaemonState(desktopProjectId, { limits: {}, holder: null });
|
|
1217
1290
|
|
|
1218
1291
|
await preflight({ needGit: true });
|
|
1219
1292
|
|
|
@@ -1378,6 +1451,7 @@ export async function runFleetDaemon() {
|
|
|
1378
1451
|
// A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
|
|
1379
1452
|
};
|
|
1380
1453
|
process.on('SIGINT', () => {
|
|
1454
|
+
emitMachineEvent({ event: 'stopped', reason: 'signal' });
|
|
1381
1455
|
console.log('');
|
|
1382
1456
|
note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
1383
1457
|
teardown();
|
|
@@ -1387,6 +1461,7 @@ export async function runFleetDaemon() {
|
|
|
1387
1461
|
// handler every child survived a `systemctl stop` — the exact orphaning the
|
|
1388
1462
|
// teardown exists to prevent.
|
|
1389
1463
|
process.on('SIGTERM', () => {
|
|
1464
|
+
emitMachineEvent({ event: 'stopped', reason: 'signal' });
|
|
1390
1465
|
console.log('');
|
|
1391
1466
|
note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
1392
1467
|
teardown();
|
|
@@ -1736,10 +1811,11 @@ export async function runFleetDaemon() {
|
|
|
1736
1811
|
} else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
|
|
1737
1812
|
try {
|
|
1738
1813
|
// Explicit refspec form so a leading '-' can't be a git flag.
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1814
|
+
// NETWORK call, so timed and non-interactive (2026-09-24, the
|
|
1815
|
+
// audit): a bare execFileSync here had no timeout and could
|
|
1816
|
+
// prompt on /dev/tty, freezing every poll and lease on the
|
|
1817
|
+
// machine behind this cleanup's own async loop.
|
|
1818
|
+
await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
|
|
1743
1819
|
} catch {
|
|
1744
1820
|
/* branch already gone — fine */
|
|
1745
1821
|
}
|
|
@@ -2336,10 +2412,12 @@ export async function runFleetDaemon() {
|
|
|
2336
2412
|
livePreviewIds(),
|
|
2337
2413
|
heldSessionIds(),
|
|
2338
2414
|
admit('churn'),
|
|
2339
|
-
repoRoot
|
|
2415
|
+
repoRoot,
|
|
2416
|
+
getBaseRef()
|
|
2340
2417
|
);
|
|
2341
2418
|
} catch (e) {
|
|
2342
2419
|
if (e.auth) {
|
|
2420
|
+
emitMachineEvent({ event: 'stopped', reason: 'credential-revoked' });
|
|
2343
2421
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
2344
2422
|
teardown();
|
|
2345
2423
|
// EXIT 0, for the same reason the commanded-stop path does: a revoked
|
|
@@ -2361,7 +2439,7 @@ export async function runFleetDaemon() {
|
|
|
2361
2439
|
// you're viewing project B's wiki) is obvious instead of a silent no-op.
|
|
2362
2440
|
if (roster.project) {
|
|
2363
2441
|
note(
|
|
2364
|
-
`${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
|
|
2442
|
+
`${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
|
|
2365
2443
|
);
|
|
2366
2444
|
note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
|
|
2367
2445
|
// Remember the NAME beside the stored credential, so the picker and
|
|
@@ -2400,6 +2478,8 @@ export async function runFleetDaemon() {
|
|
|
2400
2478
|
}
|
|
2401
2479
|
})();
|
|
2402
2480
|
}
|
|
2481
|
+
if (roster.project?.id) desktopProjectId = roster.project.id;
|
|
2482
|
+
writeDaemonState(desktopProjectId, { lastPoll: new Date().toISOString() });
|
|
2403
2483
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
2404
2484
|
/**
|
|
2405
2485
|
* HOW MANY TURNS THE APP SAYS THIS MACHINE MAY RUN (2026-09-17).
|
|
@@ -2416,6 +2496,9 @@ export async function runFleetDaemon() {
|
|
|
2416
2496
|
*/
|
|
2417
2497
|
setServerMaxTurns(roster.maxTurns);
|
|
2418
2498
|
if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
|
|
2499
|
+
// The server's word on which project this token serves — settles the env
|
|
2500
|
+
// report's salt for a token that came from --fleet or the environment.
|
|
2501
|
+
learnProjectId(roster.project?.id);
|
|
2419
2502
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
2420
2503
|
// A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
|
|
2421
2504
|
// this sits ABOVE the version signal rather than inside it. Both read the
|
|
@@ -2425,6 +2508,7 @@ export async function runFleetDaemon() {
|
|
|
2425
2508
|
// going away.
|
|
2426
2509
|
const stopSignal = shouldStop(roster.daemon);
|
|
2427
2510
|
if (stopSignal) {
|
|
2511
|
+
emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
|
|
2428
2512
|
warn(
|
|
2429
2513
|
stopSignal.reason
|
|
2430
2514
|
? `stopped by Flowviant — ${stopSignal.reason}`
|
|
@@ -2510,7 +2594,7 @@ export async function runFleetDaemon() {
|
|
|
2510
2594
|
// The project's NAME rather than the box that took over, because no box
|
|
2511
2595
|
// took over. Falls back to "this project" inside — an unnamed project
|
|
2512
2596
|
// says so rather than being guessed at.
|
|
2513
|
-
project: roster.standDown.project ?? roster.project?.name,
|
|
2597
|
+
project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
|
|
2514
2598
|
settleAgentTurns,
|
|
2515
2599
|
flushReports: flushWorkReports,
|
|
2516
2600
|
teardown,
|
|
@@ -2529,6 +2613,12 @@ export async function runFleetDaemon() {
|
|
|
2529
2613
|
* would start deleting worktrees it cannot see the tabs for.
|
|
2530
2614
|
*/
|
|
2531
2615
|
holderState = holderWatch.observe(roster.holder);
|
|
2616
|
+
writeDaemonState(desktopProjectId, {
|
|
2617
|
+
lastPoll: new Date().toISOString(),
|
|
2618
|
+
holder: ({ mine: 'serving', standby: 'inactive' })[holderState] ?? null,
|
|
2619
|
+
});
|
|
2620
|
+
if (holderState === 'mine' && !wasServing) emitMachineEvent({ event: 'serving' });
|
|
2621
|
+
wasServing = holderState === 'mine';
|
|
2532
2622
|
// Keep the daemon current. Safe = no worker mid-task (true at startup, since
|
|
2533
2623
|
// no workers are spawned yet). If it self-updates it re-execs into the new
|
|
2534
2624
|
// version and this process becomes a proxy — stop the loop.
|
|
@@ -2550,7 +2640,7 @@ export async function runFleetDaemon() {
|
|
|
2550
2640
|
safeToUpdate,
|
|
2551
2641
|
teardown,
|
|
2552
2642
|
});
|
|
2553
|
-
if (updating) return;
|
|
2643
|
+
if (await updating) return;
|
|
2554
2644
|
}
|
|
2555
2645
|
// Settle any turn/ship answers whose earlier report POST failed BEFORE
|
|
2556
2646
|
// taking new work — the skip-if-pending guards make the ordering safe, but
|
|
@@ -2722,6 +2812,7 @@ export async function runFleetDaemon() {
|
|
|
2722
2812
|
// throttled and deduped inside, and silent forever on an older server.
|
|
2723
2813
|
// This is what replaced the vault's sync tick — see `maybeReportEnv`.
|
|
2724
2814
|
void maybeReportEnv(repoRoot);
|
|
2815
|
+
void maybeReportTools(repoRoot, getBaseRef());
|
|
2725
2816
|
|
|
2726
2817
|
// Tell the app what this machine is doing with itself. Every reconcile,
|
|
2727
2818
|
// best-effort, and never awaited — telemetry that can delay a dispatch is
|
package/bin/lib/git.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Git worktree helpers (fleet & static-fleet modes). */
|
|
2
2
|
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
4
4
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
|
|
@@ -8,6 +8,134 @@ export function git(args, cwd) {
|
|
|
8
8
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* THE SSH COMMAND A NETWORK GIT CALL RUNS UNDER when the operator has not named
|
|
13
|
+
* one. `BatchMode` turns a passphrase or host-key prompt on /dev/tty into an
|
|
14
|
+
* immediate failure; the keepalive pair ends a half-open connection (a laptop
|
|
15
|
+
* that slept, a Wi-Fi change) in ~30s instead of when the kernel gives up hours
|
|
16
|
+
* later. An agent key still works — BatchMode disables prompts, not keys.
|
|
17
|
+
*/
|
|
18
|
+
export const NET_SSH_COMMAND =
|
|
19
|
+
'ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=2';
|
|
20
|
+
|
|
21
|
+
/** The default bound on one network git call. */
|
|
22
|
+
export const GIT_NET_TIMEOUT_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The environment a NETWORK git call runs under — never interactive.
|
|
26
|
+
*
|
|
27
|
+
* `GIT_TERMINAL_PROMPT=0` makes git fail instead of opening /dev/tty for a
|
|
28
|
+
* username (an HTTPS remote whose credential cache expired), and
|
|
29
|
+
* `GCM_INTERACTIVE=never` says the same to Git Credential Manager. The SSH
|
|
30
|
+
* command is set only when the operator has not chosen one — `GIT_SSH_COMMAND`,
|
|
31
|
+
* `GIT_SSH` or `core.sshCommand` — because the env var OUTRANKS the config key,
|
|
32
|
+
* and overriding somebody's `-i ~/.ssh/deploy_key` would break every fetch to
|
|
33
|
+
* make it non-interactive. Their own command is still bounded by the timeout.
|
|
34
|
+
*/
|
|
35
|
+
export function gitNetEnv(cwd, env = process.env) {
|
|
36
|
+
const out = { ...env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
|
|
37
|
+
if (env.GIT_SSH_COMMAND || env.GIT_SSH) return out;
|
|
38
|
+
let configured = '';
|
|
39
|
+
try {
|
|
40
|
+
configured = execFileSync('git', ['config', '--get', 'core.sshCommand'], {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
44
|
+
timeout: 5_000,
|
|
45
|
+
}).trim();
|
|
46
|
+
} catch {
|
|
47
|
+
/* unset (exit 1) or unreadable — ours, then */
|
|
48
|
+
}
|
|
49
|
+
if (!configured) out.GIT_SSH_COMMAND = NET_SSH_COMMAND;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A NETWORK git call (fetch, push), TIMED AND NON-INTERACTIVE.
|
|
55
|
+
*
|
|
56
|
+
* `git()` above is `execFileSync` with no timeout: right for a local
|
|
57
|
+
* `rev-parse`, and a daemon-wide freeze for a `fetch` — the call blocks the
|
|
58
|
+
* whole event loop, so a remote that prompts on /dev/tty or a connection that
|
|
59
|
+
* went half-open stops every poll, settle, lease renewal and relay on the
|
|
60
|
+
* machine until it returns. Every call that talks to a remote goes through
|
|
61
|
+
* here. Returns the untrimmed stdout; throws like `git()` does (a timeout
|
|
62
|
+
* throws with `code: 'ETIMEDOUT'`).
|
|
63
|
+
*/
|
|
64
|
+
export function gitNet(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
|
|
65
|
+
return execFileSync('git', args, {
|
|
66
|
+
cwd,
|
|
67
|
+
encoding: 'utf8',
|
|
68
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
69
|
+
timeout: ms,
|
|
70
|
+
killSignal: 'SIGKILL',
|
|
71
|
+
env: gitNetEnv(cwd),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The same call WITHOUT BLOCKING — for the unattended periodic fetch, where
|
|
77
|
+
* even a bounded freeze every few minutes is a daemon that goes dark for a
|
|
78
|
+
* remote's bad day. Spawned as its own process group so a timeout takes the
|
|
79
|
+
* ssh or credential helper under git with it. Resolves stdout; rejects on a
|
|
80
|
+
* non-zero exit, a spawn error or the timeout.
|
|
81
|
+
*/
|
|
82
|
+
export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
|
|
83
|
+
return new Promise((resolvePromise, reject) => {
|
|
84
|
+
let child;
|
|
85
|
+
try {
|
|
86
|
+
child = spawn('git', args, {
|
|
87
|
+
cwd,
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
env: gitNetEnv(cwd),
|
|
90
|
+
detached: true,
|
|
91
|
+
});
|
|
92
|
+
} catch (e) {
|
|
93
|
+
reject(e);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let out = '';
|
|
97
|
+
let err = '';
|
|
98
|
+
let settled = false;
|
|
99
|
+
const finish = (fn, v) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
fn(v);
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
108
|
+
} catch {
|
|
109
|
+
try {
|
|
110
|
+
child.kill('SIGKILL');
|
|
111
|
+
} catch {
|
|
112
|
+
/* already gone */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const e = new Error(`git ${args[0]} timed out after ${ms}ms`);
|
|
116
|
+
e.code = 'ETIMEDOUT';
|
|
117
|
+
finish(reject, e);
|
|
118
|
+
}, ms);
|
|
119
|
+
timer.unref?.();
|
|
120
|
+
child.stdout.on('data', (d) => {
|
|
121
|
+
out += d;
|
|
122
|
+
});
|
|
123
|
+
child.stderr.on('data', (d) => {
|
|
124
|
+
err += d;
|
|
125
|
+
});
|
|
126
|
+
child.on('error', (e) => finish(reject, e));
|
|
127
|
+
child.on('close', (code) => {
|
|
128
|
+
if (code === 0) finish(resolvePromise, out);
|
|
129
|
+
else {
|
|
130
|
+
const e = new Error(`git ${args[0]} exited ${code}`);
|
|
131
|
+
e.stderr = err;
|
|
132
|
+
e.status = code;
|
|
133
|
+
finish(reject, e);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
11
139
|
/**
|
|
12
140
|
* Same call, UNTRIMMED — for `-z` (NUL-separated) output, where trimming would
|
|
13
141
|
* eat the final separator and the leading space of a status code.
|
|
@@ -143,9 +271,9 @@ export function baseBranchName(baseRef) {
|
|
|
143
271
|
|
|
144
272
|
export function resetWorktree(wt, baseRef) {
|
|
145
273
|
try {
|
|
146
|
-
|
|
274
|
+
gitNet(['fetch', 'origin', '--quiet'], wt);
|
|
147
275
|
} catch {
|
|
148
|
-
/* offline / no remote — reset to whatever we have */
|
|
276
|
+
/* offline / no remote / timed out — reset to whatever we have */
|
|
149
277
|
}
|
|
150
278
|
try {
|
|
151
279
|
git(['checkout', '--detach', baseRef], wt);
|