flowviant 0.74.2 → 0.75.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/lib/deploy.mjs +4 -0
- package/bin/lib/fleet.mjs +6 -0
- package/bin/lib/landed.mjs +175 -0
- package/bin/lib/prompts.mjs +26 -10
- package/bin/lib/work.mjs +262 -0
- package/bin/lib/worktreeDiff.mjs +1 -1
- package/package.json +1 -1
package/bin/lib/deploy.mjs
CHANGED
|
@@ -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,7 @@ export async function runFleetDaemon() {
|
|
|
816
816
|
processShipJobs,
|
|
817
817
|
processDiffJobs,
|
|
818
818
|
processKillJobs,
|
|
819
|
+
processPrJobs,
|
|
819
820
|
heldSessionIds,
|
|
820
821
|
processPreviewJobs,
|
|
821
822
|
livePreviewIds,
|
|
@@ -1610,6 +1611,11 @@ export async function runFleetDaemon() {
|
|
|
1610
1611
|
// the pid on this job is a request, never an authority, because pids are
|
|
1611
1612
|
// recycled and the row the browser clicked is up to a sweep old.
|
|
1612
1613
|
processKillJobs(roster.killJobs);
|
|
1614
|
+
// PR-mode work (push + open, or merge) — leased like a kill: two daemons
|
|
1615
|
+
// pushing one branch would open two PRs. Runs under the operator's own
|
|
1616
|
+
// `gh` credential; a settle never closes a card (done is observed by the
|
|
1617
|
+
// landed walk when the merge reaches base).
|
|
1618
|
+
processPrJobs(roster.prJobs);
|
|
1613
1619
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1614
1620
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1615
1621
|
// 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
|
+
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -237,18 +237,32 @@ MECHANICS OF THIS TAB:
|
|
|
237
237
|
is their word to say, not yours to infer.
|
|
238
238
|
5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. A real pick between known
|
|
239
239
|
options — not an open question — ends your reply with a fenced block the app
|
|
240
|
-
renders as
|
|
240
|
+
renders as an answer card; picking an option and pressing Submit sends its
|
|
241
|
+
label as their next message, so every label must read as an answer a person
|
|
242
|
+
would say out loud:
|
|
241
243
|
|
|
242
244
|
\`\`\`flowviant-ask
|
|
243
|
-
{"question": "Which auth flow
|
|
245
|
+
{"question": "Which auth flow should the preview gate use?",
|
|
246
|
+
"header": "Auth flow",
|
|
247
|
+
"options": [
|
|
248
|
+
{"label": "Cookie + CSP change", "description": "Ships today; needs the frame-src change."},
|
|
249
|
+
{"label": "Header-based", "description": "No CSP change, but every daemon must upgrade."},
|
|
250
|
+
"Prototype both"
|
|
251
|
+
],
|
|
252
|
+
"multiSelect": false}
|
|
244
253
|
\`\`\`
|
|
245
254
|
|
|
246
|
-
ONE block per reply, and always the LAST thing in it. Two to eight options
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
in
|
|
250
|
-
|
|
251
|
-
|
|
255
|
+
ONE block per reply, and always the LAST thing in it. Two to eight options.
|
|
256
|
+
A label IS the answer — a few words, never a comma (multi-select answers
|
|
257
|
+
arrive as the chosen labels comma-joined, in the order listed); a tradeoff
|
|
258
|
+
goes in "description", one short sentence, optional. "header" is an optional
|
|
259
|
+
topic tag, three words at most. Plain-string options still work. Do NOT add
|
|
260
|
+
an "Other" option — the card offers a free-text path itself. multiSelect
|
|
261
|
+
true only for a genuine check-several-of-these case. NEVER for an open
|
|
262
|
+
question — ask those in prose, like anyone would. And ask the question in
|
|
263
|
+
prose above the block as well: a client that doesn't render the fence shows
|
|
264
|
+
it as plain text, so the reply has to read as a question with its options
|
|
265
|
+
either way.
|
|
252
266
|
|
|
253
267
|
THE LEDGER. This session's work is logged as CARDS as it happens, by you,
|
|
254
268
|
through tools — so a four-hour churn doesn't evaporate into scrollback. The
|
|
@@ -369,8 +383,10 @@ MECHANICS OF THIS TAB:
|
|
|
369
383
|
their word to say, not yours to infer.
|
|
370
384
|
5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. You have no tools here, but
|
|
371
385
|
this one costs none — it is text. A real pick between known options (not an
|
|
372
|
-
open question) ends your reply with a fenced block the app renders as
|
|
373
|
-
|
|
386
|
+
open question) ends your reply with a fenced block the app renders as an
|
|
387
|
+
answer card; picking an option and pressing Submit sends its label as their
|
|
388
|
+
next message, so every label must read as an answer a person would say out
|
|
389
|
+
loud:
|
|
374
390
|
|
|
375
391
|
\`\`\`flowviant-ask
|
|
376
392
|
{"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
MODEL,
|
|
40
40
|
} from './config.mjs';
|
|
41
41
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
42
|
+
import { createLandedObserver } from './landed.mjs';
|
|
42
43
|
import { listenersIn, measureListeners, listenersSupported } from './listeners.mjs';
|
|
43
44
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
44
45
|
import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
|
|
@@ -139,6 +140,12 @@ export function createWorkManager({
|
|
|
139
140
|
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
140
141
|
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
141
142
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
143
|
+
const PR_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-claim');
|
|
144
|
+
const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
|
|
145
|
+
// What arrived on base, whichever road it took — observed after every beat
|
|
146
|
+
// that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
|
|
147
|
+
// this daemon performed. See landed.mjs for the seeding and delivery rules.
|
|
148
|
+
const landed = createLandedObserver({ repoRoot, baseRef });
|
|
142
149
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
143
150
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
144
151
|
const MAX_WORK_TRIES = 3;
|
|
@@ -341,6 +348,10 @@ export function createWorkManager({
|
|
|
341
348
|
// moved. Without this the Repository block keeps counting a branch the
|
|
342
349
|
// ship just deleted.
|
|
343
350
|
onRepoChanged();
|
|
351
|
+
// The ship's push moved the local origin/<base> ref — observe now, so
|
|
352
|
+
// the landed report (and anything trailered a ship carried) lands on
|
|
353
|
+
// this beat rather than the next 3-minute fetch.
|
|
354
|
+
void landed.observe().catch(() => {});
|
|
344
355
|
}
|
|
345
356
|
return r;
|
|
346
357
|
};
|
|
@@ -764,6 +775,9 @@ export function createWorkManager({
|
|
|
764
775
|
} catch {
|
|
765
776
|
/* offline, or no remote — the numbers just age */
|
|
766
777
|
}
|
|
778
|
+
// The fetch may have moved the base tip — walk and report what
|
|
779
|
+
// landed. Best-effort like everything in this sweep.
|
|
780
|
+
void landed.observe().catch(() => {});
|
|
767
781
|
}
|
|
768
782
|
const reports = [];
|
|
769
783
|
for (const id of activeIds.slice(0, 20)) {
|
|
@@ -1301,6 +1315,253 @@ export function createWorkManager({
|
|
|
1301
1315
|
}
|
|
1302
1316
|
};
|
|
1303
1317
|
|
|
1318
|
+
// ── PR-mode jobs (projects.mergeMode === 'pr') ──────────────────────────
|
|
1319
|
+
// 'open' = push the session's branch and open a PR; 'merge' = merge it.
|
|
1320
|
+
// Both under the operator's own `gh` credential from the daemon's inherited
|
|
1321
|
+
// env — the same posture the dispatch-era merge path took, and the same one
|
|
1322
|
+
// claude.mjs documents for turns. LEASED like a kill: two daemons pushing
|
|
1323
|
+
// one branch would open two PRs. NOTHING here closes a card — done is
|
|
1324
|
+
// observed by the landed walk when the merge reaches base.
|
|
1325
|
+
const prWorking = new Set();
|
|
1326
|
+
const ghFirstLine = (e) =>
|
|
1327
|
+
((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
|
|
1328
|
+
'failed')
|
|
1329
|
+
.slice(0, 400);
|
|
1330
|
+
const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
|
|
1331
|
+
const claimPr = async (id) => {
|
|
1332
|
+
try {
|
|
1333
|
+
const res = await fetch(PR_CLAIM_URL, {
|
|
1334
|
+
method: 'POST',
|
|
1335
|
+
headers: {
|
|
1336
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1337
|
+
'User-Agent': USER_AGENT,
|
|
1338
|
+
'Content-Type': 'application/json',
|
|
1339
|
+
},
|
|
1340
|
+
signal: AbortSignal.timeout(15_000),
|
|
1341
|
+
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
1342
|
+
});
|
|
1343
|
+
const j = await res.json().catch(() => null);
|
|
1344
|
+
return j?.data?.claimed === true;
|
|
1345
|
+
} catch {
|
|
1346
|
+
return false; // could not claim → do nothing. The other daemon may have.
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const settlePr = async (body) => {
|
|
1350
|
+
try {
|
|
1351
|
+
await fetch(PR_DONE_URL, {
|
|
1352
|
+
method: 'POST',
|
|
1353
|
+
headers: {
|
|
1354
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1355
|
+
'User-Agent': USER_AGENT,
|
|
1356
|
+
'Content-Type': 'application/json',
|
|
1357
|
+
},
|
|
1358
|
+
signal: AbortSignal.timeout(20_000),
|
|
1359
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
1360
|
+
});
|
|
1361
|
+
} catch {
|
|
1362
|
+
/* the row expires into an honest no_answer; the re-request is the retry */
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
const runPrJob = async (job) => {
|
|
1366
|
+
const id = String(job.id);
|
|
1367
|
+
if (!(await claimPr(id))) return;
|
|
1368
|
+
// gh present and authenticated, or the honest 'unsupported' — its own
|
|
1369
|
+
// outcome because "the machine cannot do this at all" and "GitHub said
|
|
1370
|
+
// no" read differently to the person who asked.
|
|
1371
|
+
try {
|
|
1372
|
+
execFileSync('gh', ['auth', 'status'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
const missing = e?.code === 'ENOENT';
|
|
1375
|
+
await settlePr({
|
|
1376
|
+
id,
|
|
1377
|
+
outcome: 'unsupported',
|
|
1378
|
+
detail: missing
|
|
1379
|
+
? 'the GitHub CLI (gh) is not installed on this machine'
|
|
1380
|
+
: 'gh is not authenticated on this machine — run `gh auth login` there',
|
|
1381
|
+
});
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
// The branch is whatever the session's own worktree HEAD says — the same
|
|
1385
|
+
// resolution ship uses, for the same reason (the branch is where the
|
|
1386
|
+
// driver left it, not where we put it).
|
|
1387
|
+
const sessionId = String(job.sessionId);
|
|
1388
|
+
const place = placeOf(sessionId);
|
|
1389
|
+
const wt = place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
|
|
1390
|
+
let branch = `session/${sessionId}`;
|
|
1391
|
+
let detached = false;
|
|
1392
|
+
if (existsSync(wt)) {
|
|
1393
|
+
try {
|
|
1394
|
+
branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
|
|
1395
|
+
} catch {
|
|
1396
|
+
detached = true;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
if (detached) {
|
|
1400
|
+
await settlePr({
|
|
1401
|
+
id,
|
|
1402
|
+
outcome: 'failed',
|
|
1403
|
+
detail: 'the session is on a detached HEAD — no branch to push',
|
|
1404
|
+
});
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
// NEVER the base branch. A checkout-place tab (the operator's, at N=1)
|
|
1408
|
+
// commonly stands on base, and pushing it would BE the direct push this
|
|
1409
|
+
// mode exists to replace — on an unprotected repo the work lands with no
|
|
1410
|
+
// PR and the observer closes the cards as landed, PR mode silently
|
|
1411
|
+
// defeated by its own open job.
|
|
1412
|
+
const baseName = baseBranchName(baseRef());
|
|
1413
|
+
if (branch === baseName) {
|
|
1414
|
+
await settlePr({
|
|
1415
|
+
id,
|
|
1416
|
+
outcome: 'failed',
|
|
1417
|
+
detail: `the session is on the base branch (${baseName}) — nothing to open a pull request from; work on a branch first`,
|
|
1418
|
+
});
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const pushCwd = existsSync(wt) ? wt : repoRoot;
|
|
1422
|
+
/** Adopt only an OPEN PR. gh's branch finder falls back to the most recent
|
|
1423
|
+
* MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
|
|
1424
|
+
* every later delivery on a long-lived session branch into a silent
|
|
1425
|
+
* black hole ('opened'/'merged' over work that never moves). */
|
|
1426
|
+
const openPrUrl = () => {
|
|
1427
|
+
try {
|
|
1428
|
+
const j = JSON.parse(
|
|
1429
|
+
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
|
|
1430
|
+
cwd: repoRoot,
|
|
1431
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1432
|
+
}).toString()
|
|
1433
|
+
);
|
|
1434
|
+
return j?.state === 'OPEN' && typeof j?.url === 'string' ? j.url.trim() : null;
|
|
1435
|
+
} catch {
|
|
1436
|
+
return null; // no PR for the branch at all
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
if (job.kind !== 'merge') {
|
|
1440
|
+
// OPEN: push, then create — or adopt a PR already OPEN for the branch
|
|
1441
|
+
// (a re-delivery, or one the driver opened by hand).
|
|
1442
|
+
try {
|
|
1443
|
+
git(['push', '-u', 'origin', branch], pushCwd);
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
let url = openPrUrl();
|
|
1449
|
+
if (!url) {
|
|
1450
|
+
try {
|
|
1451
|
+
const out = execFileSync(
|
|
1452
|
+
'gh',
|
|
1453
|
+
// `--fill` titles the PR from the branch's own commits — no model
|
|
1454
|
+
// call, nothing invented. baseBranchName, not baseRef: gh 422s on
|
|
1455
|
+
// a remote-tracking name like origin/main.
|
|
1456
|
+
['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
|
|
1457
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
1458
|
+
)
|
|
1459
|
+
.toString()
|
|
1460
|
+
.trim();
|
|
1461
|
+
url = out.split('\n').filter(Boolean).pop() ?? null;
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
await settlePr({
|
|
1468
|
+
id,
|
|
1469
|
+
outcome: 'opened',
|
|
1470
|
+
...(url && PR_URL_RE.test(url) ? { prUrl: url } : {}),
|
|
1471
|
+
});
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
// MERGE: push FIRST — GitHub merges the REMOTE PR tip, and the quiz the
|
|
1475
|
+
// reviewer just passed fingerprinted the LOCAL worktree, so merging
|
|
1476
|
+
// without a push would land a stale tip while the observer closed the
|
|
1477
|
+
// cards over commits that never reached base. Then a MERGE COMMIT, never
|
|
1478
|
+
// squash and never rebase — the cards' receipts are commit shas, and a
|
|
1479
|
+
// squash rewrites them off base, which would orphan every receipt AND
|
|
1480
|
+
// blind the landed walk's trailer read.
|
|
1481
|
+
// No --delete-branch: the local branch may be a live worktree's HEAD.
|
|
1482
|
+
try {
|
|
1483
|
+
git(['push', 'origin', branch], pushCwd);
|
|
1484
|
+
} catch (e) {
|
|
1485
|
+
await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
try {
|
|
1489
|
+
execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
|
|
1490
|
+
cwd: repoRoot,
|
|
1491
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1492
|
+
});
|
|
1493
|
+
} catch (e) {
|
|
1494
|
+
const line = ghFirstLine(e);
|
|
1495
|
+
if (!/already merged/i.test(line)) {
|
|
1496
|
+
await settlePr({ id, outcome: 'failed', detail: line });
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// VERIFY before settling 'merged': modern gh exits 0 on an
|
|
1501
|
+
// already-MERGED PR, so a dead PR from an earlier delivery reads as
|
|
1502
|
+
// success while this branch's newest commits sit unmerged. The branch
|
|
1503
|
+
// tip being an ancestor of base is the fact 'merged' claims — check it,
|
|
1504
|
+
// with one short retry for the fetch racing GitHub's merge commit.
|
|
1505
|
+
const tipSha = (() => {
|
|
1506
|
+
try {
|
|
1507
|
+
return git(['rev-parse', branch], pushCwd);
|
|
1508
|
+
} catch {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
})();
|
|
1512
|
+
const tipOnBase = () => {
|
|
1513
|
+
if (!tipSha) return false;
|
|
1514
|
+
try {
|
|
1515
|
+
git(['merge-base', '--is-ancestor', tipSha, baseRef()], repoRoot);
|
|
1516
|
+
return true;
|
|
1517
|
+
} catch {
|
|
1518
|
+
return false;
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
let merged = false;
|
|
1522
|
+
for (let attempt = 0; attempt < 2 && !merged; attempt++) {
|
|
1523
|
+
if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
|
|
1524
|
+
try {
|
|
1525
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
1526
|
+
} catch {
|
|
1527
|
+
/* offline — the check below answers from what we have */
|
|
1528
|
+
}
|
|
1529
|
+
merged = tipOnBase();
|
|
1530
|
+
}
|
|
1531
|
+
if (!merged) {
|
|
1532
|
+
await settlePr({
|
|
1533
|
+
id,
|
|
1534
|
+
outcome: 'failed',
|
|
1535
|
+
detail:
|
|
1536
|
+
'GitHub reports a merge, but this branch\'s newest commits are not on the base branch — the PR that merged was an older one. Deliver again to open a fresh pull request.',
|
|
1537
|
+
});
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
await settlePr({ id, outcome: 'merged' });
|
|
1541
|
+
// The merge moved base. Observe NOW, so the cards close on this beat
|
|
1542
|
+
// rather than the next 3-minute sweep — the same re-measure-after-an-
|
|
1543
|
+
// action rule the kill and ship paths keep. (The fetch already ran in
|
|
1544
|
+
// the verify loop above.)
|
|
1545
|
+
void landed.observe().catch(() => {});
|
|
1546
|
+
onRepoChanged();
|
|
1547
|
+
};
|
|
1548
|
+
const processPrJobs = (jobs) => {
|
|
1549
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
1550
|
+
for (const job of jobs.slice(0, 5)) {
|
|
1551
|
+
const id = String(job?.id || '');
|
|
1552
|
+
const sid = String(job?.sessionId || '');
|
|
1553
|
+
if (!id || !isSafePathSegment(sid)) continue;
|
|
1554
|
+
// Keyed by SESSION, not job id: a deliver-time open and an approve-time
|
|
1555
|
+
// merge for one session must run in order (the roster offers them FIFO;
|
|
1556
|
+
// running them concurrently would merge before the push-and-create).
|
|
1557
|
+
if (prWorking.has(sid)) continue;
|
|
1558
|
+
prWorking.add(sid);
|
|
1559
|
+
void runPrJob(job)
|
|
1560
|
+
.catch(() => {})
|
|
1561
|
+
.finally(() => prWorking.delete(sid));
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
|
|
1304
1565
|
const processDiffJobs = (jobs) => {
|
|
1305
1566
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
1306
1567
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -3129,6 +3390,7 @@ export function createWorkManager({
|
|
|
3129
3390
|
processShipJobs,
|
|
3130
3391
|
processDiffJobs,
|
|
3131
3392
|
processKillJobs,
|
|
3393
|
+
processPrJobs,
|
|
3132
3394
|
heldSessionIds,
|
|
3133
3395
|
processPreviewJobs,
|
|
3134
3396
|
livePreviewIds,
|
package/bin/lib/worktreeDiff.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const MAX_COMMITS = 50;
|
|
|
55
55
|
* a plausible id is dropped here rather than shipped: the server drops unknown
|
|
56
56
|
* ids too, but a readout should not spend a request on obvious noise.
|
|
57
57
|
*/
|
|
58
|
-
function taskIdsFromMessage(body) {
|
|
58
|
+
export function taskIdsFromMessage(body) {
|
|
59
59
|
const ids = [];
|
|
60
60
|
for (const line of String(body || '').split('\n')) {
|
|
61
61
|
const m = line.match(/^\s*Flowviant-Task\s*:\s*(.+?)\s*$/i);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|