flowviant 0.74.2 → 0.76.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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * READING A PLANNER'S ANSWER.
3
+ *
4
+ * The scratch agent behind a Deploy press is asked for one JSON object and
5
+ * nothing else. This is what turns its final message into a proposal, and it is
6
+ * its own module because it is the one piece of that turn that can quietly LIE:
7
+ * everything else either works or throws, while a half-parsed proposal renders
8
+ * as a plausible-looking board somebody accepts.
9
+ *
10
+ * LENIENT ON PACKAGING, STRICT ON SHAPE.
11
+ *
12
+ * A model asked for one object will sometimes fence it and sometimes not, and
13
+ * sometimes say "Here you go:" first. Refusing the whole plan over that spends
14
+ * the operator's own model quota to produce nothing, so the packaging is
15
+ * forgiven. What is NOT forgiven is the shape: an agent holding no cards, a
16
+ * `taskIds` that is not an array of strings, or no agents at all is a proposal
17
+ * nobody can accept, and `null` — which the caller reports in the machine's own
18
+ * words — beats rendering an empty board with an Accept button on it.
19
+ *
20
+ * Every string is CAPPED here rather than downstream. This text is model
21
+ * output about untrusted card content, and it becomes a row.
22
+ *
23
+ * Run: node --test bin/lib/agentPlan.test.mjs
24
+ */
25
+
26
+ /** The biggest a single proposal may be. Bounds on a machine, not a policy:
27
+ * the server caps these again at its own boundary. */
28
+ const MAX_AGENTS = 20;
29
+ const MAX_TASKS_PER_AGENT = 60;
30
+ const MAX_NAME = 80;
31
+ const MAX_NOTE = 1000;
32
+
33
+ /**
34
+ * Find the object.
35
+ *
36
+ * A fenced block first, because that is what was asked for. Otherwise every `{`
37
+ * in the text is tried as a start, and its BALANCED end is found by counting
38
+ * braces while skipping string literals — the first candidate that parses into
39
+ * something with an `agents` array wins.
40
+ *
41
+ * The obvious cheap version — first `{` to last `}` — is wrong in a way a test
42
+ * caught: a planner that writes "I looked at {the auth module} first" before its
43
+ * JSON produces a span starting at the wrong brace, and the whole plan is lost
44
+ * to a sentence. Scanning candidates costs nothing at this size and cannot be
45
+ * defeated by prose.
46
+ */
47
+ function extract(raw) {
48
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
49
+ const candidates = [];
50
+ if (fenced) candidates.push(fenced[1]);
51
+ for (let i = 0; i < raw.length; i++) {
52
+ if (raw[i] !== '{') continue;
53
+ const end = balanced(raw, i);
54
+ if (end > i) candidates.push(raw.slice(i, end + 1));
55
+ }
56
+ for (const body of candidates) {
57
+ if (!body.trim()) continue;
58
+ try {
59
+ const v = JSON.parse(body);
60
+ if (v && typeof v === 'object' && Array.isArray(v.agents)) return v;
61
+ } catch {
62
+ /* the next candidate may be the object */
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /** The index of the `}` that closes the `{` at `from`, or -1. Skips string
69
+ * literals so a brace inside a card title cannot end the object early. */
70
+ function balanced(s, from) {
71
+ let depth = 0;
72
+ let inStr = false;
73
+ let esc = false;
74
+ for (let i = from; i < s.length; i++) {
75
+ const ch = s[i];
76
+ if (inStr) {
77
+ if (esc) esc = false;
78
+ else if (ch === '\\') esc = true;
79
+ else if (ch === '"') inStr = false;
80
+ continue;
81
+ }
82
+ if (ch === '"') inStr = true;
83
+ else if (ch === '{') depth++;
84
+ else if (ch === '}' && --depth === 0) return i;
85
+ }
86
+ return -1;
87
+ }
88
+
89
+ export function parseProposal(text) {
90
+ const raw = String(text ?? '');
91
+ const parsed = extract(raw);
92
+ if (!parsed) return null;
93
+
94
+ const agents = [];
95
+ for (const [i, g] of parsed.agents.slice(0, MAX_AGENTS).entries()) {
96
+ if (!g || typeof g !== 'object') continue;
97
+ const taskIds = Array.isArray(g.taskIds)
98
+ ? g.taskIds.filter((t) => typeof t === 'string' && t.trim()).slice(0, MAX_TASKS_PER_AGENT)
99
+ : [];
100
+ // AN AGENT WITH NO CARDS IS NOT AN AGENT. Dropping it is right rather than
101
+ // merely tidy: accepting one would create a worktree and a branch for
102
+ // nothing, and it would sit in Working forever with an empty queue.
103
+ if (taskIds.length === 0) continue;
104
+ agents.push({
105
+ tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
106
+ name: String(g.name ?? '').slice(0, MAX_NAME),
107
+ taskIds,
108
+ ...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
109
+ ? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
110
+ : {}),
111
+ ...(Array.isArray(g.waitsOn)
112
+ ? { waitsOn: g.waitsOn.filter((w) => typeof w === 'string' && w).slice(0, 20) }
113
+ : {}),
114
+ ...(typeof g.intoAgentId === 'string' && g.intoAgentId
115
+ ? { intoAgentId: g.intoAgentId.slice(0, 64) }
116
+ : {}),
117
+ });
118
+ }
119
+ if (agents.length === 0) return null;
120
+ return {
121
+ agents,
122
+ ...(typeof parsed.note === 'string' && parsed.note.trim()
123
+ ? { note: parsed.note.slice(0, MAX_NOTE) }
124
+ : {}),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * READING AN AGENT'S ANSWER at the end of a turn.
130
+ *
131
+ * Same lenient-packaging / strict-shape rule as `parseProposal`, and the same
132
+ * reason: the turn already ran and the operator already paid for it, so
133
+ * refusing the whole thing over a stray "Here you go:" throws away real work.
134
+ *
135
+ * NULL IS A REAL ANSWER AND THE MOST IMPORTANT ONE. It means the turn declared
136
+ * NEITHER delivered nor blocked — a signed-out CLI, a crash, an exhausted
137
+ * quota, or a model that simply stopped — and the caller reports it as
138
+ * `nothing`, which sends the agent to Stuck. Optimistic status from a machine
139
+ * that quit is the one lie this board cannot afford, so anything ambiguous ends
140
+ * up here rather than being read as success.
141
+ */
142
+ export function parseTurnResult(text) {
143
+ const parsedRaw = String(text ?? '');
144
+ const fenced = parsedRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
145
+ const candidates = [];
146
+ if (fenced) candidates.push(fenced[1]);
147
+ for (let i = 0; i < parsedRaw.length; i++) {
148
+ if (parsedRaw[i] !== '{') continue;
149
+ const end = balanced(parsedRaw, i);
150
+ if (end > i) candidates.push(parsedRaw.slice(i, end + 1));
151
+ }
152
+ for (const body of candidates) {
153
+ let v;
154
+ try {
155
+ v = JSON.parse(body);
156
+ } catch {
157
+ continue;
158
+ }
159
+ if (!v || typeof v !== 'object') continue;
160
+ if (v.status === 'blocked') {
161
+ const question = typeof v.question === 'string' ? v.question.trim() : '';
162
+ // A "blocked" with no question is not an answer anybody can act on — it
163
+ // parks an agent with nothing to reply to. Treated as `nothing`, which
164
+ // at least says truthfully that the machine went quiet.
165
+ if (!question) continue;
166
+ return { outcome: 'question', answer: question.slice(0, 8000) };
167
+ }
168
+ if (v.status === 'delivered') {
169
+ return {
170
+ outcome: 'delivered',
171
+ answer: (typeof v.summary === 'string' ? v.summary : '').slice(0, 8000),
172
+ raised: Array.isArray(v.raised)
173
+ ? v.raised
174
+ .filter((r) => r && typeof r.title === 'string' && r.title.trim())
175
+ .slice(0, 10)
176
+ .map((r) => ({
177
+ title: r.title.trim().slice(0, 300),
178
+ ...(typeof r.brief === 'string' && r.brief.trim()
179
+ ? { brief: r.brief.trim().slice(0, 2000) }
180
+ : {}),
181
+ }))
182
+ : [],
183
+ };
184
+ }
185
+ }
186
+ return null;
187
+ }
@@ -77,6 +77,10 @@ export async function reportDeployConfig(repoRoot) {
77
77
  healthcheck: t.healthcheck,
78
78
  healthStatus: t.healthStatus,
79
79
  pushSecrets: t.pushSecrets,
80
+ // Deploy-on-merge: the env this target auto-deploys to when commits land
81
+ // on base. MUST ride this map — a field forgotten here never reaches the
82
+ // server, and the server is what turns a landed report into the job.
83
+ ...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
80
84
  }));
81
85
  try {
82
86
  await post('deploy-config', { pubkey: myPubB64(), targets: meta });
package/bin/lib/fleet.mjs CHANGED
@@ -816,6 +816,11 @@ export async function runFleetDaemon() {
816
816
  processShipJobs,
817
817
  processDiffJobs,
818
818
  processKillJobs,
819
+ processPrJobs,
820
+ processAgentPlanJobs,
821
+ processAgentTurnJobs,
822
+ processAgentMergeJobs,
823
+ freshenManualPlaces,
819
824
  heldSessionIds,
820
825
  processPreviewJobs,
821
826
  livePreviewIds,
@@ -1610,6 +1615,26 @@ export async function runFleetDaemon() {
1610
1615
  // the pid on this job is a request, never an authority, because pids are
1611
1616
  // recycled and the row the browser clicked is up to a sweep old.
1612
1617
  processKillJobs(roster.killJobs);
1618
+ // PR-mode work (push + open, or merge) — leased like a kill: two daemons
1619
+ // pushing one branch would open two PRs. Runs under the operator's own
1620
+ // `gh` credential; a settle never closes a card (done is observed by the
1621
+ // landed walk when the merge reaches base).
1622
+ processPrJobs(roster.prJobs);
1623
+ // A Deploy press waiting for a plan. AFTER the job lanes above and before
1624
+ // the worktree report, for no reason other than that it reads directories
1625
+ // those lanes may still be writing — it measures, so a stale read is a
1626
+ // slightly worse hint and never a wrong action.
1627
+ processAgentPlanJobs(roster.agentPlanJobs);
1628
+ // …and an agent's next card. After the plan jobs because a press becoming
1629
+ // agents is the thing that produces these.
1630
+ processAgentTurnJobs(roster.agentTurnJobs);
1631
+ // …and a branch somebody approved. After the turns: a merge takes the
1632
+ // place's WRITER lock, and writer preference means it goes ahead of any
1633
+ // reader queued behind it anyway.
1634
+ processAgentMergeJobs(roster.agentMergeJobs);
1635
+ // Catch each person's manual worktree up to base while it is clean. Silent,
1636
+ // fast-forward only, and it never touches an agent's branch.
1637
+ freshenManualPlaces();
1613
1638
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1614
1639
  // Throttled inside, never awaited — a `git status` the human cannot run
1615
1640
  // themselves from a browser, relayed. After retirement so a directory that
@@ -0,0 +1,175 @@
1
+ /**
2
+ * THE LANDED OBSERVER — what arrived on base, whichever road it took.
3
+ *
4
+ * The daemon already fetches origin on a throttled beat (the worktree sweep)
5
+ * and moves the local base ref itself on a ship push. This module watches the
6
+ * base tip across those moments and, when it moves, walks the NEW commits and
7
+ * reports them to /fleet/base-landed: sha, subject, and any `Flowviant-Task:`
8
+ * trailer ids. The server closes what those commits name (a trailer from any
9
+ * live status, a delivered card's receipt sha) — done is OBSERVED, and this is
10
+ * the observation that covers a hand push, a PR merged on GitHub, and a
11
+ * teammate's ship, none of which pass through /fleet/ship-done.
12
+ *
13
+ * A daemon→server REPORT, so there is no version floor and the delivery
14
+ * discipline is repo-state's: a 404 (older server) goes quiet until restart,
15
+ * and the observed tip is persisted ONLY when the server accepted the report —
16
+ * a failed POST re-walks the same range on the next beat, which is free
17
+ * because the server skips done cards.
18
+ *
19
+ * FIRST SIGHT SEEDS, NEVER WALKS. A fresh install (or a base-ref change) has
20
+ * no honest "since when", and walking history would close every trailered card
21
+ * ever merged. The tip is recorded and observation starts from there. The same
22
+ * rule covers a range the repo can no longer answer (force-push, gc): reseed,
23
+ * report nothing — ignorance is never turned into a state.
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { createHash } from 'node:crypto';
30
+ import { git, baseBranchName } from './git.mjs';
31
+ import { taskIdsFromMessage } from './worktreeDiff.mjs';
32
+ import { warn } from './ui.mjs';
33
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
34
+
35
+ const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
36
+ /** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
37
+ * batches: the persisted tip advances to the last commit actually reported,
38
+ * so the remainder is picked up on the next beat rather than skipped forever
39
+ * — a trailered card in commit 51 of a big catch-up still closes. */
40
+ const MAX_COMMITS = 50;
41
+ const SHA_RE = /^[0-9a-f]{7,64}$/i;
42
+
43
+ export function createLandedObserver({ repoRoot, baseRef }) {
44
+ // Keyed like the worktree base dir: one state file per checkout, so two
45
+ // repos on one box never share a tip.
46
+ const key = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 8);
47
+ const stateFile = join(homedir(), '.flowviant', `landed-${key}.json`);
48
+ let unsupported = false; // 404 once → an older server; quiet until restart
49
+ let inFlight = false;
50
+
51
+ const readState = () => {
52
+ try {
53
+ const s = JSON.parse(readFileSync(stateFile, 'utf8'));
54
+ return s && typeof s.ref === 'string' && typeof s.tip === 'string' ? s : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+ const writeState = (s) => {
60
+ try {
61
+ mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
62
+ writeFileSync(stateFile, JSON.stringify(s));
63
+ } catch {
64
+ /* a box that cannot persist just re-observes from the next seed */
65
+ }
66
+ };
67
+
68
+ const tipOf = (ref) => {
69
+ try {
70
+ const t = git(['rev-parse', ref], repoRoot);
71
+ return SHA_RE.test(t) ? t : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ /** New non-merge commits in from..to, OLDEST FIRST. `--no-merges` for the
78
+ * same reason branchCommits keeps it: a merge commit describes a range
79
+ * rather than doing work, and its constituents are walked as themselves. */
80
+ const walk = (from, to) => {
81
+ const raw = git(
82
+ ['log', '--reverse', '--no-merges', '--format=%H%x1f%s%x1f%B%x1e', `${from}..${to}`],
83
+ repoRoot
84
+ );
85
+ const out = [];
86
+ for (const rec of raw.split('\x1e')) {
87
+ const line = rec.replace(/^\n+/, '');
88
+ if (!line.trim()) continue;
89
+ const [sha, subject, body] = line.split('\x1f');
90
+ if (!SHA_RE.test(sha || '')) continue;
91
+ out.push({
92
+ sha,
93
+ subject: String(subject || '').slice(0, 200),
94
+ taskIds: taskIdsFromMessage(body).slice(0, 8),
95
+ });
96
+ }
97
+ return out;
98
+ };
99
+
100
+ /** Look at the base tip; if it moved, report the range. Call after anything
101
+ * that may have moved origin/<base> — the sweep's fetch, a ship's push, a
102
+ * PR merge this daemon performed. Never throws, never awaited by a turn. */
103
+ const observe = async () => {
104
+ if (unsupported || inFlight) return;
105
+ const ref = baseRef();
106
+ if (!ref) return;
107
+ const tip = tipOf(ref);
108
+ if (!tip) return;
109
+ const st = readState();
110
+ if (!st || st.ref !== ref) {
111
+ writeState({ ref, tip });
112
+ return;
113
+ }
114
+ if (st.tip === tip) return;
115
+ let all;
116
+ try {
117
+ all = walk(st.tip, ref);
118
+ } catch {
119
+ // The old tip is no longer answerable (force-push, gc) — reseed and
120
+ // report nothing rather than guess at a range.
121
+ writeState({ ref, tip });
122
+ return;
123
+ }
124
+ // Oldest-first BATCH: a range past the server's cap advances the tip only
125
+ // to the last commit reported, so the remainder rides the next beat —
126
+ // nothing is skipped forever. (A range of nothing but merge commits still
127
+ // reports, tip-only: the tip moving is the fact deploy-on-merge rides.)
128
+ const commits = all.slice(0, MAX_COMMITS);
129
+ const reportedTip = all.length > MAX_COMMITS ? commits[commits.length - 1].sha : tip;
130
+ inFlight = true;
131
+ try {
132
+ const res = await fetch(LANDED_URL, {
133
+ method: 'POST',
134
+ headers: {
135
+ Authorization: `Bearer ${FLEET_TOKEN}`,
136
+ 'User-Agent': USER_AGENT,
137
+ 'Content-Type': 'application/json',
138
+ },
139
+ signal: AbortSignal.timeout(20_000),
140
+ body: JSON.stringify({ base: baseBranchName(ref), tip: reportedTip, commits }),
141
+ });
142
+ if (res.status === 404) {
143
+ unsupported = true;
144
+ return;
145
+ }
146
+ if (res.ok) {
147
+ // Persist ONLY an accepted report — a 5xx (the server could not close
148
+ // the cards) or a network failure leaves the tip where it was, so the
149
+ // next beat re-walks the same range and the close re-runs, idempotently.
150
+ writeState({ ref, tip: reportedTip });
151
+ // Deploy-on-merge refusals are computed server-side and would
152
+ // otherwise vanish — an onMerge:'prod' (or a target with no
153
+ // commands[env]) must not be quietly inert.
154
+ const j = await res.json().catch(() => null);
155
+ for (const r of j?.data?.deployRefused ?? []) {
156
+ warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
157
+ }
158
+ } else if (res.status >= 400 && res.status < 500) {
159
+ // A persistent 4xx (deploy skew, a payload this server refuses) would
160
+ // otherwise re-send the same poison range on every beat forever.
161
+ // Drop the range — the closes it carried re-run at the next REAL tip
162
+ // move only if their cards are still open, which is the idempotent
163
+ // half; the honest cost is stated out loud.
164
+ writeState({ ref, tip });
165
+ warn(`base-landed report refused (${res.status}) — skipped ${commits.length} commit(s)`);
166
+ }
167
+ } catch {
168
+ /* offline — the next fetch beat retries */
169
+ } finally {
170
+ inFlight = false;
171
+ }
172
+ };
173
+
174
+ return { observe };
175
+ }