flowviant 0.93.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/bin/cli.mjs +122 -30
- package/bin/lib/artifacts.mjs +298 -0
- package/bin/lib/claude.mjs +9 -1
- package/bin/lib/credentials.mjs +55 -0
- package/bin/lib/fleet.mjs +34 -0
- package/bin/lib/instance.mjs +98 -65
- package/bin/lib/knowledge.mjs +531 -0
- package/bin/lib/login.mjs +77 -2
- package/bin/lib/machines.mjs +216 -5
- package/bin/lib/prompts.mjs +89 -0
- package/bin/lib/runtimes.mjs +6 -1
- package/bin/lib/work.mjs +110 -4
- package/package.json +1 -1
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROJECT KNOWLEDGE, daemon side (2026-09-22, 0.94.0) — the library a person
|
|
3
|
+
* keeps for their own Claude, materialised where a CLI can read it.
|
|
4
|
+
*
|
|
5
|
+
* The owner asked whether the Workbench could be "a clone of claude projects":
|
|
6
|
+
* files uploaded once and a standing Instructions text, read by every turn on
|
|
7
|
+
* the project. The server holds the bytes; the roster carries a MANIFEST
|
|
8
|
+
* (`knowledge: { rev, instructions, files: [{ id, name, bytes, sha256 }] }`);
|
|
9
|
+
* this module makes `<checkout>/.flowviant/knowledge/` match it, and the turn
|
|
10
|
+
* prompts name that directory. Nothing here reads a file's CONTENTS for any
|
|
11
|
+
* purpose but writing it — the only brain is the CLI, reading off a disk.
|
|
12
|
+
*
|
|
13
|
+
* ── ONE COPY PER BOX, IN THE CHECKOUT ──
|
|
14
|
+
*
|
|
15
|
+
* Not per worktree. Every session, capture and agent turn on this box runs in
|
|
16
|
+
* some directory under the same user, and every one of them can read an
|
|
17
|
+
* ABSOLUTE path; a copy per worktree would be a copy per agent, fifty megabytes
|
|
18
|
+
* times however many agents are open, and N copies that can each go stale on
|
|
19
|
+
* their own. The prompt hands the absolute path.
|
|
20
|
+
*
|
|
21
|
+
* It is never committed: its paths (`FLOWVIANT_OWN_PATHS` — never the whole
|
|
22
|
+
* `.flowviant/`, 2026-09-23) are written to the exclude file git actually
|
|
23
|
+
* reads (`excludeInWorktree`, which resolves `--git-common-dir`, so one call
|
|
24
|
+
* covers the checkout and every linked worktree alike). An untracked
|
|
25
|
+
* `.flowviant/` in the checkout would make the operator's own `git status`
|
|
26
|
+
* dirty and, for a tab standing in the checkout, show the library in the
|
|
27
|
+
* rail's diffstat as session changes.
|
|
28
|
+
*
|
|
29
|
+
* ── THE THREE STATES OF THE ROSTER KEY ──
|
|
30
|
+
*
|
|
31
|
+
* ABSENT — an older server, a project that never had knowledge, or a daemon
|
|
32
|
+
* below the floor (it never sees this code). LEAVE THE DIRECTORY ALONE. An
|
|
33
|
+
* absence is what an older server says on every poll; letting it mean
|
|
34
|
+
* "delete the library" would wipe it on a server rollback.
|
|
35
|
+
* `{ rev, files: [], instructions: null }` — the project HAD knowledge and
|
|
36
|
+
* the person emptied it. The server sends this for as long as the
|
|
37
|
+
* project's rev is above zero (forever after the first write), so emptying
|
|
38
|
+
* is SAID, never inferred. The directory is removed, and with it the
|
|
39
|
+
* prompt paragraph — which is rendered only while the directory exists.
|
|
40
|
+
* non-empty — sync to it.
|
|
41
|
+
*
|
|
42
|
+
* ── WHAT A SYNC DOES ──
|
|
43
|
+
*
|
|
44
|
+
* When `rev` differs from the last one materialised (held in memory AND in a
|
|
45
|
+
* marker file beside the directory, so a restart does not re-sync a library
|
|
46
|
+
* that is already on disk): download each file whose sha256 differs from the
|
|
47
|
+
* local copy — a local copy somebody edited by hand is restored, because the
|
|
48
|
+
* library is the server's and the directory is its mirror; delete every
|
|
49
|
+
* regular file the manifest no longer names; write or remove INSTRUCTIONS.md.
|
|
50
|
+
*
|
|
51
|
+
* A FAILED DOWNLOAD DOES NOT ADVANCE THE REV. The next poll tries again — but
|
|
52
|
+
* only the files that still differ, and on a widening backoff, so a server
|
|
53
|
+
* that 500s one file cannot make this box re-download fifty megabytes every
|
|
54
|
+
* ten seconds. A file refused on SIZE is not a failure to retry: it is
|
|
55
|
+
* permanent for that rev, skipped with a warning, and the rest of the library
|
|
56
|
+
* is written.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
existsSync,
|
|
61
|
+
lstatSync,
|
|
62
|
+
mkdirSync,
|
|
63
|
+
readdirSync,
|
|
64
|
+
readFileSync,
|
|
65
|
+
renameSync,
|
|
66
|
+
rmSync,
|
|
67
|
+
unlinkSync,
|
|
68
|
+
writeFileSync,
|
|
69
|
+
} from 'node:fs';
|
|
70
|
+
import { createHash } from 'node:crypto';
|
|
71
|
+
import { join } from 'node:path';
|
|
72
|
+
|
|
73
|
+
/** Relative to the checkout, under `.flowviant/` beside the turn uploads and
|
|
74
|
+
* the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
|
|
75
|
+
* line covering all three. */
|
|
76
|
+
export const KNOWLEDGE_DIR = '.flowviant/knowledge';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* THE PATHS UNDER `.flowviant/` THAT ARE OURS, and the exclude file names these
|
|
80
|
+
* and nothing wider (2026-09-23).
|
|
81
|
+
*
|
|
82
|
+
* The first cut excluded `/.flowviant/` whole, from every place a turn spawns
|
|
83
|
+
* in — the attachment fetch's line, which until this release was written only
|
|
84
|
+
* in repos somebody had attached a file in. But `.flowviant/` is not only ours:
|
|
85
|
+
* the repo DECLARES `.flowviant/check.json` and `.flowviant/deploy.json` there,
|
|
86
|
+
* committed like any other file. An exclude line never hides a TRACKED file,
|
|
87
|
+
* so an existing one was safe — and a NEW one was not: an agent asked to "add a
|
|
88
|
+
* check command" writes `.flowviant/check.json`, its `git add -A` skips it as
|
|
89
|
+
* ignored, the commit lands without it, and `git status` shows nothing to
|
|
90
|
+
* explain why. Writing that line into every worktree on every turn would have
|
|
91
|
+
* made the product's own configuration uncommittable by the product's own
|
|
92
|
+
* agents. So the exclude names the four things this daemon writes there, and a
|
|
93
|
+
* repo's own `.flowviant/*.json` stays ordinary, committable work.
|
|
94
|
+
*
|
|
95
|
+
* (A repo that already carries the old `/.flowviant/` line keeps it — it is in
|
|
96
|
+
* the user's own exclude file, and removing a line we cannot prove we alone
|
|
97
|
+
* wrote is not ours to do.)
|
|
98
|
+
*/
|
|
99
|
+
export const FLOWVIANT_OWN_PATHS = [
|
|
100
|
+
'.flowviant/knowledge/',
|
|
101
|
+
'.flowviant/knowledge.rev*',
|
|
102
|
+
'.flowviant/artifacts/',
|
|
103
|
+
'.flowviant/uploads/',
|
|
104
|
+
];
|
|
105
|
+
/** The person's standing brief, written from the manifest's `instructions`.
|
|
106
|
+
* A RESERVED name: a library FILE called this gets a suffix, because the
|
|
107
|
+
* prompt tells the CLI to read this one first and a stranger's file must
|
|
108
|
+
* never be able to stand in for the person's own words. */
|
|
109
|
+
export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
|
|
110
|
+
/** The rev last materialised — OUTSIDE the directory the prompt hands the CLI,
|
|
111
|
+
* so the agent listing its library sees only the library. */
|
|
112
|
+
const MARKER = '.flowviant/knowledge.rev';
|
|
113
|
+
/** The server's per-file ceiling (`KNOWLEDGE_FILE_MAX_BYTES`), re-checked here:
|
|
114
|
+
* this is somebody's disk, and one place checking is one deploy away from
|
|
115
|
+
* zero places — the attachment fetch's own rule. */
|
|
116
|
+
export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
117
|
+
/** The manifest is capped at the server (25 files); a manifest longer than
|
|
118
|
+
* this is not one the product produced, and is cut rather than trusted. */
|
|
119
|
+
const MAX_FILES = 64;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `safeUploadName`, verbatim in effect (work.mjs): keeps the extension, drops
|
|
123
|
+
* every path separator, never starts with a dot or a dash. Re-applied here
|
|
124
|
+
* although the server did it, because this string becomes a path on this box.
|
|
125
|
+
*/
|
|
126
|
+
export function safeKnowledgeName(raw) {
|
|
127
|
+
const base = String(raw ?? '')
|
|
128
|
+
.split(/[\\/]/)
|
|
129
|
+
.pop()
|
|
130
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
131
|
+
.replace(/^[.-]+/, '')
|
|
132
|
+
.slice(0, 80);
|
|
133
|
+
return base || 'file';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The LOCAL name each manifest entry is written under, in manifest order.
|
|
138
|
+
* Collisions get a numeric suffix (`spec.md`, `spec-2.md`) — two files a
|
|
139
|
+
* person uploaded with one name are two files, and silently writing one over
|
|
140
|
+
* the other loses one. Deterministic in the manifest's order, so the same
|
|
141
|
+
* manifest always yields the same names and a re-sync never renames a file.
|
|
142
|
+
* `INSTRUCTIONS.md` is reserved (compared case-insensitively — macOS and
|
|
143
|
+
* Windows disks are).
|
|
144
|
+
*/
|
|
145
|
+
export function planKnowledgeNames(files) {
|
|
146
|
+
const taken = new Set([INSTRUCTIONS_FILE.toLowerCase()]);
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
const name = safeKnowledgeName(f.name);
|
|
150
|
+
const dot = name.lastIndexOf('.');
|
|
151
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
152
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
153
|
+
let candidate = name;
|
|
154
|
+
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem}-${n}${ext}`;
|
|
155
|
+
taken.add(candidate.toLowerCase());
|
|
156
|
+
out.push({ ...f, local: candidate });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sha256Of = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
162
|
+
|
|
163
|
+
/** A regular file's sha256, or null for anything else (missing, a directory, a
|
|
164
|
+
* symlink — a symlink is never followed, so it is never "the same file"). */
|
|
165
|
+
function localSha(path) {
|
|
166
|
+
try {
|
|
167
|
+
const st = lstatSync(path);
|
|
168
|
+
if (!st.isFile()) return null;
|
|
169
|
+
return sha256Of(readFileSync(path));
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Write through a temp name and rename, so a CLI reading the library mid-sync
|
|
176
|
+
* sees the old file or the new one, never half of one. Anything already at
|
|
177
|
+
* the path that is NOT a regular file (a symlink planted there) is removed
|
|
178
|
+
* first — `writeFileSync` would otherwise follow it and write wherever it
|
|
179
|
+
* pointed. */
|
|
180
|
+
function writeAtomic(path, data) {
|
|
181
|
+
try {
|
|
182
|
+
const st = lstatSync(path);
|
|
183
|
+
if (!st.isFile()) rmSync(path, { recursive: true, force: true });
|
|
184
|
+
} catch {
|
|
185
|
+
/* absent — the ordinary case */
|
|
186
|
+
}
|
|
187
|
+
const tmp = `${path}.fvtmp`;
|
|
188
|
+
// THE TEMP NAME IS CHECKED TOO (2026-09-23). The first cut guarded the
|
|
189
|
+
// destination and then wrote straight through `<name>.fvtmp` — a link planted
|
|
190
|
+
// THERE would have been followed, the library's bytes written wherever it
|
|
191
|
+
// pointed, and the link renamed into place. Unlinked first, then created
|
|
192
|
+
// EXCLUSIVELY (`wx`): if anything reappears at the name between the two, the
|
|
193
|
+
// write throws into the caller's catch rather than following it.
|
|
194
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
195
|
+
writeFileSync(tmp, data, { flag: 'wx' });
|
|
196
|
+
renameSync(tmp, path);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Is `<checkout>/.flowviant` a real directory, or absent? Anything else — a
|
|
201
|
+
* symlink, a regular file — REFUSES the sync rather than being touched.
|
|
202
|
+
*
|
|
203
|
+
* `.flowviant/` is not only ours: a repo declares `.flowviant/check.json` and
|
|
204
|
+
* `.flowviant/deploy.json` there, and an operator may have arranged it however
|
|
205
|
+
* they like. Deleting a file or a link at that path to make room for a library
|
|
206
|
+
* would be Flowviant destroying something it did not create; following a link
|
|
207
|
+
* would land downloads, and the emptied library's `rm -r`, wherever it points.
|
|
208
|
+
* So the parent is checked, never repaired.
|
|
209
|
+
*/
|
|
210
|
+
function flowviantDirOk(checkoutDir) {
|
|
211
|
+
try {
|
|
212
|
+
const st = lstatSync(join(checkoutDir, '.flowviant'));
|
|
213
|
+
return st.isDirectory() && !st.isSymbolicLink();
|
|
214
|
+
} catch {
|
|
215
|
+
return true; // absent — ours to create
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The library directory, as a real directory. The PARENT is checked by the
|
|
220
|
+
* caller; the library path itself is OURS, so a symlink or a file planted
|
|
221
|
+
* there is removed (unlinked — the link, never its target) rather than
|
|
222
|
+
* followed. */
|
|
223
|
+
function ensureDir(checkoutDir) {
|
|
224
|
+
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
225
|
+
try {
|
|
226
|
+
const st = lstatSync(dir);
|
|
227
|
+
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(dir);
|
|
228
|
+
} catch {
|
|
229
|
+
/* absent */
|
|
230
|
+
}
|
|
231
|
+
mkdirSync(dir, { recursive: true });
|
|
232
|
+
return dir;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readKnowledgeMarker(checkoutDir) {
|
|
236
|
+
try {
|
|
237
|
+
const n = Number(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
|
|
238
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function writeKnowledgeMarker(checkoutDir, rev) {
|
|
245
|
+
try {
|
|
246
|
+
mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
|
|
247
|
+
writeAtomic(join(checkoutDir, MARKER), `${rev}\n`);
|
|
248
|
+
} catch {
|
|
249
|
+
/* a lost marker costs one re-sync after a restart — every file already on
|
|
250
|
+
disk matches by sha and is not downloaded again */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Does this manifest look like one the server produced? Anything else is
|
|
255
|
+
* ignored whole rather than half-applied. */
|
|
256
|
+
export function isKnowledgeManifest(k) {
|
|
257
|
+
return (
|
|
258
|
+
!!k &&
|
|
259
|
+
typeof k === 'object' &&
|
|
260
|
+
Number.isInteger(k.rev) &&
|
|
261
|
+
k.rev >= 0 &&
|
|
262
|
+
(k.instructions === null || k.instructions === undefined || typeof k.instructions === 'string') &&
|
|
263
|
+
Array.isArray(k.files)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Make `<checkoutDir>/.flowviant/knowledge/` match `manifest`.
|
|
269
|
+
*
|
|
270
|
+
* `fetchFile(id)` returns the file's bytes as a Buffer, or throws. Injected so
|
|
271
|
+
* the sync is testable against a temp directory and a fake server; the daemon
|
|
272
|
+
* hands the `/fleet/knowledge/:id` fetch.
|
|
273
|
+
*
|
|
274
|
+
* @returns {{ ok: boolean, wrote: string[], removed: string[], refused: string[], failed: string[] }}
|
|
275
|
+
* `ok` is false when any download FAILED (to be retried); a file REFUSED for
|
|
276
|
+
* size is permanent for this rev and does not clear `ok`.
|
|
277
|
+
*/
|
|
278
|
+
export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
|
|
279
|
+
const result = { ok: true, wrote: [], removed: [], refused: [], failed: [] };
|
|
280
|
+
const dirPath = join(checkoutDir, KNOWLEDGE_DIR);
|
|
281
|
+
const files = (manifest.files ?? [])
|
|
282
|
+
.filter((f) => f && typeof f.id === 'string' && /^[0-9a-f-]{8,64}$/i.test(f.id))
|
|
283
|
+
.slice(0, MAX_FILES);
|
|
284
|
+
const instructions =
|
|
285
|
+
typeof manifest.instructions === 'string' && manifest.instructions.trim()
|
|
286
|
+
? manifest.instructions
|
|
287
|
+
: null;
|
|
288
|
+
|
|
289
|
+
if (!flowviantDirOk(checkoutDir)) {
|
|
290
|
+
// Not a failure to retry every poll — nothing changes until a person
|
|
291
|
+
// changes it — but not a success either: the rev must not advance over a
|
|
292
|
+
// library that was never written.
|
|
293
|
+
result.ok = false;
|
|
294
|
+
result.failed.push('.flowviant (not a directory — left untouched)');
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// EMPTIED: the directory goes, and the prompt paragraph with it.
|
|
299
|
+
if (files.length === 0 && !instructions) {
|
|
300
|
+
if (existsSync(dirPath)) {
|
|
301
|
+
try {
|
|
302
|
+
const st = lstatSync(dirPath);
|
|
303
|
+
if (st.isSymbolicLink()) unlinkSync(dirPath);
|
|
304
|
+
else rmSync(dirPath, { recursive: true, force: true });
|
|
305
|
+
result.removed.push(KNOWLEDGE_DIR);
|
|
306
|
+
} catch {
|
|
307
|
+
result.ok = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const dir = ensureDir(checkoutDir);
|
|
314
|
+
const planned = planKnowledgeNames(files);
|
|
315
|
+
const keep = new Set(planned.map((p) => p.local));
|
|
316
|
+
if (instructions) keep.add(INSTRUCTIONS_FILE);
|
|
317
|
+
|
|
318
|
+
for (const f of planned) {
|
|
319
|
+
const path = join(dir, f.local);
|
|
320
|
+
if (Number(f.bytes) > maxBytes) {
|
|
321
|
+
// Permanent for this rev, and NOT a reason to delete the rest. Any stale
|
|
322
|
+
// local copy under this name goes, because it is not the file the
|
|
323
|
+
// manifest now names.
|
|
324
|
+
result.refused.push(f.local);
|
|
325
|
+
keep.delete(f.local);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
|
|
329
|
+
try {
|
|
330
|
+
const buf = await fetchFile(f.id);
|
|
331
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
|
|
332
|
+
result.refused.push(f.local);
|
|
333
|
+
keep.delete(f.local);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// The bytes must BE the file the manifest names. A mismatch is a
|
|
337
|
+
// failure to retry, never a file to trust.
|
|
338
|
+
if (typeof f.sha256 === 'string' && sha256Of(buf) !== f.sha256.toLowerCase()) {
|
|
339
|
+
throw new Error('sha256 mismatch');
|
|
340
|
+
}
|
|
341
|
+
writeAtomic(path, buf);
|
|
342
|
+
result.wrote.push(f.local);
|
|
343
|
+
} catch {
|
|
344
|
+
result.ok = false;
|
|
345
|
+
result.failed.push(f.local);
|
|
346
|
+
// Keep whatever copy is there: a stale file is better than a missing one
|
|
347
|
+
// until the retry lands.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (instructions) {
|
|
352
|
+
const path = join(dir, INSTRUCTIONS_FILE);
|
|
353
|
+
const body = instructions.endsWith('\n') ? instructions : `${instructions}\n`;
|
|
354
|
+
let same = false;
|
|
355
|
+
try {
|
|
356
|
+
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === body;
|
|
357
|
+
} catch {
|
|
358
|
+
/* absent */
|
|
359
|
+
}
|
|
360
|
+
if (!same) {
|
|
361
|
+
writeAtomic(path, body);
|
|
362
|
+
result.wrote.push(INSTRUCTIONS_FILE);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Everything the manifest no longer names — files, stray temp names, and
|
|
367
|
+
// anything that is not a regular file — leaves.
|
|
368
|
+
let entries = [];
|
|
369
|
+
try {
|
|
370
|
+
entries = readdirSync(dir);
|
|
371
|
+
} catch {
|
|
372
|
+
entries = [];
|
|
373
|
+
}
|
|
374
|
+
for (const name of entries) {
|
|
375
|
+
if (keep.has(name)) continue;
|
|
376
|
+
try {
|
|
377
|
+
rmSync(join(dir, name), { recursive: true, force: true });
|
|
378
|
+
result.removed.push(name);
|
|
379
|
+
} catch {
|
|
380
|
+
/* best-effort; the next sync tries again */
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The absolute library path, or null when there is nothing there for a turn
|
|
388
|
+
* to read. The prompt paragraph is rendered only when this is non-null, so a
|
|
389
|
+
* project with no library costs the prompt nothing.
|
|
390
|
+
*/
|
|
391
|
+
export function knowledgeDirFor(checkoutDir) {
|
|
392
|
+
if (!checkoutDir || !flowviantDirOk(checkoutDir)) return null;
|
|
393
|
+
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
394
|
+
try {
|
|
395
|
+
if (!lstatSync(dir).isDirectory()) return null;
|
|
396
|
+
return readdirSync(dir).length > 0 ? dir : null;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Does the manifest name something the disk no longer has a directory for?
|
|
403
|
+
* An EMPTIED manifest over no directory is the synced state, not a loss. */
|
|
404
|
+
function libraryMissing(checkoutDir, manifest) {
|
|
405
|
+
const named =
|
|
406
|
+
(Array.isArray(manifest.files) && manifest.files.length > 0) ||
|
|
407
|
+
(typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '');
|
|
408
|
+
if (!named) return false;
|
|
409
|
+
try {
|
|
410
|
+
lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
|
|
411
|
+
return false;
|
|
412
|
+
} catch {
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The per-process driver: called with every roster's `knowledge` key.
|
|
419
|
+
*
|
|
420
|
+
* Holds the last-materialised rev in memory (seeded from the marker, so a
|
|
421
|
+
* restart does not re-sync), serialises syncs (a slow download must never be
|
|
422
|
+
* overlapped by the next poll's), and backs off after a failure: 30s, 60s,
|
|
423
|
+
* 120s … capped at ten minutes. Never throws into the poll loop.
|
|
424
|
+
*/
|
|
425
|
+
export function createKnowledgeSync({
|
|
426
|
+
checkoutDir,
|
|
427
|
+
fetchFile,
|
|
428
|
+
onExclude = () => {},
|
|
429
|
+
log = () => {},
|
|
430
|
+
now = () => Date.now(),
|
|
431
|
+
}) {
|
|
432
|
+
let rev = readKnowledgeMarker(checkoutDir);
|
|
433
|
+
let busy = false;
|
|
434
|
+
let failures = 0;
|
|
435
|
+
let retryAt = 0;
|
|
436
|
+
/** The rev the backoff belongs to. A NEW rev is a new library and is tried
|
|
437
|
+
* at once — waiting out a backoff earned by a file the person has since
|
|
438
|
+
* removed would hold their next upload hostage to the last one's failure. */
|
|
439
|
+
let failedRev = null;
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
/** The rev last fully materialised, or null. */
|
|
443
|
+
get rev() {
|
|
444
|
+
return rev;
|
|
445
|
+
},
|
|
446
|
+
/** @returns {Promise<null | ReturnType<typeof syncKnowledge>>} null when
|
|
447
|
+
* nothing ran (absent key, same rev, busy, or backing off). */
|
|
448
|
+
async onRoster(knowledge) {
|
|
449
|
+
if (knowledge === undefined || knowledge === null) return null; // ABSENT: leave it alone
|
|
450
|
+
if (!isKnowledgeManifest(knowledge)) return null;
|
|
451
|
+
/**
|
|
452
|
+
* THE SAME REV IS NOT ALWAYS THE SAME DISK (2026-09-23). The marker says
|
|
453
|
+
* which rev was written; it cannot say the directory is still there. An
|
|
454
|
+
* `rm -r .flowviant/knowledge` — by hand, or by an agent tidying its
|
|
455
|
+
* checkout — left the marker naming the current rev, so every poll after
|
|
456
|
+
* it matched and returned, the prompt paragraph vanished with the
|
|
457
|
+
* directory (`knowledgeDirFor` answers null), and every turn ran without
|
|
458
|
+
* the library until somebody happened to edit it. A manifest that names
|
|
459
|
+
* something over a directory that does not exist is re-synced; files
|
|
460
|
+
* already on disk match by hash and are not fetched again.
|
|
461
|
+
*/
|
|
462
|
+
if (knowledge.rev === rev && !libraryMissing(checkoutDir, knowledge)) return null;
|
|
463
|
+
if (busy) return null;
|
|
464
|
+
if (knowledge.rev === failedRev && now() < retryAt) return null;
|
|
465
|
+
busy = true;
|
|
466
|
+
try {
|
|
467
|
+
try {
|
|
468
|
+
onExclude(checkoutDir);
|
|
469
|
+
} catch {
|
|
470
|
+
/* a convenience, never a gate */
|
|
471
|
+
}
|
|
472
|
+
const r = await syncKnowledge({ checkoutDir, manifest: knowledge, fetchFile });
|
|
473
|
+
if (r.ok) {
|
|
474
|
+
rev = knowledge.rev;
|
|
475
|
+
failures = 0;
|
|
476
|
+
retryAt = 0;
|
|
477
|
+
failedRev = null;
|
|
478
|
+
writeKnowledgeMarker(checkoutDir, rev);
|
|
479
|
+
if (r.wrote.length || r.removed.length) {
|
|
480
|
+
log(
|
|
481
|
+
`knowledge · synced rev ${rev}` +
|
|
482
|
+
(r.wrote.length ? ` · ${r.wrote.length} written` : '') +
|
|
483
|
+
(r.removed.length ? ` · ${r.removed.length} removed` : '')
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
failures = knowledge.rev === failedRev ? failures + 1 : 1;
|
|
488
|
+
failedRev = knowledge.rev;
|
|
489
|
+
retryAt = now() + Math.min(600_000, 30_000 * 2 ** (failures - 1));
|
|
490
|
+
log(`knowledge · ${r.failed.length} file(s) did not download — retrying`);
|
|
491
|
+
}
|
|
492
|
+
if (r.refused.length) {
|
|
493
|
+
log(`knowledge · skipped over the size cap: ${r.refused.join(', ')}`);
|
|
494
|
+
}
|
|
495
|
+
return r;
|
|
496
|
+
} catch (e) {
|
|
497
|
+
failures = knowledge.rev === failedRev ? failures + 1 : 1;
|
|
498
|
+
failedRev = knowledge.rev;
|
|
499
|
+
retryAt = now() + Math.min(600_000, 30_000 * 2 ** (failures - 1));
|
|
500
|
+
log(`knowledge · sync failed: ${e?.message ?? e}`);
|
|
501
|
+
return null;
|
|
502
|
+
} finally {
|
|
503
|
+
busy = false;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The `/fleet/knowledge/:id` download, in the attachment fetch's own shape:
|
|
511
|
+
* the machine credential as a bearer, a 60s ceiling, and the size checked on
|
|
512
|
+
* the header AND on the bytes (a lying header must not decide the cap). The
|
|
513
|
+
* URL is derived from the roster URL the way every `/fleet/*` path is, so a
|
|
514
|
+
* self-hosted `FLOWVIANT_FLEET_URL` is honoured.
|
|
515
|
+
*/
|
|
516
|
+
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
|
|
517
|
+
const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
|
|
518
|
+
return async (id) => {
|
|
519
|
+
const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
|
|
520
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
521
|
+
signal: AbortSignal.timeout(60_000),
|
|
522
|
+
});
|
|
523
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
524
|
+
if (Number(res.headers.get('content-length') ?? '0') > maxBytes) {
|
|
525
|
+
// Returned rather than thrown: over the cap is a REFUSAL, permanent for
|
|
526
|
+
// this rev, not a failure the backoff should keep retrying.
|
|
527
|
+
return Buffer.alloc(maxBytes + 1);
|
|
528
|
+
}
|
|
529
|
+
return Buffer.from(await res.arrayBuffer());
|
|
530
|
+
};
|
|
531
|
+
}
|
package/bin/lib/login.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
10
|
-
import { saveLogin, detectRepoRoot, projectLabel } from './credentials.mjs';
|
|
10
|
+
import { saveLogin, detectRepoRoot, projectLabel, listStoredProjects, boundElsewhere } from './credentials.mjs';
|
|
11
11
|
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
12
12
|
import { sleep } from './claude.mjs';
|
|
13
13
|
|
|
@@ -27,6 +27,58 @@ async function post(url, body) {
|
|
|
27
27
|
return j.data ?? j;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* THE THREE ANSWERS to "this repo is already connected to another project",
|
|
32
|
+
* as menu rows — pure, so the wording is provable. Each row says its whole
|
|
33
|
+
* consequence, the same rule the `machines` menu keeps: a verb whose outcome
|
|
34
|
+
* lives in a manual is one you have to be sure about before you can use it.
|
|
35
|
+
*
|
|
36
|
+
* keep both stay; `npx flowviant` in this repo asks which to serve.
|
|
37
|
+
* replace this repo serves the NEW project; this box is disconnected from
|
|
38
|
+
* the old one (its daemon here stopped, its row left in the app,
|
|
39
|
+
* its credential forgotten here) — the whole disconnect, because a
|
|
40
|
+
* half of it leaves a daemon running on a credential the store no
|
|
41
|
+
* longer names.
|
|
42
|
+
* cancel nothing is saved. The approval in the app minted or reused the
|
|
43
|
+
* project's credential either way; a credential nobody holds is a
|
|
44
|
+
* row, not a machine.
|
|
45
|
+
*/
|
|
46
|
+
export function secondProjectOptions(clash, entry, repoRoot) {
|
|
47
|
+
const olds = clash.map(projectLabel).join(' and ');
|
|
48
|
+
const now = projectLabel(entry);
|
|
49
|
+
return [
|
|
50
|
+
`keep both — every \`npx flowviant\` in ${repoRoot} asks whether to serve ${olds} or ${now}`,
|
|
51
|
+
`replace — ${repoRoot} serves ${now} from now on; this box is disconnected from ${olds} (its daemon here stopped, its credential forgotten here)`,
|
|
52
|
+
`cancel — save nothing; ${repoRoot} stays connected to ${olds}`,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DECISIONS = ['keep', 'replace', 'cancel'];
|
|
57
|
+
|
|
58
|
+
/** Ask, on whatever this terminal can draw. No terminal to ask on: KEEP, and
|
|
59
|
+
* say so — a headless login must not hang, and refusing would strand a
|
|
60
|
+
* runner that had just been approved in the app. */
|
|
61
|
+
async function secondProjectDecision({ clash, entry, repoRoot }) {
|
|
62
|
+
const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./tty.mjs');
|
|
63
|
+
const olds = clash.map(projectLabel).join(' and ');
|
|
64
|
+
console.log('');
|
|
65
|
+
warn(`${repoRoot} is already connected to ${olds} on this box.`);
|
|
66
|
+
if (!canPrompt()) {
|
|
67
|
+
console.log(` keeping both — every \`npx flowviant\` there will ask which to serve; \`flowviant machines\` removes one.\n`);
|
|
68
|
+
return 'keep';
|
|
69
|
+
}
|
|
70
|
+
const options = secondProjectOptions(clash, entry, repoRoot);
|
|
71
|
+
if (menuSupported()) {
|
|
72
|
+
const res = await selectMenu({ options });
|
|
73
|
+
if (res.cancelled) return 'cancel';
|
|
74
|
+
if (!res.unsupported) return DECISIONS[res.index];
|
|
75
|
+
}
|
|
76
|
+
console.log(options.map((o, i) => ` ${i + 1}. ${o}`).join('\n'));
|
|
77
|
+
const raw = await askWithTimeout(' Which? [1-3] ');
|
|
78
|
+
const n = Number.parseInt(raw ?? '', 10);
|
|
79
|
+
return DECISIONS[n - 1] ?? 'cancel';
|
|
80
|
+
}
|
|
81
|
+
|
|
30
82
|
export async function runLogin({ thenStart = false } = {}) {
|
|
31
83
|
console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
|
|
32
84
|
let start;
|
|
@@ -73,6 +125,29 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
73
125
|
name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
|
|
74
126
|
repoRoot,
|
|
75
127
|
};
|
|
128
|
+
// A SECOND PROJECT ON A REPO IS A QUESTION, NEVER A SILENT SAVE
|
|
129
|
+
// (2026-09-23). The owner: "im not sure how it even allowed me to run
|
|
130
|
+
// npx flowviant login twice and init a daemon twice on the same
|
|
131
|
+
// project/repository/directory in the first place." It allowed it
|
|
132
|
+
// because it never looked: the approval names a project, this repo was
|
|
133
|
+
// already bound to a different one, and both landed in the store — after
|
|
134
|
+
// which every start here was a picker between two rows that read alike.
|
|
135
|
+
// Asked while the person is still at the terminal; headless, the save
|
|
136
|
+
// goes ahead and the fact is printed, because a CI runner cannot answer
|
|
137
|
+
// and a login that silently refused would be worse than one that said.
|
|
138
|
+
const clash = boundElsewhere(listStoredProjects(), repoRoot, entry.projectId);
|
|
139
|
+
if (clash.length > 0) {
|
|
140
|
+
const decision = await secondProjectDecision({ clash, entry, repoRoot });
|
|
141
|
+
if (decision === 'cancel') {
|
|
142
|
+
console.log(`\n nothing saved — ${repoRoot} stays connected to ${clash.map(projectLabel).join(' and ')}.\n`);
|
|
143
|
+
return { saved: false };
|
|
144
|
+
}
|
|
145
|
+
if (decision === 'replace') {
|
|
146
|
+
const { disconnectHere, realDisconnectDeps, leaveUrlFrom } = await import('./machines.mjs');
|
|
147
|
+
const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: (m) => console.log(` ${m}`) });
|
|
148
|
+
for (const old of clash) await disconnectHere(old, deps, { log: (m) => console.log(` ${m}`) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
76
151
|
saveLogin(entry);
|
|
77
152
|
ok(
|
|
78
153
|
`connected to ${c.bold(projectLabel(entry))}` +
|
|
@@ -86,7 +161,7 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
86
161
|
? `\n ${c.dim('starting your agent — leave this running')}\n`
|
|
87
162
|
: `\n Now run: ${c.bold('npx flowviant')}\n`
|
|
88
163
|
);
|
|
89
|
-
return;
|
|
164
|
+
return { saved: true };
|
|
90
165
|
}
|
|
91
166
|
if (poll.status === 'expired') {
|
|
92
167
|
warn('that code expired — run `flowviant login` again.');
|