flowviant 0.102.2 → 0.104.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 +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/admission.mjs
CHANGED
|
@@ -80,7 +80,23 @@
|
|
|
80
80
|
*/
|
|
81
81
|
|
|
82
82
|
import { MAX_CONCURRENT, MAX_CONCURRENT_FROM_ENV } from './config.mjs';
|
|
83
|
-
import {
|
|
83
|
+
import { measurePressure } from './resources.mjs';
|
|
84
|
+
import { pressureVerdict, readPressureThresholds } from './pressurePolicy.mjs';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* THE PRESSURE HALF OF AN ADMISSION, AS THE DAEMON ASKS IT: this box's
|
|
88
|
+
* (briefly cached) measurement, judged against the thresholds the environment
|
|
89
|
+
* states AT THIS CALL. Both are read per decision — an operator who exports
|
|
90
|
+
* `FLOWVIANT_MIN_FREE_MB` into a running daemon's environment is heard on the
|
|
91
|
+
* next spawn. Injectable only so that promise can be pinned; `createAdmission`
|
|
92
|
+
* passes nothing. A guard switched off measures nothing (no `vm_stat` spawn
|
|
93
|
+
* per decision on macOS), as before the split.
|
|
94
|
+
*/
|
|
95
|
+
export function livePressureVerdict(level, { measure = measurePressure, env = process.env } = {}) {
|
|
96
|
+
const thresholds = readPressureThresholds(env);
|
|
97
|
+
if (thresholds.off) return null;
|
|
98
|
+
return pressureVerdict(level, measure(), thresholds);
|
|
99
|
+
}
|
|
84
100
|
|
|
85
101
|
/**
|
|
86
102
|
* ── THE CEILING HAS THREE POSSIBLE AUTHORS (2026-09-17) ──
|
|
@@ -127,7 +143,7 @@ export function pickMaxTurns({ env = null, server = null, derived = 1 } = {}) {
|
|
|
127
143
|
* A module holder rather than a value threaded through `createWorkManager`,
|
|
128
144
|
* because the reader and the writer are two files apart and one poll apart: the
|
|
129
145
|
* POLL learns it (fleet.mjs) and every ADMISSION reads it, including the ones
|
|
130
|
-
* work
|
|
146
|
+
* the work manager takes in lanes fleet.mjs never sees (workChildren.mjs builds it). The alternative — passing it
|
|
131
147
|
* down — would mean the number that bound a spawn was whatever was current when
|
|
132
148
|
* the manager was CONSTRUCTED, which is exactly the frozen-at-startup bug
|
|
133
149
|
* `getBaseRef` and `getLeaseTtl` are getters to avoid.
|
|
@@ -171,7 +187,7 @@ export function createAdmission({
|
|
|
171
187
|
* A number is still accepted, and every test here passes one — the whole
|
|
172
188
|
* point of injecting it. */
|
|
173
189
|
maxConcurrent = effectiveMaxTurns,
|
|
174
|
-
verdict =
|
|
190
|
+
verdict = livePressureVerdict,
|
|
175
191
|
} = {}) {
|
|
176
192
|
/** Slots taken by a decision whose process does not exist yet. A Set of
|
|
177
193
|
* tokens rather than a counter: a double release then costs nothing, which
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
// THE UPLINK SCRUBBER, as the DEFAULT for the two readers whose every string
|
|
29
29
|
// goes to the server (2026-09-24, the audit). See `parseTurnResult`.
|
|
30
|
-
import { scrub as envScrub } from './
|
|
30
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
31
31
|
|
|
32
32
|
/** The biggest a single proposal may be. Bounds on a machine, not a policy:
|
|
33
33
|
* the server caps these again at its own boundary. */
|
|
@@ -277,7 +277,7 @@ function uniqueTempIds(list) {
|
|
|
277
277
|
* summary and the wire, and a cap must never run ahead of it. So this is the
|
|
278
278
|
* same absurdity bound `summary` takes one line up (8000): enough that nothing
|
|
279
279
|
* a model writes reaches the caller unbounded, far enough above the 1000 the
|
|
280
|
-
* wire cuts to that the scrub always sees whole values. `
|
|
280
|
+
* wire cuts to that the scrub always sees whole values. `workAgentTurns.mjs` scrubs and
|
|
281
281
|
* THEN cuts to 1000, and the server clamps again because the wire never trusts
|
|
282
282
|
* a machine to have clamped.
|
|
283
283
|
*/
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CARD'S KIND — the daemon's one table of what each kind IS (0.97.0):
|
|
3
|
+
* `code` (the default), `design` and `research`. Every reader of a kind goes
|
|
4
|
+
* through this module: the prompts that print it and pick a contract
|
|
5
|
+
* (prompts.mjs, which keeps the strings), the agent lane that picks a posture,
|
|
6
|
+
* refuses a runtime (workAgentTurnExecution.mjs) and checks the artifact
|
|
7
|
+
* (workAgentTurnOutcome.mjs), and the turn runner that fences the non-code
|
|
8
|
+
* postures (runTurn.mjs and claudePosture.mjs, via turnProfile.mjs).
|
|
9
|
+
*
|
|
10
|
+
* Split out (SOLID F041) because the kind was branched on at five sites — the
|
|
11
|
+
* vocabulary twice in prompts.mjs, the posture and the artifact rule in the
|
|
12
|
+
* agent lane, the posture names again in claude.mjs — so a fourth kind was a
|
|
13
|
+
* coordinated edit whose missed site ran a card under the wrong posture. Now a
|
|
14
|
+
* kind is one entry here, and `agentTaskKinds.test.mjs` proves every entry has
|
|
15
|
+
* a contract, a posture a runtime can declare, and (for a non-code kind) the
|
|
16
|
+
* artifact that proves it delivered.
|
|
17
|
+
*
|
|
18
|
+
* Pure data and pure functions: no imports, no environment, no I/O — prompts
|
|
19
|
+
* imports it, and prompts is strings.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The kinds. Per kind:
|
|
24
|
+
* - `posture`: the turn profile the card runs under (turnProfile.mjs);
|
|
25
|
+
* - `artifact`: null for code (it delivers commits), else what proves a
|
|
26
|
+
* delivery — `match` over the artifact file name and the `missing`
|
|
27
|
+
* sentence a turn settles `nothing` with when none was written;
|
|
28
|
+
* - `offRuntime(rt)`: the sentence when the chosen CLI cannot run the posture.
|
|
29
|
+
*/
|
|
30
|
+
export const AGENT_TASK_KINDS = Object.freeze({
|
|
31
|
+
code: Object.freeze({
|
|
32
|
+
posture: 'build',
|
|
33
|
+
artifact: null,
|
|
34
|
+
offRuntime: (rt) => `this machine cannot run ${rt}`,
|
|
35
|
+
}),
|
|
36
|
+
design: Object.freeze({
|
|
37
|
+
posture: 'design',
|
|
38
|
+
artifact: Object.freeze({
|
|
39
|
+
match: /\.html?$/i,
|
|
40
|
+
missing: 'the turn ended without writing a mockup under .flowviant/artifacts/',
|
|
41
|
+
}),
|
|
42
|
+
offRuntime: () => 'design and research cards run on Claude on this machine',
|
|
43
|
+
}),
|
|
44
|
+
research: Object.freeze({
|
|
45
|
+
posture: 'research',
|
|
46
|
+
artifact: Object.freeze({
|
|
47
|
+
match: /\.md$/i,
|
|
48
|
+
missing: 'the turn ended without writing a write-up under .flowviant/artifacts/',
|
|
49
|
+
}),
|
|
50
|
+
offRuntime: () => 'design and research cards run on Claude on this machine',
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const known = (v) => typeof v === 'string' && Object.hasOwn(AGENT_TASK_KINDS, v);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A CARD'S KIND, READ THE ONE WAY THE DAEMON READS IT.
|
|
58
|
+
*
|
|
59
|
+
* The server sends the key only for non-code kinds, so ABSENT IS CODE, and an
|
|
60
|
+
* unknown string (a newer server, a typo) degrades to code: the one kind that
|
|
61
|
+
* changes nothing about how a card is PRINTED. Every printing reader — the
|
|
62
|
+
* contract selector, the kickoff, the spec — goes through this, so no two of
|
|
63
|
+
* them can disagree about what an odd value means.
|
|
64
|
+
*/
|
|
65
|
+
export const agentTaskKindOf = (v) => (known(v) ? v : 'code');
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* …EXCEPT WHERE A TURN IS ABOUT TO SPAWN (2026-09-23). "Unknown degrades to
|
|
69
|
+
* code" is right for every reader that only PRINTS the kind, and wrong for the
|
|
70
|
+
* one that picks a POSTURE: code is the build turn, permissions skipped, so a
|
|
71
|
+
* kind a newer server invented for a card that must not touch the code (a
|
|
72
|
+
* fourth kind, a typo, `Design`) would have run as exactly the turn it exists
|
|
73
|
+
* to prevent. The agent lane asks this first and refuses in words, relaying
|
|
74
|
+
* the word it was handed. Absent, null and empty stay code — that is how a
|
|
75
|
+
* code card arrives.
|
|
76
|
+
*
|
|
77
|
+
* @returns {string|null} the unrecognised value, or null when the kind is one
|
|
78
|
+
* this daemon runs.
|
|
79
|
+
*/
|
|
80
|
+
export const unknownAgentTaskKind = (v) =>
|
|
81
|
+
v === undefined || v === null || v === '' || known(v)
|
|
82
|
+
? null
|
|
83
|
+
: String(v).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 40);
|
|
84
|
+
|
|
85
|
+
/** The table entry for a kind value, read the printing way (unknown → code). */
|
|
86
|
+
export const agentTaskKind = (v) => AGENT_TASK_KINDS[agentTaskKindOf(v)];
|
|
87
|
+
|
|
88
|
+
/** The postures only a card's kind selects (every kind's but code's). */
|
|
89
|
+
export const KIND_POSTURES = Object.freeze(
|
|
90
|
+
Object.values(AGENT_TASK_KINDS)
|
|
91
|
+
.map((k) => k.posture)
|
|
92
|
+
.filter((p) => p !== 'build')
|
|
93
|
+
);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANTIGRAVITY (agy) SESSION PRESENCE (split out of localSessions.mjs
|
|
3
|
+
* 2026-09-26, SOLID F062). agy's SQLite store, its cwd cache and its
|
|
4
|
+
* process-name liveness change for reasons of their own, apart from Claude's
|
|
5
|
+
* transcript format (claudeSessions.mjs); the shared fence and report cap are
|
|
6
|
+
* the coordinator's (localSessions.mjs).
|
|
7
|
+
*
|
|
8
|
+
* NOTHING in this file throws.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { procPids, scanProcs } from './procScan.mjs';
|
|
16
|
+
|
|
17
|
+
// agy's store is nothing like Claude's: one SQLite db per conversation at
|
|
18
|
+
// ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
|
|
19
|
+
// no per-pid liveness registry that survives contact (the presence/*.lock
|
|
20
|
+
// files sit untouched by real runs — measured), and the only cwd mapping is
|
|
21
|
+
// cache/last_conversations.json: {cwd → the LAST conversation run there}.
|
|
22
|
+
// So the honest agy report is a SUBSET — the last conversation per directory
|
|
23
|
+
// inside this repo — and that is exactly the one `agy --continue` would give
|
|
24
|
+
// the person at that keyboard, i.e. the one worth offering to adopt.
|
|
25
|
+
|
|
26
|
+
const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
|
|
27
|
+
const AGY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
|
|
29
|
+
/** Newest write to the conversation's store — the wal carries recent turns,
|
|
30
|
+
* so its mtime (not the db's) is the real "last active" (measured: a resume
|
|
31
|
+
* touched db+wal, and never the presence lock). 0 = no such conversation. */
|
|
32
|
+
function agyLastWriteMs(id) {
|
|
33
|
+
if (!AGY_UUID_RE.test(id)) return 0;
|
|
34
|
+
let newest = 0;
|
|
35
|
+
for (const suffix of ['.db', '.db-wal']) {
|
|
36
|
+
try {
|
|
37
|
+
const t = statSync(join(AGY_DIR(), 'conversations', `${id}${suffix}`)).mtimeMs;
|
|
38
|
+
if (t > newest) newest = t;
|
|
39
|
+
} catch {
|
|
40
|
+
/* absent half is fine — the db alone still answers */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return newest;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Any agy process on the machine right now? /proc comm scan — cheap at the
|
|
47
|
+
* 60s cadence, and the only liveness signal agy leaves (locks are inert).
|
|
48
|
+
* Tri-state on purpose: true (an agy process exists), false (scanned /proc
|
|
49
|
+
* and found none), null (no /proc to scan — macOS — so the question is
|
|
50
|
+
* UNANSWERABLE here, which is a different fact from "no", and the adoption
|
|
51
|
+
* path below treats it differently: unknowable refuses, absent permits). */
|
|
52
|
+
function agyProcessAlive() {
|
|
53
|
+
if (!existsSync('/proc')) {
|
|
54
|
+
// No /proc (macOS): ask pgrep the same question. A MEASURED "none" here
|
|
55
|
+
// matters — answering null instead made every agy conversation on such a
|
|
56
|
+
// machine read live forever, which both invented a state ("live" when the
|
|
57
|
+
// truth was "unmeasured") and permanently killed agy adoption there.
|
|
58
|
+
try {
|
|
59
|
+
execFileSync('pgrep', ['-x', 'agy'], {
|
|
60
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
61
|
+
timeout: 5_000,
|
|
62
|
+
});
|
|
63
|
+
return true; // exit 0 — at least one agy process
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (e?.status === 1) return false; // pgrep ran and found none
|
|
66
|
+
return null; // pgrep itself unavailable/errored — genuinely unknowable
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// The /proc walk is procScan's (every pid looked at; a bound of ONE row,
|
|
70
|
+
// because the first agy answers the question).
|
|
71
|
+
const pids = procPids();
|
|
72
|
+
if (pids === null) return null; // /proc exists but won't read — still unknowable
|
|
73
|
+
const isAgy = (name) => {
|
|
74
|
+
try {
|
|
75
|
+
return readFileSync(`/proc/${name}/comm`, 'utf8').trim() === 'agy' ? true : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null; // raced exit — keep scanning
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return scanProcs(pids, isAgy, 1).rows.length > 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is this agy conversation being driven RIGHT NOW? agy cannot answer
|
|
85
|
+
* per-conversation, so this is the conservative composite: an agy process
|
|
86
|
+
* exists AND this conversation's store was written in the last 10 minutes.
|
|
87
|
+
* Adoption is a MOVE for agy (no fork exists — measured, "trajectory not
|
|
88
|
+
* found" on a renamed copy), so refusing a maybe-live conversation for a few
|
|
89
|
+
* minutes costs a retry; adopting an actually-live one puts two drivers on
|
|
90
|
+
* one store.
|
|
91
|
+
*/
|
|
92
|
+
const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
|
|
93
|
+
export function isAgyConversationLive(id, { processAlive = agyProcessAlive } = {}) {
|
|
94
|
+
try {
|
|
95
|
+
const up = processAlive();
|
|
96
|
+
// Unknowable is NOT "ended". Without /proc the process half of the
|
|
97
|
+
// composite cannot be measured, and calling that "no process" would make
|
|
98
|
+
// every agy conversation on such a machine adoptable — the move-adoption
|
|
99
|
+
// then puts a second driver on a store someone may still be writing.
|
|
100
|
+
// "Live" here means "refuse adoption", and refusing what we cannot verify
|
|
101
|
+
// is the same conservatism as the composite itself.
|
|
102
|
+
if (up === null) return true;
|
|
103
|
+
if (!up) return false;
|
|
104
|
+
const t = agyLastWriteMs(id);
|
|
105
|
+
return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The repo's agy conversations, via the cwd registry — see the section
|
|
112
|
+
* comment for why this is deliberately a subset. The coordinator hands over
|
|
113
|
+
* the fence (`ours`, `mine`) and the ended-session `cutoff`; `processAlive`
|
|
114
|
+
* measures liveness (true / false / null = unknowable), asked once and only
|
|
115
|
+
* when there is a registry to read. */
|
|
116
|
+
export function scanAgyConversations({ ours, mine, cutoff, processAlive = agyProcessAlive }) {
|
|
117
|
+
const out = [];
|
|
118
|
+
try {
|
|
119
|
+
const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
|
|
120
|
+
const map = JSON.parse(raw);
|
|
121
|
+
if (!map || typeof map !== 'object') return out;
|
|
122
|
+
const processUp = processAlive();
|
|
123
|
+
for (const [cwd, id] of Object.entries(map)) {
|
|
124
|
+
if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
|
|
125
|
+
// The operator's own agy TAB, whose conversation is registered against
|
|
126
|
+
// the checkout exactly as a terminal one would be.
|
|
127
|
+
if (mine.has(id)) continue;
|
|
128
|
+
let real;
|
|
129
|
+
try {
|
|
130
|
+
real = realpathSync(cwd);
|
|
131
|
+
} catch {
|
|
132
|
+
continue; // the directory is gone — nothing to point a tab at
|
|
133
|
+
}
|
|
134
|
+
if (!ours(real)) continue;
|
|
135
|
+
const lastMs = agyLastWriteMs(id);
|
|
136
|
+
if (!lastMs || lastMs < cutoff) continue;
|
|
137
|
+
out.push({
|
|
138
|
+
id,
|
|
139
|
+
cwd: real,
|
|
140
|
+
// Mirrors isAgyConversationLive exactly, unknowable (null) included:
|
|
141
|
+
// the report must never offer as adoptable a conversation the adopt
|
|
142
|
+
// path will then refuse — an offer wired to a refusal is the dead
|
|
143
|
+
// control this product keeps deleting.
|
|
144
|
+
live: processUp === null ? true : processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
|
|
145
|
+
lastActiveAt: new Date(lastMs).toISOString(),
|
|
146
|
+
runtime: 'antigravity',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
out.sort(
|
|
150
|
+
(a, b) =>
|
|
151
|
+
(b.lastActiveAt < a.lastActiveAt ? -1 : b.lastActiveAt > a.lastActiveAt ? 1 : 0) ||
|
|
152
|
+
(a.id < b.id ? -1 : 1)
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
/* no agy on this machine, or an unreadable registry — nothing to report */
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ARTIFACT POLICY — GENERATED, DO NOT EDIT BY HAND.
|
|
3
|
+
*
|
|
4
|
+
* Rendered from the server's packages/shared/src/schemas/artifact.schema.ts
|
|
5
|
+
* by flowviant's packages/shared/src/schemas/daemonArtifactPolicy.ts
|
|
6
|
+
* (`bun scripts/write-daemon-artifact-policy.ts` in the app repo). The server
|
|
7
|
+
* is the release source; its parity test fails until this file matches it.
|
|
8
|
+
* artifacts.mjs reads the allowlist and the caps from here, and the server
|
|
9
|
+
* still verifies every upload on its own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ARTIFACT_POLICY_SOURCE = 'packages/shared/src/schemas/artifact.schema.ts';
|
|
13
|
+
|
|
14
|
+
/** Per text file and ordinary binary artifact. */
|
|
15
|
+
export const ARTIFACT_MAX_BYTES = 2097152;
|
|
16
|
+
/** Binary model buffers (the extensions below). */
|
|
17
|
+
export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 20971520;
|
|
18
|
+
export const ARTIFACT_BINARY_MODEL_EXTS = ['glb', 'bin'];
|
|
19
|
+
/** Per session and per agent — the server keeps as many per owner. */
|
|
20
|
+
export const ARTIFACT_MAX_PER_OWNER = 40;
|
|
21
|
+
|
|
22
|
+
/** Extension → the one mime the server serves it under; `text` is scrubbed. */
|
|
23
|
+
export const ARTIFACT_TYPES = {
|
|
24
|
+
html: { mime: 'text/html', text: true },
|
|
25
|
+
htm: { mime: 'text/html', text: true },
|
|
26
|
+
md: { mime: 'text/markdown', text: true },
|
|
27
|
+
svg: { mime: 'image/svg+xml', text: true },
|
|
28
|
+
png: { mime: 'image/png', text: false },
|
|
29
|
+
jpg: { mime: 'image/jpeg', text: false },
|
|
30
|
+
jpeg: { mime: 'image/jpeg', text: false },
|
|
31
|
+
gif: { mime: 'image/gif', text: false },
|
|
32
|
+
webp: { mime: 'image/webp', text: false },
|
|
33
|
+
json: { mime: 'application/json', text: true },
|
|
34
|
+
csv: { mime: 'text/csv', text: true },
|
|
35
|
+
txt: { mime: 'text/plain', text: true },
|
|
36
|
+
docx: { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', text: false },
|
|
37
|
+
pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
|
|
38
|
+
xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
|
|
39
|
+
pdf: { mime: 'application/pdf', text: false },
|
|
40
|
+
gltf: { mime: 'model/gltf+json', text: true },
|
|
41
|
+
obj: { mime: 'model/obj', text: true },
|
|
42
|
+
glb: { mime: 'model/gltf-binary', text: false },
|
|
43
|
+
bin: { mime: 'application/octet-stream', text: false },
|
|
44
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DESIGN PREVIEW — a screenshot of an HTML artifact, taken in a browser
|
|
3
|
+
* (2026-09-26, split out of artifacts.mjs — SOLID F051).
|
|
4
|
+
*
|
|
5
|
+
* Browser rendering is its own reason to change: a Chrome flag, a CSP tweak or
|
|
6
|
+
* a viewport size has nothing to do with how the artifact directory is scanned
|
|
7
|
+
* or how an upload is retried. The reporter (`artifactReporter.mjs`) is handed
|
|
8
|
+
* this function rather than importing it, so a test can stand in a renderer
|
|
9
|
+
* that never launches a browser.
|
|
10
|
+
*
|
|
11
|
+
* It renders the SCRUBBED bytes, never the checkout's source, under the
|
|
12
|
+
* artifact box's own network fence (`PREVIEW_CSP`) placed before any
|
|
13
|
+
* model-written markup executes. A failed measurement is explicit
|
|
14
|
+
* (`renderState: 'unavailable'`); a daemon older than this simply sends no
|
|
15
|
+
* `renderState`, which the server reads as unmeasured.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import { captureScreenshot } from './shot.mjs';
|
|
23
|
+
|
|
24
|
+
export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
26
|
+
|
|
27
|
+
/** Render the scrubbed HTML, never the checkout's unsanitized source. A
|
|
28
|
+
* failed browser measurement is explicit; older daemon reports are absent. */
|
|
29
|
+
export async function renderDesignPreview(bytes, capture = captureScreenshot) {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
|
|
31
|
+
try {
|
|
32
|
+
const html = join(dir, 'design.html');
|
|
33
|
+
const png = join(dir, 'design.png');
|
|
34
|
+
// A fresh Chrome profile has no Flowviant credentials. Put the artifact
|
|
35
|
+
// box's network fence before any model-written markup executes.
|
|
36
|
+
writeFileSync(html, Buffer.concat([
|
|
37
|
+
Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`),
|
|
38
|
+
bytes,
|
|
39
|
+
]));
|
|
40
|
+
const result = await capture({ url: pathToFileURL(html).href, out: png, width: 720, height: 450, timeoutMs: 15_000 });
|
|
41
|
+
if (!result.ok) return { renderState: 'unavailable' };
|
|
42
|
+
const image = readFileSync(png);
|
|
43
|
+
if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
|
|
44
|
+
return { renderState: 'unavailable' };
|
|
45
|
+
}
|
|
46
|
+
return { renderState: 'rendered', preview: image };
|
|
47
|
+
} catch {
|
|
48
|
+
return { renderState: 'unavailable' };
|
|
49
|
+
} finally {
|
|
50
|
+
rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ARTIFACT REPORTER — the queued POST to `/fleet/artifact` and its bounded
|
|
3
|
+
* retries (2026-09-26, split out of artifacts.mjs — SOLID F051).
|
|
4
|
+
*
|
|
5
|
+
* Network delivery is its own reason to change, apart from what is on the disk
|
|
6
|
+
* (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
|
|
7
|
+
* reporter imports neither: it is HANDED the three functions it runs —
|
|
8
|
+
* `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
|
|
9
|
+
* owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
|
|
10
|
+
* screenshot) — so the delivery rules below are tested against a fake server
|
|
11
|
+
* and a fake renderer, and a new source of bodies would not edit this file.
|
|
12
|
+
*
|
|
13
|
+
* ── DELIVERY ──
|
|
14
|
+
*
|
|
15
|
+
* A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
|
|
16
|
+
* older SERVER 404s the whole route, and holding bodies it will refuse forever
|
|
17
|
+
* would be the trace relay's wedge wearing a retry's clothes; a refusal of one
|
|
18
|
+
* file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
|
|
19
|
+
* network error keeps the STORED BODY — the fields and the bytes as they were
|
|
20
|
+
* scrubbed — and retries it on the next report beat, the settle's shape, at
|
|
21
|
+
* most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
|
|
22
|
+
*
|
|
23
|
+
* A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
|
|
24
|
+
* `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
28
|
+
|
|
29
|
+
/** A held upload is tried this many times in all, then dropped with a line. */
|
|
30
|
+
const MAX_TRIES = 4;
|
|
31
|
+
/** Held bodies across every owner — each can be two megabytes. */
|
|
32
|
+
const MAX_PENDING = 40;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The reporter a work manager holds for its life. `report` is called once per
|
|
36
|
+
* finished turn and never awaited by the turn (a slow uplink must not hold the
|
|
37
|
+
* next turn behind a readout); `retryPending` rides the settle retry beat.
|
|
38
|
+
*/
|
|
39
|
+
export function createArtifactReporter({
|
|
40
|
+
fleetUrl,
|
|
41
|
+
token,
|
|
42
|
+
userAgent,
|
|
43
|
+
scrub,
|
|
44
|
+
secretIn,
|
|
45
|
+
fetchImpl,
|
|
46
|
+
log = () => {},
|
|
47
|
+
listChanged,
|
|
48
|
+
buildUpload,
|
|
49
|
+
renderPreview,
|
|
50
|
+
}) {
|
|
51
|
+
const url = fleetEndpoint('artifact', fleetUrl);
|
|
52
|
+
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
53
|
+
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
54
|
+
|
|
55
|
+
/** true = settled (delivered or permanently refused); false = hold it. */
|
|
56
|
+
const post = async (body) => {
|
|
57
|
+
const form = new FormData();
|
|
58
|
+
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
59
|
+
if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
|
|
60
|
+
if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
|
|
61
|
+
try {
|
|
62
|
+
const res = await doFetch(url, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
65
|
+
signal: AbortSignal.timeout(30_000),
|
|
66
|
+
body: form,
|
|
67
|
+
});
|
|
68
|
+
return (
|
|
69
|
+
res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
70
|
+
);
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const hold = (key, body, tries) => {
|
|
77
|
+
if (tries >= MAX_TRIES) {
|
|
78
|
+
pending.delete(key);
|
|
79
|
+
log(`artifact ${body.fields.name}: not delivered after ${tries} tries — dropped`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
|
|
83
|
+
pending.set(key, { body, tries });
|
|
84
|
+
while (pending.size > MAX_PENDING) {
|
|
85
|
+
const shed = pending.keys().next().value;
|
|
86
|
+
pending.delete(shed);
|
|
87
|
+
if (!chains.has(shed)) genOf.delete(shed);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ONE COPY OF A FILE IS IN FLIGHT AT A TIME, AND ONLY THE NEWEST MAY
|
|
92
|
+
// DECIDE. Each report of a (owner, name) takes a fresh generation, and every
|
|
93
|
+
// post for that key is chained behind the previous one — so a slow retry of
|
|
94
|
+
// an OLD body can neither land on the server after the newer copy (the
|
|
95
|
+
// server upserts by name, so the last write wins) nor re-hold itself over
|
|
96
|
+
// the newer one when it fails. A body superseded before it went out is not
|
|
97
|
+
// sent at all; one superseded while in flight has its answer discarded.
|
|
98
|
+
let generation = 0;
|
|
99
|
+
const genOf = new Map(); // key -> the generation allowed to decide
|
|
100
|
+
const chains = new Map(); // key -> the tail of that key's post chain
|
|
101
|
+
|
|
102
|
+
const send = (key, body, tries, gen) => {
|
|
103
|
+
const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
|
|
104
|
+
if (genOf.get(key) !== gen) return; // superseded before it went out
|
|
105
|
+
const ok = await post(body);
|
|
106
|
+
if (genOf.get(key) !== gen) return; // a newer copy decides for this key
|
|
107
|
+
if (ok) pending.delete(key);
|
|
108
|
+
else hold(key, body, tries + 1);
|
|
109
|
+
});
|
|
110
|
+
const tail = run.catch(() => {});
|
|
111
|
+
chains.set(key, tail);
|
|
112
|
+
tail.then(() => {
|
|
113
|
+
if (chains.get(key) !== tail) return;
|
|
114
|
+
chains.delete(key);
|
|
115
|
+
if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
|
|
116
|
+
});
|
|
117
|
+
return run;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
let retrying = false;
|
|
121
|
+
const retryPending = async () => {
|
|
122
|
+
if (retrying || pending.size === 0) return;
|
|
123
|
+
retrying = true;
|
|
124
|
+
try {
|
|
125
|
+
for (const [key, held] of [...pending]) {
|
|
126
|
+
if (pending.get(key) !== held) continue; // superseded meanwhile
|
|
127
|
+
await send(key, held.body, held.tries, genOf.get(key));
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
retrying = false;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
135
|
+
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
136
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
|
|
137
|
+
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
138
|
+
const changed = listChanged(placeDir, before);
|
|
139
|
+
let n = 0;
|
|
140
|
+
for (const entry of changed) {
|
|
141
|
+
const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
|
|
142
|
+
if (!body) continue;
|
|
143
|
+
if (body.withheld) {
|
|
144
|
+
log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (body.bytes && /\.html?$/i.test(entry.name)) {
|
|
148
|
+
const measured = await renderPreview(body.bytes);
|
|
149
|
+
body.fields.renderState = measured.renderState;
|
|
150
|
+
if (measured.preview) body.preview = measured.preview;
|
|
151
|
+
}
|
|
152
|
+
const key = `${sessionId ?? agentId}:${entry.name}`;
|
|
153
|
+
pending.delete(key); // this copy supersedes a held older one
|
|
154
|
+
const gen = ++generation;
|
|
155
|
+
genOf.set(key, gen); // …and one still in flight
|
|
156
|
+
await send(key, body, 0, gen);
|
|
157
|
+
n++;
|
|
158
|
+
}
|
|
159
|
+
return n;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return { report, retryPending, pendingCount: () => pending.size };
|
|
163
|
+
}
|