flowviant 0.124.0 → 0.124.1
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 +6 -0
- package/bin/cli.mjs +7 -0
- package/bin/lib/agentInternalWait.mjs +2 -1
- package/bin/lib/agentPlan.mjs +8 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +21 -3
- package/bin/lib/fleet.mjs +19 -6
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectTools.mjs +2 -1
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +6 -1
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +23 -2
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeClaude.mjs +5 -2
- package/bin/lib/runtimeCodex.mjs +2 -1
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/work.mjs +22 -24
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPrecheck.mjs +5 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +76 -9
- package/bin/lib/workAgentTurnOutcome.mjs +9 -3
- package/bin/lib/workAgentTurns.mjs +33 -9
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +6 -1
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +72 -40
- package/bin/lib/workShip.mjs +17 -16
- package/package.json +1 -1
- package/npm-shrinkwrap.json +0 -69
package/README.md
CHANGED
|
@@ -37,6 +37,12 @@ flowviant
|
|
|
37
37
|
|
|
38
38
|
It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
|
|
39
39
|
|
|
40
|
+
Run `flowviant login` from another checkout and choose the same project in the
|
|
41
|
+
browser to add its repository to that board. Project settings → General lists
|
|
42
|
+
the repositories, their remotes and base branches. The project's merge mode
|
|
43
|
+
applies to all of them. Credentials keep the named checkout paths together;
|
|
44
|
+
each directory still belongs to only one project on this machine.
|
|
45
|
+
|
|
40
46
|
Prefer an explicit token? Create a machine credential in the app and pass it directly:
|
|
41
47
|
|
|
42
48
|
```bash
|
package/bin/cli.mjs
CHANGED
|
@@ -80,6 +80,11 @@
|
|
|
80
80
|
// answered here before anything else loads: no config, no update check, no
|
|
81
81
|
// output but the hook's. An unknown hook exits 2, which Claude Code reads as a
|
|
82
82
|
// refusal — the read guard's own rule for a hook that cannot judge.
|
|
83
|
+
if (['__repository', '__repository-mcp'].includes(process.argv[2])) {
|
|
84
|
+
await (await import('./lib/repositoryTool.mjs')).repositoryToolMain(process.argv[2] === '__repository-mcp');
|
|
85
|
+
process.exit(process.exitCode ?? 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
if (process.argv[2] === '__hook') {
|
|
84
89
|
const HOOKS = {
|
|
85
90
|
'read-guard': () => import('./lib/hooks/readGuard.mjs'),
|
|
@@ -279,6 +284,8 @@ async function cleanCommand() {
|
|
|
279
284
|
/* du unavailable — skip the size */
|
|
280
285
|
}
|
|
281
286
|
console.log('note: stop any running flowviant daemon first — in-flight local work is discarded.');
|
|
287
|
+
const { unlockRepositorySnapshots } = await import('./lib/repositoryPlaces.mjs');
|
|
288
|
+
unlockRepositorySnapshots(dir);
|
|
282
289
|
rmSync(dir, { recursive: true, force: true });
|
|
283
290
|
console.log(`cleaned ~/.flowviant/worktrees${size}.`);
|
|
284
291
|
process.exit(0);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* measured failure for the lane's existing one-shot settlement. */
|
|
6
6
|
import { parseTurnResult } from './agentPlan.mjs';
|
|
7
7
|
import { limitLine } from './cliLimit.mjs';
|
|
8
|
+
import { addUsage } from './turnUsage.mjs';
|
|
8
9
|
|
|
9
10
|
export const INTERNAL_WAIT_RECOVERIES = 2;
|
|
10
11
|
export const INTERNAL_WAIT_CONTRACT = `Your own command or test still running is internal work, never a human blocker.
|
|
@@ -58,7 +59,7 @@ export async function runAgentWithInternalWaits(run, options) {
|
|
|
58
59
|
options.onAnswer?.(text);
|
|
59
60
|
},
|
|
60
61
|
onUsage: (usage) => {
|
|
61
|
-
currentUsage = usage;
|
|
62
|
+
currentUsage = addUsage(currentUsage, usage);
|
|
62
63
|
options.onUsage?.(combine());
|
|
63
64
|
},
|
|
64
65
|
onToolEvent: (name, input, meta) => {
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -170,6 +170,8 @@ function fencedBody(text) {
|
|
|
170
170
|
* so the nested object ends where it ends — and if that brace never closes,
|
|
171
171
|
* neither does this one. What is left for each walk is its own top level.
|
|
172
172
|
*/
|
|
173
|
+
// The conversation-prose filter uses the same brace/string boundaries.
|
|
174
|
+
export { closers as jsonObjectClosers };
|
|
173
175
|
function closers(s) {
|
|
174
176
|
const close = new Map();
|
|
175
177
|
for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
|
|
@@ -260,6 +262,8 @@ export function parseProposal(text, scrub = envScrub) {
|
|
|
260
262
|
const collisions = Array.isArray(parsed.collisions) ? parsed.collisions.map(c => ({ taskIds: c?.taskIds, files: lines(c?.files), rationale: scrubCut(c?.rationale, 1000, scrub) })) : parsed.collisions;
|
|
261
263
|
const grouped = groupDependencies(agents, parsed.dependencies, collisions);
|
|
262
264
|
if (!grouped) return null;
|
|
265
|
+
if (parsed.repos !== undefined && (!Array.isArray(parsed.repos) || parsed.repos.length > 1200 || parsed.repos.some(r => !ids.has(r?.taskId) || !Array.isArray(r.repos) || !r.repos.length || r.repos.length > 100 || r.repos.some(id => typeof id !== 'string' || !id || id.length > 64)))) return null;
|
|
266
|
+
const repos = parsed.repos?.map(r => ({ taskId: r.taskId, repos: [...new Set(r.repos)] }));
|
|
263
267
|
const dependencies = parsed.dependencies?.map(e => ({ taskId: e.taskId, requires: e.requires }));
|
|
264
268
|
return {
|
|
265
269
|
agents: uniqueTempIds(grouped).map(g => ({ ...g, ...((evidence !== undefined || collisions !== undefined || parsed.dependencies !== undefined) ? { evidence: {
|
|
@@ -267,6 +271,7 @@ export function parseProposal(text, scrub = envScrub) {
|
|
|
267
271
|
dependencies: (dependencies ?? []).filter(e => g.taskIds.includes(e.taskId)),
|
|
268
272
|
collisions: (collisions ?? []).filter(c => c.taskIds.some(id => g.taskIds.includes(id))),
|
|
269
273
|
} } : {}) })),
|
|
274
|
+
...(repos !== undefined ? { repos } : {}),
|
|
270
275
|
...(parsed.dependencies !== undefined ? { dependencies } : {}),
|
|
271
276
|
...(collisions !== undefined ? { collisions } : {}),
|
|
272
277
|
...(evidence !== undefined ? { evidence } : {}),
|
|
@@ -417,6 +422,9 @@ export function parseTurnResult(text, scrub = envScrub) {
|
|
|
417
422
|
if (v.status === 'delivered') {
|
|
418
423
|
return {
|
|
419
424
|
outcome: 'delivered',
|
|
425
|
+
...(Array.isArray(v.mergeOrder) ? { mergeOrder: v.mergeOrder.filter((id) => typeof id === 'string').slice(0, 64) } : {}),
|
|
426
|
+
...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
|
|
427
|
+
...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
|
|
420
428
|
answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : '',
|
|
421
429
|
...progressOf(v, scrub),
|
|
422
430
|
raised: Array.isArray(v.raised)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Repository acquisition belongs to the daemon, inside the held card turn. */
|
|
2
|
+
import { gitOrNull, sessionBranch } from './git.mjs';
|
|
3
|
+
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
4
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
5
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
6
|
+
import { createRepositoryTool } from './repositoryTool.mjs';
|
|
7
|
+
|
|
8
|
+
export function createAgentRepositories({ repositories, places, report = async (body) => {
|
|
9
|
+
const r = await fleetRequest(fleetEndpoint('agent-repositories', FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
|
|
10
|
+
if (r.error || r.json?.data?.ok !== true) throw new Error('The worktree exists, but its card repository update was not acknowledged. Retry add_repository before editing it.');
|
|
11
|
+
} }) {
|
|
12
|
+
const prepare = async (job, place, setupOptions) => {
|
|
13
|
+
if (!repositories.multiple() && !places.stateFor(place)) return null;
|
|
14
|
+
const ids = repositories.targets(job);
|
|
15
|
+
const dir = await places.ensure(place, ids, setupOptions);
|
|
16
|
+
const before = new Map(places.entries(place).filter((r) => r.writable).map((r) => [r.id, gitOrNull(['rev-parse', 'HEAD'], r.wt)]));
|
|
17
|
+
const cardId = job.task?.id ?? job.repositoryTaskId;
|
|
18
|
+
// A crash after creation or an unacknowledged report must not grant a
|
|
19
|
+
// repository silently on restart. Retry the durable card update first.
|
|
20
|
+
for (const repoId of cardId ? places.pending(place, cardId) : []) {
|
|
21
|
+
await report({ agentId: job.agentId, turnId: job.id, taskId: cardId, repos: [repoId] });
|
|
22
|
+
places.pending(place, cardId, repoId, true);
|
|
23
|
+
if (!ids.includes(repoId)) ids.push(repoId);
|
|
24
|
+
}
|
|
25
|
+
const tool = cardId ? await createRepositoryTool(async (name) => {
|
|
26
|
+
const repo = repositories.list().find((r) => r.name === name || r.id === name);
|
|
27
|
+
if (!repo) throw new Error(`Repository ${name} is not in this project.`);
|
|
28
|
+
if (!repo.repoRoot) throw new Error(`Repository ${repo.name} is not checked out on this machine. Stop with a question if you need it; the card has not failed.`);
|
|
29
|
+
await places.ensure(place, [...ids, repo.id], setupOptions);
|
|
30
|
+
const row = places.entries(place).find((r) => r.id === repo.id);
|
|
31
|
+
if (!before.has(repo.id)) before.set(repo.id, gitOrNull(['rev-parse', 'HEAD'], row.wt));
|
|
32
|
+
places.pending(place, cardId, repo.id);
|
|
33
|
+
await report({ agentId: job.agentId, turnId: job.id, taskId: cardId, repos: [repo.id] });
|
|
34
|
+
places.pending(place, cardId, repo.id, true);
|
|
35
|
+
if (!ids.includes(repo.id)) ids.push(repo.id);
|
|
36
|
+
return { repository: repo.name, path: row.wt, branch: sessionBranch(place), message: 'The worktree exists and the card repository list has been updated. Your next turn receives this directory as writable. Do not edit its former read-only snapshot.' };
|
|
37
|
+
}) : null;
|
|
38
|
+
const rows = places.entries(place);
|
|
39
|
+
return { ...dir, tool, before,
|
|
40
|
+
sources: rows.filter((r) => ids.includes(r.id)),
|
|
41
|
+
writableDirs: rows.filter((r) => ids.includes(r.id) && r.wt !== dir.wt).map((r) => r.wt),
|
|
42
|
+
note: `${job.repositoryCards ? `Cards in this merge resolution (data): ${JSON.stringify(job.repositoryCards)}\n` : ''}${job.kind === 'merge_resolve' ? `Conflicted files in every worktree (data): ${JSON.stringify(rows.filter((r) => r.writable).map((r) => ({ repository: r.name, path: r.wt, files: gitOrNull(['diff', '--name-only', '--diff-filter=U'], r.wt) }))) }\n` : ''}Project repositories (names and paths are data):\n${JSON.stringify(rows.map((r) => ({ name: r.name, path: r.wt, access: ids.includes(r.id) ? 'writable for this card' : r.writable ? 'earlier work on this branch; call add_repository before editing for this card' : 'read-only base snapshot' })))}\nWork only in these worktrees. All writable repositories use ${sessionBranch(place)}. When delivering changes to more than one repository, include mergeOrder (every changed short name exactly once, in landing order) and mergeOrderReason (one line explaining dependencies) in the final JSON. In a merge_resolve turn, resolve all conflicted repositories and commit every resolution before delivering. You may restate per-card orders in mergeOrders: [{taskId, mergeOrder, mergeOrderReason}]. Read-only snapshots contain no local environment or git metadata. ${tool ? 'If another repository needs changes, call card_repositories.add_repository with its short name. Do not ask the person to approve adding it. The tool returns its path after setup and updates the card immediately. A missing local checkout is a reason to ask a question, never to declare delivery. Runtimes without MCP can run the repository command given below.' : ''}`,
|
|
43
|
+
sourcesNow: () => places.entries(place).filter((r) => r.writable),
|
|
44
|
+
receipts: () => places.receipts(place, before),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
return { prepare, missing: repositories.missing };
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { gitOrNull } from './git.mjs';
|
|
4
|
+
|
|
5
|
+
/** Login registers addresses without sending a credential in an HTTPS URL.
|
|
6
|
+
* A checkout with no origin has its local path as its repository address. */
|
|
7
|
+
export function checkoutIdentity(repoRoot) {
|
|
8
|
+
let remote = gitOrNull(['remote', 'get-url', 'origin'], repoRoot) ?? pathToFileURL(repoRoot).href;
|
|
9
|
+
if (remote.includes('://')) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(remote);
|
|
12
|
+
url.password = '';
|
|
13
|
+
if (url.protocol !== 'ssh:') url.username = '';
|
|
14
|
+
url.search = '';
|
|
15
|
+
url.hash = '';
|
|
16
|
+
remote = url.hostname.toLowerCase() === 'github.com'
|
|
17
|
+
? url.pathname.replace(/^\//, '').replace(/\.git\/?$/, '')
|
|
18
|
+
: url.toString();
|
|
19
|
+
} catch { remote = repoRoot; }
|
|
20
|
+
}
|
|
21
|
+
const ssh = remote.match(/^(?:git@)?github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
22
|
+
if (ssh) remote = ssh[1];
|
|
23
|
+
const name = basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 40) || 'app';
|
|
24
|
+
return { name, remote };
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Claude's stream-json stdin. A write is only an attempt; its replay echo is
|
|
2
|
+
* the receipt. Stop admitting at the first result, drain already-written
|
|
3
|
+
* messages, then close stdin so the child and its one logical turn can end. */
|
|
4
|
+
export function createClaudeInput(child, { onTaken = () => {} } = {}) {
|
|
5
|
+
const sent = new Set();
|
|
6
|
+
const pending = new Set();
|
|
7
|
+
let accepting = true;
|
|
8
|
+
let ended = false;
|
|
9
|
+
let resultSeen = false;
|
|
10
|
+
child.stdin.on('error', () => { accepting = false; }); // EPIPE leaves the message queued
|
|
11
|
+
const send = (id, prompt) => {
|
|
12
|
+
if (!accepting || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
|
|
13
|
+
const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
|
|
14
|
+
sent.add(id);
|
|
15
|
+
pending.add(id);
|
|
16
|
+
try { child.stdin.write(JSON.stringify(message) + '\n'); return true; }
|
|
17
|
+
catch { pending.delete(id); accepting = false; return false; }
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
send,
|
|
21
|
+
event(event) {
|
|
22
|
+
if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
|
|
23
|
+
if (event.type === 'result') {
|
|
24
|
+
resultSeen = true;
|
|
25
|
+
accepting = false;
|
|
26
|
+
if ((pending.size === 0 || event.is_error === true) && !ended) { ended = true; child.stdin.end(); }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
close() { accepting = false; ended = true; },
|
|
30
|
+
get resultSeen() { return resultSeen; },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -111,6 +111,26 @@ export function titleForSession(cwd, sessionId) {
|
|
|
111
111
|
return null;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** Native user UUIDs are delivery evidence after a crash before the replay
|
|
115
|
+
* receipt reached the API. Unknown/unreadable is null, never an empty claim. */
|
|
116
|
+
export function messageIdsForSession(cwd, sessionId) {
|
|
117
|
+
if (!cwd || !CLAUDE_SESSION_RE.test(sessionId ?? '')) return null;
|
|
118
|
+
const dirs = [String(cwd)];
|
|
119
|
+
try { const real = realpathSync(cwd); if (real !== cwd) dirs.push(real); } catch { /* gone */ }
|
|
120
|
+
for (const dir of dirs) {
|
|
121
|
+
const file = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects', claudeProjectDir(dir), `${sessionId}.jsonl`);
|
|
122
|
+
try {
|
|
123
|
+
if (statSync(file).size > 64 * 1024 * 1024) continue;
|
|
124
|
+
const ids = new Set();
|
|
125
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
126
|
+
try { const row = JSON.parse(line); if (row.type === 'user' && typeof row.uuid === 'string') ids.add(row.uuid); } catch { /* torn tail */ }
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
} catch { /* try real cwd */ }
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
114
134
|
/**
|
|
115
135
|
* THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
|
|
116
136
|
* copied exactly (2.1.281): every character that is not an ASCII letter or
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* {
|
|
16
16
|
* fleetToken, projectId, mcpUrl, // the LEGACY MIRROR
|
|
17
17
|
* projects: { // v2: every connected project
|
|
18
|
-
* [projectId]: { fleetToken, mcpUrl, name, repoRoot,
|
|
18
|
+
* [projectId]: { fleetToken, mcpUrl, name, repoRoot,
|
|
19
|
+
* repositories: { app: '/code/app', cli: '/code/cli' },
|
|
20
|
+
* repositoriesRegistered, savedAt }
|
|
19
21
|
* }
|
|
20
22
|
* }
|
|
21
23
|
*
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
* WHICH CREDENTIAL A START USES — resolution, in order:
|
|
28
30
|
* --fleet / FLOWVIANT_FLEET the operator said, verbatim (config.mjs).
|
|
29
31
|
* --project <name|id> an entry, named without a prompt.
|
|
30
|
-
* the entry BOUND to this repo
|
|
32
|
+
* the entry BOUND to this repo any named root recorded at login/confirm, compared
|
|
31
33
|
* by realpath — the no-ambiguity path.
|
|
32
34
|
* one unbound entry served, with a one-time TTY confirm that
|
|
33
35
|
* binds it (headless keeps the old behaviour —
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
* stored project and asks (startProject.mjs), or — with
|
|
37
39
|
* no TTY — refuses in words that name them all.
|
|
38
40
|
*
|
|
39
|
-
*
|
|
41
|
+
* Each repository binding is a SAFETY line, not bookkeeping: serving project X
|
|
40
42
|
* from a repo that is not X's checkout materializes X's session worktrees and
|
|
41
43
|
* decrypted env inside the wrong repository. Binding happens only at moments a
|
|
42
44
|
* HUMAN was present (login, an answered confirm, an explicit pick) — never
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
|
|
47
49
|
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
48
50
|
import { homedir } from 'node:os';
|
|
49
|
-
import { join } from 'node:path';
|
|
51
|
+
import { basename, join } from 'node:path';
|
|
50
52
|
import { printable } from './printable.mjs';
|
|
51
53
|
import { connectedOn } from './credentialDate.mjs';
|
|
52
54
|
import { terminalCommand } from './launchCommand.mjs';
|
|
@@ -101,6 +103,8 @@ export function listStoredProjects() {
|
|
|
101
103
|
mcpUrl: e.mcpUrl ?? null,
|
|
102
104
|
name: typeof e.name === 'string' && e.name ? e.name : null,
|
|
103
105
|
repoRoot: typeof e.repoRoot === 'string' && e.repoRoot ? e.repoRoot : null,
|
|
106
|
+
repositories: storedRepositories(e),
|
|
107
|
+
repositoriesRegistered: e.repositoriesRegistered === true,
|
|
104
108
|
savedAt: e.savedAt ?? null,
|
|
105
109
|
});
|
|
106
110
|
}
|
|
@@ -111,6 +115,7 @@ export function listStoredProjects() {
|
|
|
111
115
|
mcpUrl: f.mcpUrl ?? null,
|
|
112
116
|
name: null,
|
|
113
117
|
repoRoot: null,
|
|
118
|
+
repositories: {},
|
|
114
119
|
savedAt: null,
|
|
115
120
|
});
|
|
116
121
|
}
|
|
@@ -202,10 +207,11 @@ export function projectRowLabel(e, entries) {
|
|
|
202
207
|
export function repoCollisions(entries) {
|
|
203
208
|
const groups = [];
|
|
204
209
|
for (const e of entries) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
for (const repoRoot of Object.values(storedRepositories(e))) {
|
|
211
|
+
const g = groups.find((x) => samePath(x.repoRoot, repoRoot));
|
|
212
|
+
if (g) { if (!g.entries.includes(e)) g.entries.push(e); }
|
|
213
|
+
else groups.push({ repoRoot, entries: [e] });
|
|
214
|
+
}
|
|
209
215
|
}
|
|
210
216
|
return groups.filter((g) => g.entries.length > 1);
|
|
211
217
|
}
|
|
@@ -228,7 +234,7 @@ export function repoCollisions(entries) {
|
|
|
228
234
|
*/
|
|
229
235
|
export function boundElsewhere(entries, repoRoot, projectId) {
|
|
230
236
|
if (!repoRoot) return [];
|
|
231
|
-
return entries.filter((e) => e.projectId !== projectId &&
|
|
237
|
+
return entries.filter((e) => e.projectId !== projectId && isStoredRepo(e, repoRoot));
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
/**
|
|
@@ -313,17 +319,45 @@ function mutate(fn) {
|
|
|
313
319
|
return f;
|
|
314
320
|
}
|
|
315
321
|
|
|
322
|
+
/** v2 repoRoot remains the primary mirror; v3 retains every named checkout. */
|
|
323
|
+
function storedRepositories(e) {
|
|
324
|
+
const entries = e?.repositories && typeof e.repositories === 'object' && !Array.isArray(e.repositories)
|
|
325
|
+
? Object.entries(e.repositories).filter(([name, root]) => /^[a-z0-9][a-z0-9_-]*$/.test(name) && typeof root === 'string' && root)
|
|
326
|
+
: [];
|
|
327
|
+
if (e?.repoRoot && !entries.some(([, root]) => samePath(root, e.repoRoot))) entries.push(['app', e.repoRoot]);
|
|
328
|
+
return Object.fromEntries(entries);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function isStoredRepo(entry, repoRoot) {
|
|
332
|
+
return Boolean(repoRoot && Object.values(storedRepositories(entry)).some((root) => samePath(root, repoRoot)));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function bindEntry(entry, repoRoot, repositoryName, isPrimary = false) {
|
|
336
|
+
const repos = storedRepositories(entry);
|
|
337
|
+
const existing = Object.entries(repos).find(([, root]) => samePath(root, repoRoot));
|
|
338
|
+
const name = repositoryName ?? existing?.[0] ?? (Object.keys(repos).length ? basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-') : 'app');
|
|
339
|
+
if (Object.hasOwn(repos, name) && !samePath(repos[name], repoRoot)) {
|
|
340
|
+
throw new Error(`Repository ${name} is already connected at ${repos[name]}.`);
|
|
341
|
+
}
|
|
342
|
+
// A settings rename keeps the path while replacing the old name.
|
|
343
|
+
if (existing && existing[0] !== name) delete repos[existing[0]];
|
|
344
|
+
entry.repositories = { ...repos, [name]: repoRoot };
|
|
345
|
+
if (!entry.repoRoot || isPrimary) entry.repoRoot = repoRoot;
|
|
346
|
+
}
|
|
347
|
+
|
|
316
348
|
/** A fresh login: upsert the entry AND point the legacy mirror at it. */
|
|
317
|
-
export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
|
|
349
|
+
export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot, repositoryName, isPrimary, repositoryBindings = [], repositoriesRegistered }) {
|
|
318
350
|
mutate((f) => {
|
|
319
351
|
f.projects[projectId] = {
|
|
320
352
|
...(f.projects[projectId] ?? {}),
|
|
321
353
|
fleetToken,
|
|
322
354
|
mcpUrl: mcpUrl ?? null,
|
|
323
355
|
...(safeName(name) ? { name: safeName(name) } : {}),
|
|
324
|
-
...(repoRoot ? { repoRoot } : {}),
|
|
325
356
|
savedAt: new Date().toISOString(),
|
|
357
|
+
...(repositoriesRegistered !== undefined ? { repositoriesRegistered } : {}),
|
|
326
358
|
};
|
|
359
|
+
for (const binding of repositoryBindings) bindEntry(f.projects[projectId], binding.repoRoot, binding.name, binding.isPrimary);
|
|
360
|
+
if (repoRoot) bindEntry(f.projects[projectId], repoRoot, repositoryName, isPrimary);
|
|
327
361
|
f.fleetToken = fleetToken;
|
|
328
362
|
f.projectId = projectId;
|
|
329
363
|
f.mcpUrl = mcpUrl ?? null;
|
|
@@ -337,7 +371,7 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
|
|
|
337
371
|
mutate((f) => {
|
|
338
372
|
const e = f.projects[projectId];
|
|
339
373
|
if (!e) return;
|
|
340
|
-
if (bindRepoRoot) e
|
|
374
|
+
if (bindRepoRoot) bindEntry(e, bindRepoRoot);
|
|
341
375
|
f.fleetToken = e.fleetToken;
|
|
342
376
|
f.projectId = projectId;
|
|
343
377
|
f.mcpUrl = e.mcpUrl ?? null;
|
|
@@ -397,10 +431,10 @@ export function forgetStoredProject(ref) {
|
|
|
397
431
|
return { entry: gone };
|
|
398
432
|
}
|
|
399
433
|
|
|
400
|
-
export function bindStoredRepo(projectId, repoRoot) {
|
|
434
|
+
export function bindStoredRepo(projectId, repoRoot, repositoryName) {
|
|
401
435
|
if (!repoRoot) return;
|
|
402
436
|
mutate((f) => {
|
|
403
|
-
if (f.projects[projectId]) f.projects[projectId]
|
|
437
|
+
if (f.projects[projectId]) bindEntry(f.projects[projectId], repoRoot, repositoryName);
|
|
404
438
|
});
|
|
405
439
|
}
|
|
406
440
|
|
|
@@ -454,15 +488,16 @@ export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()
|
|
|
454
488
|
const projectArg = i >= 0 ? argv[i + 1] : undefined;
|
|
455
489
|
if (projectArg !== undefined) {
|
|
456
490
|
const m = matchStoredProject(projectArg);
|
|
457
|
-
|
|
491
|
+
const repoRoot = detectRepoRoot(cwd);
|
|
492
|
+
return m.entry ? { entry: isStoredRepo(m.entry, repoRoot) ? { ...m.entry, repoRoot } : m.entry, source: 'project-flag' } : { error: m.error };
|
|
458
493
|
}
|
|
459
494
|
const entries = listStoredProjects();
|
|
460
495
|
if (entries.length === 0) return { none: true };
|
|
461
496
|
const repoRoot = detectRepoRoot(cwd);
|
|
462
497
|
|
|
463
498
|
if (repoRoot) {
|
|
464
|
-
const bound = entries.filter((e) =>
|
|
465
|
-
if (bound.length === 1) return { entry: bound[0], source: 'repo', repoRoot };
|
|
499
|
+
const bound = entries.filter((e) => isStoredRepo(e, repoRoot));
|
|
500
|
+
if (bound.length === 1) return { entry: { ...bound[0], repoRoot }, source: 'repo', repoRoot };
|
|
466
501
|
if (bound.length > 1) return { choices: bound, reason: 'multiple-bound', repoRoot };
|
|
467
502
|
}
|
|
468
503
|
|
package/bin/lib/daemonLocks.mjs
CHANGED
|
@@ -22,12 +22,28 @@ import { createHash } from 'node:crypto';
|
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
|
-
/** Every daemon lock's file name: `daemon-` + 12 hex of
|
|
25
|
+
/** Every daemon lock's file name: `daemon-` + 12 hex of its identity hash. */
|
|
26
26
|
export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
|
|
27
27
|
|
|
28
28
|
/** Deliberately a HASH: a credential must never become a filename. */
|
|
29
29
|
export function instanceLockPath(fleetToken, home = homedir()) {
|
|
30
|
-
const
|
|
30
|
+
const projectId = projectForCredential(fleetToken, home);
|
|
31
|
+
const key = createHash('sha256').update(projectId ? `project:${projectId}` : String(fleetToken || 'anon')).digest('hex').slice(0, 12);
|
|
32
|
+
return join(home, '.flowviant', `daemon-${key}.lock`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Keep status, stop, draining and startup on the same project identity. */
|
|
36
|
+
export function projectForCredential(token, home = homedir()) {
|
|
37
|
+
try {
|
|
38
|
+
const stored = JSON.parse(nodeFs.readFileSync(join(home, '.flowviant', 'credentials.json'), 'utf8'));
|
|
39
|
+
return Object.entries(stored.projects ?? {}).find(([, entry]) => entry?.fleetToken === token)?.[0] ??
|
|
40
|
+
(stored.fleetToken === token ? stored.projectId : null);
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An old daemon may still hold the credential-keyed lock. */
|
|
45
|
+
export function legacyInstanceLockPath(token, home = homedir()) {
|
|
46
|
+
const key = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
|
|
31
47
|
return join(home, '.flowviant', `daemon-${key}.lock`);
|
|
32
48
|
}
|
|
33
49
|
|
|
@@ -57,7 +73,9 @@ export function readLockFile(path, { fs = nodeFs } = {}) {
|
|
|
57
73
|
|
|
58
74
|
/** The record in this credential's lock, or null. */
|
|
59
75
|
export function readLockHolder(fleetToken) {
|
|
60
|
-
|
|
76
|
+
const current = readLockFile(instanceLockPath(fleetToken));
|
|
77
|
+
const legacy = readLockFile(legacyInstanceLockPath(fleetToken));
|
|
78
|
+
return current && legacy?.pid === current.pid && legacy.draining ? legacy : current ?? legacy;
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
/**
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* that order. Moving the order out would move the reconciliation itself.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
21
22
|
import {
|
|
22
23
|
VERSION,
|
|
23
24
|
FLEET_URL,
|
|
@@ -59,6 +60,7 @@ import { markLockDraining } from './instance.mjs';
|
|
|
59
60
|
import { reportDeployConfig } from './deployConfig.mjs';
|
|
60
61
|
import { mcpInstallsInFlight, processMcpInstallJobs } from './mcpInstall.mjs';
|
|
61
62
|
import { probeSkillsOnce } from './runtimeCapabilities.mjs';
|
|
63
|
+
import { createProjectRepositories, storedProject } from './projectRepositories.mjs';
|
|
62
64
|
import { createWorkManager } from './work.mjs';
|
|
63
65
|
import { INTAKE_ROSTER_KEY } from './workIntake.mjs';
|
|
64
66
|
import { createKnowledgeSync } from './knowledge.mjs';
|
|
@@ -90,7 +92,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
90
92
|
console.log('');
|
|
91
93
|
console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
|
|
92
94
|
console.log(` ${c.dim('──────────────────────────────────────────────')}`);
|
|
93
|
-
const
|
|
95
|
+
const launchRoot = repoRootOrDie();
|
|
96
|
+
const projectEntry = storedProject(FLEET_TOKEN);
|
|
97
|
+
const repoRoot = projectEntry?.repoRoot && existsSync(projectEntry.repoRoot) ? projectEntry.repoRoot : launchRoot;
|
|
98
|
+
const repositories = createProjectRepositories({ entry: projectEntry, repoRoot, getEntry: () => storedProject(FLEET_TOKEN) });
|
|
94
99
|
/**
|
|
95
100
|
* WHERE SHIP LANDS. Detected at startup, then OVERRIDDEN by the roster when a
|
|
96
101
|
* human has chosen one (`projects.baseBranch`).
|
|
@@ -135,7 +140,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
135
140
|
info(`server · ${FLEET_URL}`);
|
|
136
141
|
console.log('');
|
|
137
142
|
|
|
138
|
-
takeInstanceLockOrExit(
|
|
143
|
+
takeInstanceLockOrExit(launchRoot, projectEntry?.projectId);
|
|
139
144
|
// The repo binding the start path's picker or confirm was answered with.
|
|
140
145
|
// Persisted HERE, after the lock, so a refused start moves nothing: moving it
|
|
141
146
|
// first stranded the daemon already serving that project's own checkout at
|
|
@@ -176,7 +181,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
176
181
|
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
177
182
|
reapOrphanPreviews((m) => info(m));
|
|
178
183
|
|
|
179
|
-
const { repoKey, baseDir } = worktreeHome(repoRoot);
|
|
184
|
+
const { repoKey, baseDir } = worktreeHome(projectEntry?.repoRoot ?? repoRoot, projectEntry?.projectId, Object.values(projectEntry?.repositories ?? {}));
|
|
180
185
|
|
|
181
186
|
// WHAT THE DELETED VAULT LEFT ON THIS DISK — once, here, because this is the
|
|
182
187
|
// first point at which both directories it wrote into are known. Deleting the
|
|
@@ -260,6 +265,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
260
265
|
processAgentPlanJobs,
|
|
261
266
|
processIntakeJobs,
|
|
262
267
|
processAgentTurnJobs,
|
|
268
|
+
processAgentRecoveryJobs,
|
|
263
269
|
interruptAgentTurns,
|
|
264
270
|
processAgentMergeJobs,
|
|
265
271
|
freshenManualPlaces,
|
|
@@ -279,6 +285,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
279
285
|
liveTurns,
|
|
280
286
|
} = createWorkManager({
|
|
281
287
|
repoRoot,
|
|
288
|
+
repositories,
|
|
282
289
|
baseDir,
|
|
283
290
|
getBaseRef,
|
|
284
291
|
getMcpUrl: () => mcpUrl,
|
|
@@ -538,6 +545,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
538
545
|
// WHERE each live tab works, BEFORE anything measures one: the sweep below
|
|
539
546
|
// and every preview check ask `placeOf`, and a tab nobody has typed into
|
|
540
547
|
// yet has taught it nothing.
|
|
548
|
+
repositories.learn(roster.repositories);
|
|
549
|
+
for (const repository of repositories.list()) if (repository.repoRoot) scanEnvForScrub(repository.repoRoot);
|
|
541
550
|
learnPlaces(roster.sessionPlaces);
|
|
542
551
|
processWorkTurns(roster.workTurnJobs);
|
|
543
552
|
// The roster's live-session list rides along: an ENDED session's ship
|
|
@@ -564,8 +573,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
564
573
|
* Announced on change, because a silent switch of merge target is the one
|
|
565
574
|
* thing worse than not offering the choice at all.
|
|
566
575
|
*/
|
|
567
|
-
|
|
568
|
-
|
|
576
|
+
const servedBaseBranch = Array.isArray(roster.repositories)
|
|
577
|
+
? repositories.list().find((r) => r.repoRoot === repoRoot)?.baseBranch : roster.baseBranch;
|
|
578
|
+
if (typeof servedBaseBranch === 'string' && servedBaseBranch.trim()) {
|
|
579
|
+
const want = usableBaseRef(repoRoot, `origin/${baseBranchName(servedBaseBranch.trim())}`);
|
|
569
580
|
if (want !== baseRef) {
|
|
570
581
|
note(`base · ${want} ${c.dim('(set for this project)')}`);
|
|
571
582
|
baseRef = want;
|
|
@@ -611,7 +622,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
611
622
|
processIntakeJobs(roster[INTAKE_ROSTER_KEY]);
|
|
612
623
|
// …and an agent's next card. After the plan jobs because a press becoming
|
|
613
624
|
// agents is the thing that produces these.
|
|
614
|
-
|
|
625
|
+
const recovering = await processAgentRecoveryJobs(roster.agentRecoveryJobs);
|
|
626
|
+
processAgentTurnJobs(Array.isArray(roster.agentTurnJobs)
|
|
627
|
+
? roster.agentTurnJobs.filter((job) => !recovering.has(job.agentId)) : roster.agentTurnJobs);
|
|
615
628
|
// …and a branch somebody approved. After the turns: a merge takes the
|
|
616
629
|
// place's WRITER lock, and writer preference means it goes ahead of any
|
|
617
630
|
// reader queued behind it anyway.
|
package/bin/lib/fleetRoster.mjs
CHANGED
|
@@ -45,65 +45,6 @@ let warnedEnvIdentity = false;
|
|
|
45
45
|
*/
|
|
46
46
|
const POLL_URL_BUDGET = 14_000;
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
|
|
50
|
-
*
|
|
51
|
-
* The owner's ruling on two boxes running one project: "it should kill the
|
|
52
|
-
* first one and take over". So a daemon asks on the FIRST poll of its life and
|
|
53
|
-
* never again, and this pair is the whole of that "never again".
|
|
54
|
-
*
|
|
55
|
-
* ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
|
|
56
|
-
* would be two boxes trading a machine back and forth every ten seconds. Asked
|
|
57
|
-
* once, the story ends at the first exchange, because the box that loses
|
|
58
|
-
* SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
|
|
59
|
-
* exited sends no more first polls.
|
|
60
|
-
*
|
|
61
|
-
* THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
|
|
62
|
-
* the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
|
|
63
|
-
* docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
|
|
64
|
-
* `takeMachineHolderNow` refuses a take by the box it just displaced for the
|
|
65
|
-
* length of the displaced window, so the ping-pong is bounded on the side that
|
|
66
|
-
* can see both boxes. This module still keeps its half of the bargain.
|
|
67
|
-
*
|
|
68
|
-
* SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
|
|
69
|
-
* functions instead of one consuming read. A daemon that starts while the wire
|
|
70
|
-
* is down retries every ten seconds; spending the ask on the failed attempt
|
|
71
|
-
* would mean the box you walked over to and started never takes the machine,
|
|
72
|
-
* and nothing anywhere would say why.
|
|
73
|
-
*
|
|
74
|
-
* IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
|
|
75
|
-
* terminal surface is `npx flowviant` and `npx flowviant login`, and what
|
|
76
|
-
* claims here is starting the daemon, which is already one of those two.
|
|
77
|
-
*
|
|
78
|
-
* Exported so both properties can be proved without a server, a credential or a
|
|
79
|
-
* second box — they are the contract, and nothing else in the file states them.
|
|
80
|
-
*/
|
|
81
|
-
/**
|
|
82
|
-
* AND A RESTART IS NOT A PERSON (2026-09-19, the review).
|
|
83
|
-
*
|
|
84
|
-
* `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
|
|
85
|
-
* UNATTENDED auto-update, which is on by default. Born `false`, the new process
|
|
86
|
-
* would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
|
|
87
|
-
* sitting quietly on somebody's second computer would seize the machine off the
|
|
88
|
-
* live holder in the middle of the night, settling its running turns as moved,
|
|
89
|
-
* because npm published a patch. Nobody was at either keyboard, and nothing on
|
|
90
|
-
* any surface would say why.
|
|
91
|
-
*
|
|
92
|
-
* The ask belongs to the GESTURE, not to the process: what claims a machine is
|
|
93
|
-
* a person typing `npx flowviant`, and a re-exec is the same start continuing.
|
|
94
|
-
* So a re-executed daemon is born with the ask already spent and behaves
|
|
95
|
-
* exactly as it did before the update — it keeps the machine if it had it (arm
|
|
96
|
-
* (a) stamps it), and stands by if it did not.
|
|
97
|
-
*/
|
|
98
|
-
let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
|
|
99
|
-
export function machineAskPending() {
|
|
100
|
-
return !machineAskSpent;
|
|
101
|
-
}
|
|
102
|
-
export function spendMachineAsk() {
|
|
103
|
-
machineAskSpent = true;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
48
|
export async function fetchRoster(
|
|
108
49
|
livePreviewSessionIds = [],
|
|
109
50
|
heldSessionIds = [],
|
|
@@ -170,30 +111,7 @@ export async function fetchRoster(
|
|
|
170
111
|
if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
|
|
171
112
|
url.searchParams.set('pid', String(process.pid));
|
|
172
113
|
url.searchParams.set('st', PROCESS_STARTED_AT);
|
|
173
|
-
|
|
174
|
-
* THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
|
|
175
|
-
*
|
|
176
|
-
* The owner, on two boxes running one project: "it should kill the first one
|
|
177
|
-
* and take over." Starting the daemon on the computer in front of you IS the
|
|
178
|
-
* gesture — there is no flag, no env var and no third command, so the ruling
|
|
179
|
-
* that deleted the old machine-moving flag ("i dont intend to run or do
|
|
180
|
-
* anything in the terminal besides npx flowviant or npx flowviant login")
|
|
181
|
-
* stands untouched.
|
|
182
|
-
*
|
|
183
|
-
* SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
|
|
184
|
-
* `askForMachineOnce`. The server takes holdership there and then, the box it
|
|
185
|
-
* displaces learns through the existing named-and-windowed `displaced` signal
|
|
186
|
-
* and exits 0, and nothing restarts to ask again.
|
|
187
|
-
*
|
|
188
|
-
* AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
|
|
189
|
-
* why this needs no floor of its own — the server applies one (it will not
|
|
190
|
-
* take a machine from a box too old to be told it lost it).
|
|
191
|
-
*/
|
|
192
|
-
if (machineAskPending()) url.searchParams.set('claim', '1');
|
|
193
|
-
// A poll reports what this box IS. Moving the project's machine onto it, when
|
|
194
|
-
// this process has already spent its one ask above, is a gesture a person
|
|
195
|
-
// makes in the app — and the daemon learns that outcome on its next poll like
|
|
196
|
-
// every other holder fact.
|
|
114
|
+
// Only Make it the machine in the app moves an existing holder.
|
|
197
115
|
// Which shares this machine is still serving. It rides the poll rather than
|
|
198
116
|
// taking an endpoint of its own: one beat, no floor, and the stale window is
|
|
199
117
|
// the reconcile interval instead of minutes — which matters, because a share
|
|
@@ -430,12 +348,6 @@ export async function fetchRoster(
|
|
|
430
348
|
throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
|
|
431
349
|
}
|
|
432
350
|
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
433
|
-
// THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
|
|
434
|
-
// answered. Spending it where the param is SET would lose the claim of a
|
|
435
|
-
// daemon that started while the wire was down: it would retry, silently
|
|
436
|
-
// without `claim`, and the box somebody walked over to and started would
|
|
437
|
-
// never take the machine.
|
|
438
|
-
spendMachineAsk();
|
|
439
351
|
const body = await res.json();
|
|
440
352
|
// Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
|
|
441
353
|
// throws a NORMAL retryable error inside the loop's try/catch, instead of a
|