flowviant 0.54.1 → 0.55.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/bin/cli.mjs +144 -8
- package/bin/lib/config.mjs +18 -11
- package/bin/lib/credentials.mjs +258 -0
- package/bin/lib/deploy.mjs +10 -2
- package/bin/lib/env-cli.mjs +5 -1
- package/bin/lib/fleet.mjs +50 -4
- package/bin/lib/instance.mjs +104 -21
- package/bin/lib/login.mjs +23 -21
- package/bin/lib/mcp-cli.mjs +3 -2
- package/bin/lib/preview.mjs +99 -10
- package/bin/lib/work.mjs +37 -1
- package/package.json +2 -2
package/bin/cli.mjs
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* odd side job the roster carries — a commit's patch, a preview share, a wiki
|
|
33
33
|
* regen. When you say ship, the daemon merges that branch into base `--no-ff`.
|
|
34
34
|
*
|
|
35
|
+
* MANY PROJECTS, ONE BOX (0.55.0): `flowviant login` in each repo stores one
|
|
36
|
+
* credential per project (~/.flowviant/credentials.json holds a map), and a
|
|
37
|
+
* bare `npx flowviant` serves the project BOUND to the repo it is started in.
|
|
38
|
+
* Ambiguity is a picker on a TTY and a worded refusal headless — never a
|
|
39
|
+
* guess. `flowviant projects` lists what is stored; `--project <name|id>`
|
|
40
|
+
* picks without a prompt.
|
|
41
|
+
*
|
|
35
42
|
* Env:
|
|
36
43
|
* FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
|
|
37
44
|
* FLOWVIANT_API_URL default https://api.flowviant.com/api/v2
|
|
@@ -49,7 +56,7 @@
|
|
|
49
56
|
* git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
|
|
50
57
|
* env + env-cli + vault, resources, deploy, shot.
|
|
51
58
|
*/
|
|
52
|
-
import { FLEET_TOKEN } from './lib/config.mjs';
|
|
59
|
+
import { FLEET_TOKEN, CREDENTIAL, adoptStoredCredential } from './lib/config.mjs';
|
|
53
60
|
import { runFleetDaemon } from './lib/fleet.mjs';
|
|
54
61
|
import { runLogin } from './lib/login.mjs';
|
|
55
62
|
|
|
@@ -172,6 +179,32 @@ if (process.argv[2] === 'stop') {
|
|
|
172
179
|
process.exit(failed > 0 ? 1 : 0);
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
// `flowviant projects` — every project this box has a credential for, which
|
|
183
|
+
// repo each is bound to, and which the legacy mirror points at. Needs no
|
|
184
|
+
// network: it reads the store, which is the exact thing a confused person is
|
|
185
|
+
// trying to see. The remedies are named because this listing IS the moment of
|
|
186
|
+
// confusion ("why did it say skadooble?"), not documentation.
|
|
187
|
+
if (process.argv[2] === 'projects') {
|
|
188
|
+
const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
|
|
189
|
+
const entries = listStoredProjects();
|
|
190
|
+
if (entries.length === 0) {
|
|
191
|
+
console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
for (const e of entries) {
|
|
195
|
+
console.log(
|
|
196
|
+
` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…)` +
|
|
197
|
+
`${e.repoRoot ? `\n repo · ${e.repoRoot}` : '\n repo · not bound yet — first start or login in its repo binds it'}` +
|
|
198
|
+
`${e.active ? '\n what a pre-0.55.0 flowviant on this box would serve (the legacy mirror)' : ''}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
console.log(
|
|
202
|
+
'\n `npx flowviant` picks by the repo it is started in; `--project <name|id>` overrides;\n' +
|
|
203
|
+
' `flowviant login` in a new repo connects another project.'
|
|
204
|
+
);
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
207
|
+
|
|
175
208
|
// `flowviant env <import|set|show>` — the CLI half of team env sync. Values
|
|
176
209
|
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
177
210
|
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
|
@@ -191,14 +224,117 @@ if (process.argv[2] === 'env') {
|
|
|
191
224
|
process.exit(0);
|
|
192
225
|
}
|
|
193
226
|
|
|
227
|
+
// ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
|
|
228
|
+
//
|
|
229
|
+
// The store holds many projects since 0.55.0 and resolution is BY REPO
|
|
230
|
+
// (credentials.mjs). What is left here is the human half: an ambiguous store
|
|
231
|
+
// on a TTY becomes a PICKER, a single unbound credential gets ONE confirm that
|
|
232
|
+
// binds it, and a headless start with no unambiguous answer refuses in words
|
|
233
|
+
// that name every stored project and every way out. The one thing this block
|
|
234
|
+
// must never do is serve a project the resolution did not name — "it said
|
|
235
|
+
// skadooble in my calendar repo" is the confusion this exists to end.
|
|
236
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
237
|
+
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
238
|
+
|
|
239
|
+
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
|
240
|
+
* pattern: config.mjs read the store at IMPORT time, before the credential
|
|
241
|
+
* existed, so this process cannot serve; the child can. */
|
|
242
|
+
async function reexecAfterLogin() {
|
|
243
|
+
await runLogin({ thenStart: false });
|
|
244
|
+
const { spawn } = await import('node:child_process');
|
|
245
|
+
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
246
|
+
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function listLines(entries, { projectLabel }) {
|
|
250
|
+
return entries
|
|
251
|
+
.map(
|
|
252
|
+
(e, i) =>
|
|
253
|
+
` ${i + 1}. ${projectLabel(e)}` +
|
|
254
|
+
(e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
|
|
255
|
+
)
|
|
256
|
+
.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
194
259
|
if (!FLEET_TOKEN) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
260
|
+
if (CREDENTIAL.error) {
|
|
261
|
+
console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
if (CREDENTIAL.choices?.length && interactive) {
|
|
265
|
+
const creds = await import('./lib/credentials.mjs');
|
|
266
|
+
const { choices, repoRoot } = CREDENTIAL;
|
|
267
|
+
console.log(
|
|
268
|
+
CREDENTIAL.reason === 'outside-repo'
|
|
269
|
+
? 'flowviant is not inside a git repo, and more than one project is connected here.'
|
|
270
|
+
: CREDENTIAL.reason === 'multiple-bound'
|
|
271
|
+
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
272
|
+
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
273
|
+
);
|
|
274
|
+
console.log(listLines(choices, creds));
|
|
275
|
+
console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
|
|
276
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
277
|
+
input: process.stdin,
|
|
278
|
+
output: process.stdout,
|
|
279
|
+
});
|
|
280
|
+
const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
|
|
281
|
+
rl.close();
|
|
282
|
+
const n = Number.parseInt(raw, 10);
|
|
283
|
+
if (n === choices.length + 1) await reexecAfterLogin();
|
|
284
|
+
const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
|
|
285
|
+
if (!picked) {
|
|
286
|
+
console.error('nothing chosen — nothing started.');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
// An answered question is consent: adopt it, and BIND it to this repo so
|
|
290
|
+
// the next start needs no prompt. Repointing is legitimate and said aloud.
|
|
291
|
+
if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
|
|
292
|
+
console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
|
|
293
|
+
}
|
|
294
|
+
adoptStoredCredential(picked);
|
|
295
|
+
creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
|
|
296
|
+
console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
|
|
297
|
+
} else if (CREDENTIAL.choices?.length) {
|
|
298
|
+
const creds = await import('./lib/credentials.mjs');
|
|
299
|
+
console.error(
|
|
300
|
+
'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
|
|
301
|
+
listLines(CREDENTIAL.choices, creds) +
|
|
302
|
+
'\nPick one with `--project <name|id>`, bind this repo by running `flowviant` here in a terminal once,\n' +
|
|
303
|
+
'or connect this repo to its own project with `flowviant login`.'
|
|
304
|
+
);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
} else {
|
|
307
|
+
console.error(
|
|
308
|
+
'error: no credential found. Easiest:\n' +
|
|
309
|
+
' flowviant login (approve in the app — recommended)\n' +
|
|
310
|
+
'Or set:\n' +
|
|
311
|
+
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
312
|
+
);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
} else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
|
|
316
|
+
// ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
|
|
317
|
+
// yes binds and every later start is silent. This is the exact question
|
|
318
|
+
// whose absence had a calendar checkout serving skadooble.
|
|
319
|
+
const creds = await import('./lib/credentials.mjs');
|
|
320
|
+
const label = creds.projectLabel(CREDENTIAL.entry);
|
|
321
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
322
|
+
input: process.stdin,
|
|
323
|
+
output: process.stdout,
|
|
324
|
+
});
|
|
325
|
+
const raw = (
|
|
326
|
+
await rl.question(`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `)
|
|
327
|
+
).trim().toLowerCase();
|
|
328
|
+
rl.close();
|
|
329
|
+
if (raw === '' || raw === 'y' || raw === 'yes') {
|
|
330
|
+
creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
|
|
331
|
+
} else {
|
|
332
|
+
console.error(
|
|
333
|
+
`nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
|
|
334
|
+
`or see what is stored with \`flowviant projects\`.`
|
|
335
|
+
);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
202
338
|
}
|
|
203
339
|
|
|
204
340
|
await runFleetDaemon();
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
7
|
+
import { cpus, totalmem } from 'node:os';
|
|
8
8
|
|
|
9
9
|
// Read the daemon's version from its OWN package.json (always shipped in the npm
|
|
10
10
|
// tarball) — never hardcode it. The hardcoded constant drifted: it sat at
|
|
@@ -30,14 +30,11 @@ export const MODEL = process.env.FLOWVIANT_MODEL || 'opus';
|
|
|
30
30
|
|
|
31
31
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
32
32
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const stored = readStoredCredential();
|
|
33
|
+
// Since 0.55.0 the store holds MANY projects and resolution is BY REPO — see
|
|
34
|
+
// credentials.mjs for the whole rule. `CREDENTIAL` carries the resolution so
|
|
35
|
+
// cli.mjs can turn an ambiguity into a picker instead of a guess.
|
|
36
|
+
import { resolveStoredCredential } from './credentials.mjs';
|
|
37
|
+
export const CREDENTIAL = resolveStoredCredential();
|
|
41
38
|
|
|
42
39
|
function argFlag(name) {
|
|
43
40
|
const i = process.argv.indexOf(name);
|
|
@@ -167,5 +164,15 @@ export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
|
|
|
167
164
|
// --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
|
|
168
165
|
// that principal owns zero tools since dispatch was deleted, and the kind can no
|
|
169
166
|
// longer be minted, so the plumbing went with the entrypoint (2026-08-19).
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
// `let`, because ES named imports are LIVE bindings: when cli.mjs answers an
|
|
168
|
+
// ambiguous store with a picker, adoptStoredCredential updates every importer
|
|
169
|
+
// before the daemon touches the network. Everything before that point (the
|
|
170
|
+
// resolution, the flags) is settled synchronously at import, as it always was.
|
|
171
|
+
export let FLEET_TOKEN =
|
|
172
|
+
argFlag('--fleet') || process.env.FLOWVIANT_FLEET || CREDENTIAL.entry?.fleetToken || '';
|
|
173
|
+
|
|
174
|
+
/** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
|
|
175
|
+
* re-authenticates a connection already made. */
|
|
176
|
+
export function adoptStoredCredential(entry) {
|
|
177
|
+
if (entry?.fleetToken) FLEET_TOKEN = entry.fleetToken;
|
|
178
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CREDENTIAL STORE — one file, MANY projects (v2, 2026-08-23).
|
|
3
|
+
*
|
|
4
|
+
* ~/.flowviant/credentials.json used to hold exactly one credential, and
|
|
5
|
+
* `flowviant login` overwrote it — so `npx flowviant` in ANY directory served
|
|
6
|
+
* whatever project was logged into last. A user standing in their calendar
|
|
7
|
+
* repo watched the banner say another project's name, and the only thing that
|
|
8
|
+
* saved them from serving the wrong repo was the instance lock's same-repo
|
|
9
|
+
* refusal. One VM is allowed to run one daemon per project (the lock is keyed
|
|
10
|
+
* per credential and the header of instance.mjs says so out loud); the store
|
|
11
|
+
* was the only thing pretending otherwise.
|
|
12
|
+
*
|
|
13
|
+
* THE FILE SHAPE, and why it is two shapes at once:
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* fleetToken, projectId, mcpUrl, // the LEGACY MIRROR
|
|
17
|
+
* projects: { // v2: every connected project
|
|
18
|
+
* [projectId]: { fleetToken, mcpUrl, name, repoRoot, savedAt }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* The top-level trio is what every daemon before 0.55.0 reads, and the CLI is
|
|
23
|
+
* the one component a deploy cannot upgrade — so it stays, always mirroring
|
|
24
|
+
* the ACTIVE (most recently logged-in or explicitly picked) project. An old
|
|
25
|
+
* version keeps working with that project; a new one resolves by REPO.
|
|
26
|
+
*
|
|
27
|
+
* WHICH CREDENTIAL A START USES — resolution, in order:
|
|
28
|
+
* --fleet / FLOWVIANT_FLEET the operator said, verbatim (config.mjs).
|
|
29
|
+
* --project <name|id> an entry, named without a prompt.
|
|
30
|
+
* the entry BOUND to this repo repoRoot recorded at login/confirm, compared
|
|
31
|
+
* by realpath — the no-ambiguity path.
|
|
32
|
+
* one unbound entry served, with a one-time TTY confirm that
|
|
33
|
+
* binds it (headless keeps the old behaviour —
|
|
34
|
+
* a systemd restart must not hang on a prompt).
|
|
35
|
+
* anything else a CHOICE, never a guess: the CLI lists every
|
|
36
|
+
* stored project and asks (cli.mjs), or — with
|
|
37
|
+
* no TTY — refuses in words that name them all.
|
|
38
|
+
*
|
|
39
|
+
* The repoRoot binding is a SAFETY line, not bookkeeping: serving project X
|
|
40
|
+
* from a repo that is not X's checkout materializes X's session worktrees and
|
|
41
|
+
* decrypted env inside the wrong repository. Binding happens only at moments a
|
|
42
|
+
* HUMAN was present (login, an answered confirm, an explicit pick) — never
|
|
43
|
+
* silently on a headless start, where cementing the wrong repo would make the
|
|
44
|
+
* mistake permanent.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { execFileSync } from 'node:child_process';
|
|
48
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
49
|
+
import { homedir } from 'node:os';
|
|
50
|
+
import { join } from 'node:path';
|
|
51
|
+
|
|
52
|
+
const CRED_DIR = join(homedir(), '.flowviant');
|
|
53
|
+
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
54
|
+
|
|
55
|
+
function readFile() {
|
|
56
|
+
try {
|
|
57
|
+
const v = JSON.parse(readFileSync(CRED_FILE, 'utf8'));
|
|
58
|
+
return v && typeof v === 'object' ? v : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Atomic write (tmp + rename), 0600 — this file is credentials. */
|
|
65
|
+
function writeFile(v) {
|
|
66
|
+
mkdirSync(CRED_DIR, { recursive: true });
|
|
67
|
+
const tmp = `${CRED_FILE}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(tmp, JSON.stringify(v, null, 2), { mode: 0o600 });
|
|
69
|
+
renameSync(tmp, CRED_FILE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
|
|
73
|
+
export function detectRepoRoot(cwd = process.cwd()) {
|
|
74
|
+
try {
|
|
75
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
76
|
+
cwd,
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
}).trim();
|
|
81
|
+
return root ? realpathSync(root) : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Same directory whatever it is spelled as — the instance.mjs rule. */
|
|
88
|
+
function samePath(a, b) {
|
|
89
|
+
if (!a || !b) return false;
|
|
90
|
+
const norm = (v) => {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(v);
|
|
93
|
+
} catch {
|
|
94
|
+
return String(v).replace(/\/+$/, '');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return norm(a) === norm(b);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every stored project, NORMALIZED: the v2 map, plus the legacy trio surfaced
|
|
102
|
+
* as an entry when no map row carries its token (a pre-0.55.0 file is one
|
|
103
|
+
* project with no name and no binding — real, and listed as such). `active`
|
|
104
|
+
* marks the entry the legacy mirror points at.
|
|
105
|
+
*/
|
|
106
|
+
export function listStoredProjects() {
|
|
107
|
+
const f = readFile();
|
|
108
|
+
if (!f) return [];
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const [projectId, e] of Object.entries(f.projects ?? {})) {
|
|
111
|
+
if (!e || typeof e.fleetToken !== 'string' || !e.fleetToken) continue;
|
|
112
|
+
out.set(projectId, {
|
|
113
|
+
projectId,
|
|
114
|
+
fleetToken: e.fleetToken,
|
|
115
|
+
mcpUrl: e.mcpUrl ?? null,
|
|
116
|
+
name: typeof e.name === 'string' && e.name ? e.name : null,
|
|
117
|
+
repoRoot: typeof e.repoRoot === 'string' && e.repoRoot ? e.repoRoot : null,
|
|
118
|
+
savedAt: e.savedAt ?? null,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (typeof f.fleetToken === 'string' && f.fleetToken && typeof f.projectId === 'string' && f.projectId && !out.has(f.projectId)) {
|
|
122
|
+
out.set(f.projectId, {
|
|
123
|
+
projectId: f.projectId,
|
|
124
|
+
fleetToken: f.fleetToken,
|
|
125
|
+
mcpUrl: f.mcpUrl ?? null,
|
|
126
|
+
name: null,
|
|
127
|
+
repoRoot: null,
|
|
128
|
+
savedAt: null,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return [...out.values()].map((e) => ({ ...e, active: e.projectId === f.projectId }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a project is CALLED on a terminal: its name, or an id you can grep. */
|
|
135
|
+
export function projectLabel(e) {
|
|
136
|
+
return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mutate(fn) {
|
|
140
|
+
const f = readFile() ?? {};
|
|
141
|
+
if (!f.projects || typeof f.projects !== 'object') f.projects = {};
|
|
142
|
+
// Surface a legacy trio into the map before any edit, so nothing loses it.
|
|
143
|
+
if (typeof f.fleetToken === 'string' && f.fleetToken && typeof f.projectId === 'string' && f.projectId && !f.projects[f.projectId]) {
|
|
144
|
+
f.projects[f.projectId] = { fleetToken: f.fleetToken, mcpUrl: f.mcpUrl ?? null };
|
|
145
|
+
}
|
|
146
|
+
fn(f);
|
|
147
|
+
writeFile(f);
|
|
148
|
+
return f;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A fresh login: upsert the entry AND point the legacy mirror at it. */
|
|
152
|
+
export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
|
|
153
|
+
mutate((f) => {
|
|
154
|
+
f.projects[projectId] = {
|
|
155
|
+
...(f.projects[projectId] ?? {}),
|
|
156
|
+
fleetToken,
|
|
157
|
+
mcpUrl: mcpUrl ?? null,
|
|
158
|
+
...(name ? { name } : {}),
|
|
159
|
+
...(repoRoot ? { repoRoot } : {}),
|
|
160
|
+
savedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
f.fleetToken = fleetToken;
|
|
163
|
+
f.projectId = projectId;
|
|
164
|
+
f.mcpUrl = mcpUrl ?? null;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An explicit human pick: repoint the legacy mirror, and — when the pick was
|
|
169
|
+
* made standing in a repo — bind the entry there. A pick IS the confirmation;
|
|
170
|
+
* binding on anything less would cement a guess. */
|
|
171
|
+
export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
|
|
172
|
+
mutate((f) => {
|
|
173
|
+
const e = f.projects[projectId];
|
|
174
|
+
if (!e) return;
|
|
175
|
+
if (bindRepoRoot) e.repoRoot = bindRepoRoot;
|
|
176
|
+
f.fleetToken = e.fleetToken;
|
|
177
|
+
f.projectId = projectId;
|
|
178
|
+
f.mcpUrl = e.mcpUrl ?? null;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function bindStoredRepo(projectId, repoRoot) {
|
|
183
|
+
if (!repoRoot) return;
|
|
184
|
+
mutate((f) => {
|
|
185
|
+
if (f.projects[projectId]) f.projects[projectId].repoRoot = repoRoot;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The roster names the project on every poll; remember it so the picker and
|
|
190
|
+
* `flowviant projects` can say a NAME instead of an id. */
|
|
191
|
+
export function setStoredProjectName(projectId, name) {
|
|
192
|
+
if (!projectId || typeof name !== 'string' || !name) return;
|
|
193
|
+
const entries = listStoredProjects();
|
|
194
|
+
const e = entries.find((x) => x.projectId === projectId);
|
|
195
|
+
if (!e || e.name === name) return;
|
|
196
|
+
mutate((f) => {
|
|
197
|
+
if (f.projects[projectId]) f.projects[projectId].name = name;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Match `--project <ref>` against the store: exact id, id prefix (≥6), or
|
|
202
|
+
* case-insensitive name. Ambiguity is an error, never a coin flip. */
|
|
203
|
+
export function matchStoredProject(ref) {
|
|
204
|
+
const entries = listStoredProjects();
|
|
205
|
+
const q = String(ref ?? '').trim();
|
|
206
|
+
if (!q) return { error: 'empty --project value' };
|
|
207
|
+
const byId = entries.filter(
|
|
208
|
+
(e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
|
|
209
|
+
);
|
|
210
|
+
const byName = entries.filter((e) => e.name && e.name.toLowerCase() === q.toLowerCase());
|
|
211
|
+
const hits = byId.length > 0 ? byId : byName;
|
|
212
|
+
if (hits.length === 1) return { entry: hits[0] };
|
|
213
|
+
if (hits.length > 1) return { error: `"${q}" matches ${hits.length} stored projects — use the full project id` };
|
|
214
|
+
return { error: `no stored project matches "${q}"` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve which stored credential this invocation should use. PURE over the
|
|
219
|
+
* store + argv + cwd; every refusal and prompt above it is built from what
|
|
220
|
+
* this returns. Shapes:
|
|
221
|
+
* { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
|
|
222
|
+
* { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
|
|
223
|
+
* { none: true }
|
|
224
|
+
* { error }
|
|
225
|
+
*
|
|
226
|
+
* Deliberately NO mirror fallback once more than one project is stored: the
|
|
227
|
+
* mirror is whichever login happened last, and serving it because it was
|
|
228
|
+
* recent is exactly the calendar-says-skadooble surprise this file exists to
|
|
229
|
+
* end. Two projects means the answer is a QUESTION (or --project, or the
|
|
230
|
+
* repo binding), never recency.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()) {
|
|
233
|
+
const i = argv.indexOf('--project');
|
|
234
|
+
const projectArg = i >= 0 ? argv[i + 1] : undefined;
|
|
235
|
+
if (projectArg !== undefined) {
|
|
236
|
+
const m = matchStoredProject(projectArg);
|
|
237
|
+
return m.entry ? { entry: m.entry, source: 'project-flag' } : { error: m.error };
|
|
238
|
+
}
|
|
239
|
+
const entries = listStoredProjects();
|
|
240
|
+
if (entries.length === 0) return { none: true };
|
|
241
|
+
const repoRoot = detectRepoRoot(cwd);
|
|
242
|
+
|
|
243
|
+
if (repoRoot) {
|
|
244
|
+
const bound = entries.filter((e) => samePath(e.repoRoot, repoRoot));
|
|
245
|
+
if (bound.length === 1) return { entry: bound[0], source: 'repo', repoRoot };
|
|
246
|
+
if (bound.length > 1) return { choices: bound, reason: 'multiple-bound', repoRoot };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ONE stored project with no binding: the pre-0.55.0 world. Serve it — a
|
|
250
|
+
// headless restart must not hang — but flag it, so a TTY start asks ONCE and
|
|
251
|
+
// binds, which is what turns "it said skadooble in my calendar repo" into a
|
|
252
|
+
// question instead of a surprise.
|
|
253
|
+
if (entries.length === 1 && !entries[0].repoRoot) {
|
|
254
|
+
return { entry: entries[0], source: 'only', needsConfirm: Boolean(repoRoot), repoRoot };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { choices: entries, reason: repoRoot ? 'no-match' : 'outside-repo', repoRoot };
|
|
258
|
+
}
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readFileSync, existsSync } from 'node:fs';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
16
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
17
17
|
import { c, note, ok, warn } from './ui.mjs';
|
|
18
18
|
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
19
|
|
|
@@ -167,7 +167,15 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
167
167
|
void (async () => {
|
|
168
168
|
let beat = null;
|
|
169
169
|
try {
|
|
170
|
-
|
|
170
|
+
// `instance` names THIS PROCESS. The pubkey cannot: it is the env
|
|
171
|
+
// keypair read from one file per home directory, so two daemons on one
|
|
172
|
+
// box share it and a pubkey-only read-back let both "win" the claim
|
|
173
|
+
// and run the same deploy twice concurrently.
|
|
174
|
+
const claimed = await post('deploy-claim', {
|
|
175
|
+
jobId: job.id,
|
|
176
|
+
pubkey: ctx.myPubB64(),
|
|
177
|
+
instance: DAEMON_INSTANCE,
|
|
178
|
+
}).catch(() => null);
|
|
171
179
|
if (!claimed?.claimed) return; // another daemon won the claim
|
|
172
180
|
// Keep the claim fresh while we run — a long deploy must never be
|
|
173
181
|
// re-queued out from under us (that would double-deploy). The async
|
package/bin/lib/env-cli.mjs
CHANGED
|
@@ -105,7 +105,11 @@ async function readSecretFromStdin(promptText) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function runEnvCommand(args) {
|
|
108
|
-
if (!FLEET_TOKEN)
|
|
108
|
+
if (!FLEET_TOKEN)
|
|
109
|
+
die(
|
|
110
|
+
'no credential resolves here — run `flowviant login`, or run this inside the ' +
|
|
111
|
+
"project's own repo / pass `--project <name|id>` (`flowviant projects` lists them)."
|
|
112
|
+
);
|
|
109
113
|
await sodium.ready;
|
|
110
114
|
await ensureKeypair();
|
|
111
115
|
const cmd = args[0];
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
REFRESH_BEFORE_SECONDS,
|
|
32
32
|
LIVE,
|
|
33
33
|
AUTO_UPDATE,
|
|
34
|
+
CREDENTIAL,
|
|
34
35
|
} from './config.mjs';
|
|
36
|
+
import { projectLabel, setStoredProjectName } from './credentials.mjs';
|
|
35
37
|
import { handleVersionSignal } from './update.mjs';
|
|
36
38
|
import {
|
|
37
39
|
git,
|
|
@@ -322,6 +324,14 @@ export async function runFleetDaemon() {
|
|
|
322
324
|
const repoRoot = repoRootOrDie();
|
|
323
325
|
const baseRef = detectBaseRef(repoRoot);
|
|
324
326
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
327
|
+
// WHICH PROJECT, before anything connects — the roster names it again a few
|
|
328
|
+
// seconds later with the server's word, but "which project is this daemon
|
|
329
|
+
// about to serve" must not require a network round trip to answer. Only when
|
|
330
|
+
// the credential came from the STORE: a --fleet/env token names no project
|
|
331
|
+
// until the roster does.
|
|
332
|
+
if (CREDENTIAL?.entry) {
|
|
333
|
+
info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
|
|
334
|
+
}
|
|
325
335
|
info(`repo · ${repoRoot}`);
|
|
326
336
|
info(`base · ${baseRef}`);
|
|
327
337
|
info(`server · ${FLEET_URL}`);
|
|
@@ -782,7 +792,7 @@ export async function runFleetDaemon() {
|
|
|
782
792
|
// Direct enqueue = immediacy; the server's durable regroundJobs list
|
|
783
793
|
// (created by merge-done above, cleared by our reground-done report)
|
|
784
794
|
// is the restart-safe backstop — dedup'd here by groundedIntents.
|
|
785
|
-
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
|
|
795
|
+
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages, job.shas);
|
|
786
796
|
} else if (failedReason) {
|
|
787
797
|
// Report into the thread (server narrates + re-arms the merge
|
|
788
798
|
// button + notifies) — the job disappears from the roster.
|
|
@@ -926,7 +936,7 @@ export async function runFleetDaemon() {
|
|
|
926
936
|
wikiQueue.push({ type: 'sweep' });
|
|
927
937
|
void drainWiki();
|
|
928
938
|
};
|
|
929
|
-
const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
|
|
939
|
+
const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
|
|
930
940
|
if (!intentId || groundedIntents.has(intentId)) return;
|
|
931
941
|
groundedIntents.add(intentId);
|
|
932
942
|
wikiQueue.push({
|
|
@@ -939,6 +949,13 @@ export async function runFleetDaemon() {
|
|
|
939
949
|
// frontmatter file list has drifted, or that document a concept rather
|
|
940
950
|
// than a directory.
|
|
941
951
|
dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
|
|
952
|
+
// THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
|
|
953
|
+
// Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
|
|
954
|
+
// shas on every reground job, this function never stored them, and the
|
|
955
|
+
// drain's `task.shas` was undefined on every job — so the re-ground
|
|
956
|
+
// "revived" on 2026-08-22 retried three times against nothing and gave
|
|
957
|
+
// up, on a console nobody reads, on every single ship.
|
|
958
|
+
shas: Array.isArray(shas) ? shas : [],
|
|
942
959
|
});
|
|
943
960
|
void drainWiki();
|
|
944
961
|
};
|
|
@@ -1185,6 +1202,13 @@ export async function runFleetDaemon() {
|
|
|
1185
1202
|
// crash BEFORE this line leaves the job listed for a retry.
|
|
1186
1203
|
regroundAttempts.delete(task.intentId);
|
|
1187
1204
|
await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
|
|
1205
|
+
// The dedup was DAEMON-LIFETIME, which wedged a reopened card: its
|
|
1206
|
+
// second ship writes a fresh durable job, this Set still holds the
|
|
1207
|
+
// taskId, enqueueReground refuses it on every poll forever, and
|
|
1208
|
+
// the never-consumed job churns the wiki-writer lease until a
|
|
1209
|
+
// restart. The job is consumed now, so the guard has done its work;
|
|
1210
|
+
// a FUTURE ship of the same card is new work, not a duplicate.
|
|
1211
|
+
groundedIntents.delete(task.intentId);
|
|
1188
1212
|
}
|
|
1189
1213
|
} catch (e) {
|
|
1190
1214
|
warn(`wiki ${task.type} failed: ${e.message}`);
|
|
@@ -1269,7 +1293,13 @@ export async function runFleetDaemon() {
|
|
|
1269
1293
|
if (e.auth) {
|
|
1270
1294
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
1271
1295
|
teardown();
|
|
1272
|
-
|
|
1296
|
+
// EXIT 0, for the same reason the commanded-stop path does: a revoked
|
|
1297
|
+
// credential is a terminal, asked-for-by-someone state, and a relaunch
|
|
1298
|
+
// can never fix it. Under `Restart=on-failure` a nonzero code has
|
|
1299
|
+
// systemd relaunch the daemon immediately — a restart loop hammering
|
|
1300
|
+
// dead-credential polls, fighting the Disconnect that revoked it, and
|
|
1301
|
+
// ending in a unit that reads as a crash rather than a kill.
|
|
1302
|
+
process.exit(0);
|
|
1273
1303
|
}
|
|
1274
1304
|
warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
|
|
1275
1305
|
await sleep(RECONCILE_SECONDS);
|
|
@@ -1285,6 +1315,11 @@ export async function runFleetDaemon() {
|
|
|
1285
1315
|
`${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
|
|
1286
1316
|
);
|
|
1287
1317
|
note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
|
|
1318
|
+
// Remember the NAME beside the stored credential, so the picker and
|
|
1319
|
+
// `flowviant projects` can say "skadooble" instead of an id — a
|
|
1320
|
+
// credential saved before the server sent names backfills here. No-op
|
|
1321
|
+
// for a --fleet/env token (nothing stored to annotate).
|
|
1322
|
+
setStoredProjectName(roster.project.id, roster.project.name);
|
|
1288
1323
|
}
|
|
1289
1324
|
}
|
|
1290
1325
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
@@ -1304,6 +1339,17 @@ export async function runFleetDaemon() {
|
|
|
1304
1339
|
: 'stopped by Flowviant — no reason given.'
|
|
1305
1340
|
);
|
|
1306
1341
|
note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
1342
|
+
// FLUSH the settle queue first, bounded: a queued-but-undelivered report
|
|
1343
|
+
// is a COMPLETED turn whose side effects already happened, and dropping
|
|
1344
|
+
// it re-runs the whole turn on the next start — quota spent twice and
|
|
1345
|
+
// every card write doubled. This path is async (unlike the signal
|
|
1346
|
+
// handlers, which cannot await), so the stop can afford five seconds of
|
|
1347
|
+
// delivery before it obeys.
|
|
1348
|
+
try {
|
|
1349
|
+
await Promise.race([flushWorkReports(), sleep(5)]);
|
|
1350
|
+
} catch {
|
|
1351
|
+
/* undelivered reports re-run; delivering them was best-effort */
|
|
1352
|
+
}
|
|
1307
1353
|
// teardown() is NOT optional on this path. Detached preview tunnels
|
|
1308
1354
|
// survive this process BY DESIGN, so exiting without it strands a public
|
|
1309
1355
|
// hostname pointed into a worktree until somebody reboots the box — which
|
|
@@ -1421,7 +1467,7 @@ export async function runFleetDaemon() {
|
|
|
1421
1467
|
for (const j of roster.regroundJobs ?? []) {
|
|
1422
1468
|
const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
|
|
1423
1469
|
if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
|
|
1424
|
-
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
|
|
1470
|
+
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
|
|
1425
1471
|
}
|
|
1426
1472
|
void drainWiki();
|
|
1427
1473
|
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -413,15 +413,16 @@ function stillTheHolder(holder) {
|
|
|
413
413
|
// `process.argv[1] || ''` — and matching on '' would match every process
|
|
414
414
|
// alive, so it takes the same road as a missing one.
|
|
415
415
|
if (!want) return startedAroundLockWrite(holder);
|
|
416
|
+
let cmdline;
|
|
416
417
|
try {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
418
|
+
cmdline =
|
|
419
|
+
platform() === 'linux'
|
|
420
|
+
? readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ')
|
|
421
|
+
: execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
422
|
+
encoding: 'utf8',
|
|
423
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
424
|
+
timeout: 3000,
|
|
425
|
+
});
|
|
425
426
|
} catch {
|
|
426
427
|
// NOT `false`. takeOverFrom already returned early if the pid were gone, so
|
|
427
428
|
// reaching here means the process is alive and we could not READ it —
|
|
@@ -429,6 +430,45 @@ function stillTheHolder(holder) {
|
|
|
429
430
|
// here is what made the refusal claim the pid belonged to somebody else.
|
|
430
431
|
return null;
|
|
431
432
|
}
|
|
433
|
+
if (!cmdline.includes(want)) return false;
|
|
434
|
+
// AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
|
|
435
|
+
// entry path under a global install, so "cmdline contains this cli.mjs"
|
|
436
|
+
// proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
|
|
437
|
+
// lock" — and a crashed daemon's pid recycled to a SIBLING project's live
|
|
438
|
+
// daemon passed it, which let a same-repo takeover SIGTERM a different
|
|
439
|
+
// project's machine. Every 0.54.0+ lock also carries `startedAt`, the
|
|
440
|
+
// process's own witness to when it began, so when it is present the start
|
|
441
|
+
// time must agree too. `null` (could not measure — hidepid, no ps, a lock
|
|
442
|
+
// with no startedAt) falls back to the entry match alone, exactly the
|
|
443
|
+
// pre-check behaviour: refusing on ignorance here would re-brick takeover
|
|
444
|
+
// on the hosts that hide /proc.
|
|
445
|
+
const around = startedAroundLockWrite(holder);
|
|
446
|
+
return around === false ? false : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Unlink a lock file ONLY while it still names the pid the caller decided
|
|
451
|
+
* about (or nothing readable). Every rmSync of a lock outside the ppid-adopt
|
|
452
|
+
* path goes through this: between "I proved pid N is dead/stale" and the
|
|
453
|
+
* unlink, a concurrently starting daemon can clear the file itself and
|
|
454
|
+
* wx-create its own — and an unconditional rm then deletes a LIVE daemon's
|
|
455
|
+
* lock, leaving it running unguarded, which is the one condition this module
|
|
456
|
+
* exists to prevent. The read-then-rm gap that remains is microseconds against
|
|
457
|
+
* the seconds-wide window it closes.
|
|
458
|
+
*
|
|
459
|
+
* Returns false when the file now names a DIFFERENT pid — a handover the
|
|
460
|
+
* caller must treat as "not mine to clear" — true otherwise (removed, already
|
|
461
|
+
* gone, or best-effort failed into acquire's next pass).
|
|
462
|
+
*/
|
|
463
|
+
function rmLockIfStill(path, pid) {
|
|
464
|
+
const cur = readHolder(path);
|
|
465
|
+
if (cur && cur.pid !== pid) return false;
|
|
466
|
+
try {
|
|
467
|
+
rmSync(path, { force: true });
|
|
468
|
+
} catch {
|
|
469
|
+
/* best-effort; a stale file is cleared by the next acquire */
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
432
472
|
}
|
|
433
473
|
|
|
434
474
|
/** Blocking, because this runs before there is an event loop worth yielding to
|
|
@@ -530,11 +570,11 @@ function standDown(holder, path, log) {
|
|
|
530
570
|
sleep(400);
|
|
531
571
|
}
|
|
532
572
|
|
|
533
|
-
// A SIGKILLed daemon never ran its release(), so clear what it left
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return { failed: '
|
|
573
|
+
// A SIGKILLed daemon never ran its release(), so clear what it left — but
|
|
574
|
+
// only if the file still names the pid we stood down: in the gap since the
|
|
575
|
+
// last read a fresh daemon may have cleared it and taken the lock itself.
|
|
576
|
+
if (!rmLockIfStill(path, holder.pid)) {
|
|
577
|
+
return { failed: 'another daemon took the lock while it was being cleared — try again in a moment' };
|
|
538
578
|
}
|
|
539
579
|
return null;
|
|
540
580
|
}
|
|
@@ -564,7 +604,13 @@ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
|
564
604
|
};
|
|
565
605
|
}
|
|
566
606
|
if (identified === false) {
|
|
567
|
-
|
|
607
|
+
// MEASURED: the lock's writer is gone and the pid now belongs to something
|
|
608
|
+
// else. That is a STALE LOCK, not an unremovable holder — refusing here
|
|
609
|
+
// used to brick every start after an OOM-kill or reboot recycled the pid
|
|
610
|
+
// to any live process, until a human deleted ~/.flowviant/daemon-*.lock by
|
|
611
|
+
// hand. Nothing is signalled (the process is a stranger); the caller
|
|
612
|
+
// clears the corpse the same way it clears a dead pid's.
|
|
613
|
+
return { stale: true };
|
|
568
614
|
}
|
|
569
615
|
if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
|
|
570
616
|
return {
|
|
@@ -608,9 +654,19 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
608
654
|
// quietly.
|
|
609
655
|
if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
|
|
610
656
|
log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
|
|
611
|
-
|
|
612
|
-
|
|
657
|
+
// The OPTIONS ride along — this call used to drop them, so a deliberate
|
|
658
|
+
// `flowviant --takeover-downgrade` against a newer neighbour printed
|
|
659
|
+
// "--takeover-downgrade if you mean it" at somebody who had already
|
|
660
|
+
// typed it.
|
|
661
|
+
const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log, { allowDowngrade });
|
|
662
|
+
if (bad?.stale) {
|
|
663
|
+
// The neighbour's lock is a corpse wearing a recycled pid — clear it (if
|
|
664
|
+
// it still names that pid) and carry on to our own lock.
|
|
665
|
+
log?.(`pid ${neighbour.pid} is no longer a daemon — clearing its stale lock.`);
|
|
666
|
+
rmLockIfStill(neighbourLockPath(neighbour, path), neighbour.pid);
|
|
667
|
+
} else if (bad) {
|
|
613
668
|
return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
|
|
669
|
+
}
|
|
614
670
|
}
|
|
615
671
|
|
|
616
672
|
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
@@ -623,11 +679,20 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
623
679
|
if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
|
|
624
680
|
const holder = readHolder(path);
|
|
625
681
|
if (!holder || !alive(holder.pid)) {
|
|
626
|
-
// A crashed daemon's leftover. Clear it and take it on the next pass
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
682
|
+
// A crashed daemon's leftover. Clear it and take it on the next pass —
|
|
683
|
+
// ownership-verified, because a concurrent start may have cleared and
|
|
684
|
+
// re-created it in the gap since our read.
|
|
685
|
+
if (holder) rmLockIfStill(path, holder.pid);
|
|
686
|
+
else {
|
|
687
|
+
// Unreadable content: re-read before clearing, so a half-written
|
|
688
|
+
// record a peer is writing RIGHT NOW is not deleted mid-write.
|
|
689
|
+
const again = readHolder(path);
|
|
690
|
+
if (again && alive(again.pid)) continue; // it finished writing — a real holder now
|
|
691
|
+
try {
|
|
692
|
+
rmSync(path, { force: true });
|
|
693
|
+
} catch {
|
|
694
|
+
return { ok: true, release: () => {}, unguarded: true };
|
|
695
|
+
}
|
|
631
696
|
}
|
|
632
697
|
continue;
|
|
633
698
|
}
|
|
@@ -653,10 +718,28 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
653
718
|
const wanted = force || (here && !noTakeover);
|
|
654
719
|
if (wanted) {
|
|
655
720
|
const bad = takeOverFrom(holder, path, log, { allowDowngrade });
|
|
721
|
+
if (bad?.stale) {
|
|
722
|
+
// Measured: the lock's writer is gone and its pid was recycled to a
|
|
723
|
+
// stranger. A corpse is cleared, never "refused" — refusing bricked
|
|
724
|
+
// every start after a reboot handed the pid to any live process.
|
|
725
|
+
log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
|
|
726
|
+
rmLockIfStill(path, holder.pid);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
656
729
|
if (bad)
|
|
657
730
|
return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
|
|
658
731
|
continue; // the file is gone — the next pass takes it
|
|
659
732
|
}
|
|
733
|
+
// Before refusing on a different-repo holder, make sure it IS one: a
|
|
734
|
+
// stale lock whose pid was recycled to any live process would otherwise
|
|
735
|
+
// refuse this credential's start forever, naming a "daemon" that is a
|
|
736
|
+
// stranger. Only the MEASURED verdict clears; null (could not look)
|
|
737
|
+
// still refuses, because ignorance must not delete a lock.
|
|
738
|
+
if (stillTheHolder(holder) === false) {
|
|
739
|
+
log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
|
|
740
|
+
rmLockIfStill(path, holder.pid);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
660
743
|
return { ok: false, holder, sameRepo: here };
|
|
661
744
|
}
|
|
662
745
|
try {
|
package/bin/lib/login.mjs
CHANGED
|
@@ -6,33 +6,15 @@
|
|
|
6
6
|
* plain `flowviant` just runs — no token, no env var.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
9
|
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
10
|
+
import { saveLogin, detectRepoRoot, projectLabel } from './credentials.mjs';
|
|
13
11
|
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
14
12
|
import { sleep } from './claude.mjs';
|
|
15
13
|
|
|
16
|
-
const CRED_DIR = join(homedir(), '.flowviant');
|
|
17
|
-
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
18
14
|
const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
|
|
19
15
|
const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
|
|
20
16
|
const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
21
17
|
|
|
22
|
-
/** The locally-stored credential from a prior `login`, or null. Read by config. */
|
|
23
|
-
export function readStoredCredential() {
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(readFileSync(CRED_FILE, 'utf8'));
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function store(cred) {
|
|
32
|
-
mkdirSync(CRED_DIR, { recursive: true });
|
|
33
|
-
writeFileSync(CRED_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
18
|
async function post(url, body) {
|
|
37
19
|
const res = await fetch(url, {
|
|
38
20
|
method: 'POST',
|
|
@@ -74,8 +56,28 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
74
56
|
continue; // transient — keep polling
|
|
75
57
|
}
|
|
76
58
|
if (poll.status === 'approved') {
|
|
77
|
-
|
|
78
|
-
|
|
59
|
+
// `machineToken` is the wire's new name; `fleetToken` is the one every
|
|
60
|
+
// published daemon reads. The server dual-sends until DAEMON_MIN clears
|
|
61
|
+
// the release that reads the new one (0.54.2+) — reading both here is
|
|
62
|
+
// what makes retiring the old key possible at all.
|
|
63
|
+
//
|
|
64
|
+
// BOUND to the repo the login was run in: a login is the one moment we
|
|
65
|
+
// know for certain which checkout this project means, and the binding is
|
|
66
|
+
// what lets a multi-project VM resolve `npx flowviant` by DIRECTORY
|
|
67
|
+
// instead of by whichever login happened last.
|
|
68
|
+
const repoRoot = detectRepoRoot();
|
|
69
|
+
const entry = {
|
|
70
|
+
fleetToken: poll.machineToken ?? poll.fleetToken,
|
|
71
|
+
projectId: poll.projectId,
|
|
72
|
+
mcpUrl: poll.mcpUrl,
|
|
73
|
+
name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
|
|
74
|
+
repoRoot,
|
|
75
|
+
};
|
|
76
|
+
saveLogin(entry);
|
|
77
|
+
ok(
|
|
78
|
+
`connected to ${c.bold(projectLabel(entry))}` +
|
|
79
|
+
`${repoRoot ? ` for ${c.dim(repoRoot)}` : ''} — saved to ~/.flowviant/credentials.json`
|
|
80
|
+
);
|
|
79
81
|
// The daemon starts right here unless the caller opted out; telling
|
|
80
82
|
// someone to run a second command was the step that got missed, since by
|
|
81
83
|
// this point they are looking at the browser, not this terminal.
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -23,8 +23,9 @@ const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
|
|
|
23
23
|
export async function runMcpCommand(args = []) {
|
|
24
24
|
if (!FLEET_TOKEN) {
|
|
25
25
|
console.error(
|
|
26
|
-
'error: no credential. Run `flowviant login` first —
|
|
27
|
-
'
|
|
26
|
+
'error: no credential resolves here. Run `flowviant login` first — or, with\n' +
|
|
27
|
+
'several projects connected on this box, run this inside the project\'s own\n' +
|
|
28
|
+
'repo or pass `--project <name|id>` (`flowviant projects` lists them).'
|
|
28
29
|
);
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -228,9 +228,25 @@ function mutateRegistry(fn) {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
|
|
232
|
+
function processAlive(pid) {
|
|
233
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid, 0);
|
|
236
|
+
return true;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return e.code === 'EPERM';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
231
242
|
function recordPreviewPid(pid, sig) {
|
|
232
243
|
if (!pid) return;
|
|
233
|
-
|
|
244
|
+
// `owner` is the DAEMON that spawned it. The registry is shared by design —
|
|
245
|
+
// two daemons serving two projects both write here — so without an owner a
|
|
246
|
+
// starting daemon reaped its PEER's live tunnels: killed them, wiped their
|
|
247
|
+
// entries, and the peer kept heartbeating a URL that 530s (its probe watches
|
|
248
|
+
// the origin port, which was still alive).
|
|
249
|
+
mutateRegistry((list) => [...list, { pid, sig, owner: process.pid }]);
|
|
234
250
|
}
|
|
235
251
|
|
|
236
252
|
function forgetPreviewPid(pid) {
|
|
@@ -253,12 +269,21 @@ function stillOurs(pid, sig) {
|
|
|
253
269
|
}
|
|
254
270
|
|
|
255
271
|
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
256
|
-
* Call once at daemon startup, before any work begins.
|
|
272
|
+
* Call once at daemon startup, before any work begins.
|
|
273
|
+
*
|
|
274
|
+
* ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
|
|
275
|
+
* peer serving another project (or to the process we are replacing, whose
|
|
276
|
+
* own teardown handles it) — killing those and wiping their entries was a
|
|
277
|
+
* peer daemon's startup silently breaking every live share on the box. Only
|
|
278
|
+
* the entries this pass handled are removed; a peer's records survive. */
|
|
257
279
|
export function reapOrphanPreviews(log) {
|
|
258
280
|
const list = readRegistry();
|
|
259
281
|
if (list.length === 0) return;
|
|
260
282
|
let killed = 0;
|
|
261
|
-
|
|
283
|
+
const handled = new Set();
|
|
284
|
+
for (const { pid, sig, owner } of list) {
|
|
285
|
+
if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
|
|
286
|
+
handled.add(pid);
|
|
262
287
|
if (!stillOurs(pid, sig)) continue;
|
|
263
288
|
try {
|
|
264
289
|
process.kill(-pid, 'SIGKILL'); // whole group
|
|
@@ -272,7 +297,7 @@ export function reapOrphanPreviews(log) {
|
|
|
272
297
|
}
|
|
273
298
|
}
|
|
274
299
|
}
|
|
275
|
-
mutateRegistry(() =>
|
|
300
|
+
if (handled.size) mutateRegistry((cur) => cur.filter((e) => !handled.has(e.pid)));
|
|
276
301
|
if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
|
|
277
302
|
}
|
|
278
303
|
|
|
@@ -295,8 +320,35 @@ const TAIL_BYTES = 2000;
|
|
|
295
320
|
* cloudflared happily outlives a dead dev server and the gate answers a dead
|
|
296
321
|
* origin with 502, so without this the product would report "live" over a 502 —
|
|
297
322
|
* Flowviant asserting a state it never measured.
|
|
323
|
+
*
|
|
324
|
+
* `stillServing` (optional, async → boolean) is the ATTRIBUTION re-check the
|
|
325
|
+
* probe runs instead of a bare TCP connect. Ports are global to a box and a
|
|
326
|
+
* worktree is not: when the driver's dev server dies and anything else — a
|
|
327
|
+
* teammate's worktree, a database — binds the same number, a bare
|
|
328
|
+
* `isListening` keeps the probe green and the existing URL+password serve the
|
|
329
|
+
* NEW process, outside every consent gate. The caller passes the same
|
|
330
|
+
* `listenersIn(worktree)` check the open path uses, so "the origin is alive"
|
|
331
|
+
* keeps meaning "THIS session's origin".
|
|
332
|
+
*
|
|
333
|
+
* `onAbuse` fires when the gate closes itself after repeated failed password
|
|
334
|
+
* attempts — AFTER the share is torn down locally — so the caller can report
|
|
335
|
+
* the incident. Without it the abuse close was invisible: the row kept
|
|
336
|
+
* reading "live" until staleness, and endedReason 'abuse' was unreachable.
|
|
337
|
+
*
|
|
338
|
+
* `onTunnelGone` fires when cloudflared exits AFTER the URL was published
|
|
339
|
+
* (quick tunnels are best-effort and do get dropped). The probe cannot see
|
|
340
|
+
* this — it watches the origin — and a daemon that keeps heartbeating a dead
|
|
341
|
+
* hostname confirms "live" over a 530 for up to 8 hours.
|
|
298
342
|
*/
|
|
299
|
-
export async function openTunnel({
|
|
343
|
+
export async function openTunnel({
|
|
344
|
+
port,
|
|
345
|
+
log,
|
|
346
|
+
onDead,
|
|
347
|
+
onAbuse,
|
|
348
|
+
onTunnelGone,
|
|
349
|
+
stillServing,
|
|
350
|
+
probeMs = 20_000,
|
|
351
|
+
}) {
|
|
300
352
|
// Re-validate at the machine. The server checked this port against the last
|
|
301
353
|
// report; reports are up to a minute old and a dev server is a process a
|
|
302
354
|
// human can stop at any moment.
|
|
@@ -337,7 +389,18 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
337
389
|
|
|
338
390
|
// The gate comes up FIRST and the tunnel points at it, never at the origin —
|
|
339
391
|
// so there is no window in which the public hostname is un-gated.
|
|
340
|
-
gate = await startAuthProxy({
|
|
392
|
+
gate = await startAuthProxy({
|
|
393
|
+
targetPort: port,
|
|
394
|
+
log,
|
|
395
|
+
onAbuse: () => {
|
|
396
|
+
stop();
|
|
397
|
+
try {
|
|
398
|
+
onAbuse?.();
|
|
399
|
+
} catch {
|
|
400
|
+
/* the caller's report is best-effort */
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
341
404
|
if (!gate) {
|
|
342
405
|
return { error: 'could not start the password gate for this preview, so nothing was published.' };
|
|
343
406
|
}
|
|
@@ -348,7 +411,11 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
348
411
|
args.push('--http-host-header', 'localhost');
|
|
349
412
|
|
|
350
413
|
tunnel = spawn(cf.bin, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
351
|
-
|
|
414
|
+
// The signature names THIS tunnel's gate port, not the bare word
|
|
415
|
+
// 'cloudflared': the reap matches cmdline.includes(sig), and the generic
|
|
416
|
+
// word would let a recycled pid land on an operator's own unrelated
|
|
417
|
+
// cloudflared and group-SIGKILL it.
|
|
418
|
+
recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
|
|
352
419
|
|
|
353
420
|
return new Promise((resolve) => {
|
|
354
421
|
let settled = false;
|
|
@@ -378,11 +445,19 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
378
445
|
const m = TUNNEL_RE.exec(s);
|
|
379
446
|
if (!m) return;
|
|
380
447
|
|
|
381
|
-
// Watch the ORIGIN
|
|
382
|
-
//
|
|
448
|
+
// Watch the ORIGIN — with the caller's ATTRIBUTION check when it gave
|
|
449
|
+
// one, never a bare port probe: a freed port rebound by another
|
|
450
|
+
// worktree answers a TCP connect exactly like the origin did, and the
|
|
451
|
+
// share would keep serving a process nobody consented to publish.
|
|
383
452
|
probe = setInterval(async () => {
|
|
384
453
|
if (stopped) return;
|
|
385
|
-
|
|
454
|
+
let serving;
|
|
455
|
+
try {
|
|
456
|
+
serving = stillServing ? await stillServing() : await isListening(port);
|
|
457
|
+
} catch {
|
|
458
|
+
serving = false; // an attribution check that errors is not a "yes"
|
|
459
|
+
}
|
|
460
|
+
if (!serving) {
|
|
386
461
|
const dead = onDead;
|
|
387
462
|
stop();
|
|
388
463
|
try {
|
|
@@ -394,6 +469,20 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
394
469
|
}, probeMs);
|
|
395
470
|
if (probe.unref) probe.unref();
|
|
396
471
|
|
|
472
|
+
// The TUNNEL dying after publish (quick tunnels get dropped) is the one
|
|
473
|
+
// exit the probe cannot see. `stopped` guards our own kill: stop() sets
|
|
474
|
+
// it before signalling, so this only fires for a death nobody asked for.
|
|
475
|
+
tunnel.once('close', () => {
|
|
476
|
+
if (stopped) return;
|
|
477
|
+
const gone = onTunnelGone;
|
|
478
|
+
stop();
|
|
479
|
+
try {
|
|
480
|
+
gone?.();
|
|
481
|
+
} catch {
|
|
482
|
+
/* the caller's report is best-effort */
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
397
486
|
finish({ url: m[0], user: gate.user, password: gate.password, stop });
|
|
398
487
|
};
|
|
399
488
|
|
package/bin/lib/work.mjs
CHANGED
|
@@ -492,7 +492,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
492
492
|
/* best-effort */
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
|
-
|
|
495
|
+
// Confirm only a teardown we actually PERFORMED. The stop job is a
|
|
496
|
+
// broadcast — every daemon on the credential gets it — and the one holding
|
|
497
|
+
// nothing used to answer instantly, flipping the row to 'ended' so the
|
|
498
|
+
// real holder was never told to stop and its tunnel outlived every
|
|
499
|
+
// surface. (The server drops mismatched confirms too; this is the copy on
|
|
500
|
+
// the component that can be published ahead of a deploy.) A stop for a
|
|
501
|
+
// tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
|
|
502
|
+
// row reads as over once it goes stale.
|
|
503
|
+
if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
496
504
|
};
|
|
497
505
|
|
|
498
506
|
const processPreviewJobs = (jobs) => {
|
|
@@ -552,6 +560,27 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
552
560
|
livePreviews.delete(sessionId);
|
|
553
561
|
void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
|
|
554
562
|
},
|
|
563
|
+
// ATTRIBUTION rides the probe, not just the open: a freed default
|
|
564
|
+
// port (5173…) rebound by any other process on the box would keep
|
|
565
|
+
// a bare TCP probe green, and the share's URL+password would serve
|
|
566
|
+
// a worktree nobody consented to publish.
|
|
567
|
+
stillServing: async () => listenersIn(wt).some((l) => l.port === port),
|
|
568
|
+
// The gate closed itself after repeated failed passwords. Stored,
|
|
569
|
+
// so the incident is visible — and the entry is dropped so the
|
|
570
|
+
// owner can re-share the port without restarting the daemon.
|
|
571
|
+
onAbuse: () => {
|
|
572
|
+
livePreviews.delete(sessionId);
|
|
573
|
+
void postPreview({ sessionId, ended: true, endedReason: 'abuse' });
|
|
574
|
+
},
|
|
575
|
+
// cloudflared died AFTER publishing (quick tunnels get dropped).
|
|
576
|
+
// Without this the daemon kept heartbeating a hostname that 530s.
|
|
577
|
+
onTunnelGone: () => {
|
|
578
|
+
livePreviews.delete(sessionId);
|
|
579
|
+
void postPreview({
|
|
580
|
+
sessionId,
|
|
581
|
+
error: 'the tunnel dropped — share it again to reopen.',
|
|
582
|
+
});
|
|
583
|
+
},
|
|
555
584
|
});
|
|
556
585
|
if (t.error) {
|
|
557
586
|
await postPreview({ sessionId, error: t.error });
|
|
@@ -1195,6 +1224,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1195
1224
|
// worktree would pull the directory out from under a running turn. Absence
|
|
1196
1225
|
// means "the tab closed"; this is the one other thing it can mean.
|
|
1197
1226
|
const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
|
|
1227
|
+
// A peer-held session's CACHED work token is a claim-bypass: the mint is
|
|
1228
|
+
// the one place the session lease 409s a non-holder, and a token younger
|
|
1229
|
+
// than ~23h skips the mint entirely — so a daemon that lost a lease would
|
|
1230
|
+
// run the next turn anyway, editing the worktree while every MCP call
|
|
1231
|
+
// 401s (the peer's mint rotated the secret). Dropping the cache forces
|
|
1232
|
+
// the next turn through the mint, where the 409 stands it down.
|
|
1233
|
+
for (const id of peers) workTokens.delete(id);
|
|
1198
1234
|
const dir = join(baseDir, 'sessions');
|
|
1199
1235
|
if (!existsSync(dir)) return;
|
|
1200
1236
|
let ids;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.55.0",
|
|
4
|
+
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|