convene-cli 1.0.4 → 1.1.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/dist/api.js +92 -1
- package/dist/cache.js +211 -0
- package/dist/commands/auth.js +149 -0
- package/dist/commands/catchup.js +123 -0
- package/dist/commands/deploy.js +71 -0
- package/dist/commands/fetch.js +71 -4
- package/dist/commands/gate-push.js +331 -0
- package/dist/commands/guard.js +313 -0
- package/dist/commands/init.js +193 -8
- package/dist/commands/lane.js +116 -0
- package/dist/commands/post.js +31 -1
- package/dist/commands/session-start.js +103 -0
- package/dist/commands/watch.js +147 -0
- package/dist/git.js +16 -0
- package/dist/githook.js +48 -10
- package/dist/hook.js +108 -2
- package/dist/index.js +88 -0
- package/dist/protocol.js +104 -22
- package/dist/render.js +176 -1
- package/dist/test-env.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deploy = deploy;
|
|
4
|
+
/**
|
|
5
|
+
* `convene deploy` (WP6) — the ONE verb a human learns. It claims the deploy lane
|
|
6
|
+
* and runs the compatibility verdict in one shot, then prints what to do.
|
|
7
|
+
*
|
|
8
|
+
* DIE-LOUD (exit 1) on a confirmed conflict — this is the explicit, human-run
|
|
9
|
+
* counterpart to the fail-open `gate-push`/`guard` HOOKS (those are Wave 4 and
|
|
10
|
+
* NOT implemented here). One verb cannot be both postures, so they are split.
|
|
11
|
+
*
|
|
12
|
+
* convene deploy [--lane <name>] [--eta <min>] [--break-glass [--reason <s>]]
|
|
13
|
+
*
|
|
14
|
+
* Lane is LANE-AUTHORITATIVE (server-enforced); the compat (behind-HEAD) half is
|
|
15
|
+
* advisory and client-attested. holder_instance is server-stamped from the
|
|
16
|
+
* X-Convene-Session-Instance header this client attaches.
|
|
17
|
+
*
|
|
18
|
+
* --break-glass / CONVENE_DEPLOY_BREAKGLASS=1: self-authorized escape hatch.
|
|
19
|
+
* It force-takes the lane and posts a loud audited status naming who/why — the
|
|
20
|
+
* observable alternative to silently disabling a hook.
|
|
21
|
+
*/
|
|
22
|
+
const ctx_1 = require("../ctx");
|
|
23
|
+
const cache_1 = require("../cache");
|
|
24
|
+
const git_1 = require("../git");
|
|
25
|
+
const LANE_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the 10s default
|
|
26
|
+
/** Canonical lane name for the current branch (deploy:<ref>), or the override. */
|
|
27
|
+
function resolveLaneName(opts, cwd) {
|
|
28
|
+
if (opts.lane)
|
|
29
|
+
return opts.lane.includes(':') ? opts.lane : `deploy:refs/heads/${opts.lane}`;
|
|
30
|
+
const branch = (0, git_1.currentBranch)(cwd) || 'main';
|
|
31
|
+
return `deploy:refs/heads/${branch}`;
|
|
32
|
+
}
|
|
33
|
+
async function deploy(opts = {}) {
|
|
34
|
+
const ctx = (0, ctx_1.getContext)({ project: opts.project });
|
|
35
|
+
const slug = (0, ctx_1.requireSlug)(ctx);
|
|
36
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
37
|
+
ctx.api.withInstance(instance);
|
|
38
|
+
const top = (0, git_1.gitToplevel)();
|
|
39
|
+
const cwd = top || process.cwd();
|
|
40
|
+
const lane = resolveLaneName(opts, cwd);
|
|
41
|
+
const breakGlass = opts.breakGlass || process.env.CONVENE_DEPLOY_BREAKGLASS === '1';
|
|
42
|
+
if (breakGlass) {
|
|
43
|
+
// Force-take the lane (owner-gated server-side) + post a loud audited status.
|
|
44
|
+
const fr = await ctx.api.laneForceRelease(slug, lane, LANE_TIMEOUT_MS);
|
|
45
|
+
if (fr.status === 403)
|
|
46
|
+
(0, ctx_1.die)('break-glass force-release is owner-only — you are not a project owner.');
|
|
47
|
+
const who = ctx.member;
|
|
48
|
+
const reason = opts.reason ? ` — ${opts.reason}` : '';
|
|
49
|
+
await ctx.api.post(slug, { type: 'status', body: `BREAK-GLASS deploy on ${lane} by @${who}${reason}` }, (0, ctx_1.uuid)(), LANE_TIMEOUT_MS);
|
|
50
|
+
}
|
|
51
|
+
// Claim the lane (LANE-AUTHORITATIVE). A foreign hold → die-loud 409.
|
|
52
|
+
const claim = await ctx.api.laneClaim(slug, lane, { eta_minutes: opts.eta ?? null, intent: 'deploy' }, LANE_TIMEOUT_MS);
|
|
53
|
+
if (claim.status === 409) {
|
|
54
|
+
const h = claim.json?.details ?? claim.json ?? {};
|
|
55
|
+
const holder = h.holder_handle ? `@${h.holder_handle}` : 'another session';
|
|
56
|
+
(0, ctx_1.die)(`deploy lane ${lane} is HELD by ${holder}. ` +
|
|
57
|
+
'Wait for release, retry with --break-glass (audited), or (owners) `convene lane release --force`.');
|
|
58
|
+
}
|
|
59
|
+
if (!claim.ok || !claim.json?.granted) {
|
|
60
|
+
(0, ctx_1.die)(`could not claim deploy lane (${claim.status}): ${claim.error ?? 'unknown error'}`);
|
|
61
|
+
}
|
|
62
|
+
// Compat verdict (advisory): attest HEAD; the server returns allow/rebase/wait.
|
|
63
|
+
const headSha = (0, git_1.revParse)('HEAD', cwd) ?? '';
|
|
64
|
+
const verdict = await ctx.api.gatePush(slug, { head_sha: headSha, refs: [lane.replace(/^deploy:/, '')] }, LANE_TIMEOUT_MS);
|
|
65
|
+
const v = verdict.ok ? verdict.json?.verdict : 'allow';
|
|
66
|
+
if (v === 'rebase') {
|
|
67
|
+
process.stdout.write(`convene: lane ${lane} claimed — but HEAD is behind origin. Run \`git pull --rebase\` then push.\n`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(`convene: lane ${lane} claimed — clear to deploy. Release with \`convene lane release ${lane}\` when done.\n`);
|
|
71
|
+
}
|
package/dist/commands/fetch.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.runFetch = runFetch;
|
|
4
7
|
/**
|
|
@@ -14,23 +17,69 @@ exports.runFetch = runFetch;
|
|
|
14
17
|
* a 4s fetch timeout bounds the slow path; a failed fetch falls back to the
|
|
15
18
|
* stale cache and renders DEGRADED (loud-but-non-blocking).
|
|
16
19
|
*/
|
|
20
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
21
|
const git_1 = require("../git");
|
|
18
22
|
const config_1 = require("../config");
|
|
19
23
|
const cache_1 = require("../cache");
|
|
20
24
|
const api_1 = require("../api");
|
|
21
25
|
const render_1 = require("../render");
|
|
26
|
+
const catchup_1 = require("./catchup");
|
|
22
27
|
const CACHE_TTL_SEC = 3;
|
|
23
28
|
const FETCH_TIMEOUT_MS = 4000;
|
|
24
29
|
const WATCHDOG_MS = 6000;
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Write a rendered block. In `codexHook` mode, wrap it in Codex's UserPromptSubmit
|
|
32
|
+
* hook envelope so its `additionalContext` lands in the model's per-turn context;
|
|
33
|
+
* otherwise write the raw block (the Claude Code hook reads stdout directly).
|
|
34
|
+
*/
|
|
35
|
+
function emit(block, codexHook) {
|
|
36
|
+
if (codexHook) {
|
|
37
|
+
process.stdout.write(JSON.stringify({
|
|
38
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block },
|
|
39
|
+
}) + '\n');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
26
42
|
process.stdout.write(block + '\n');
|
|
27
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Codex passes the hook a JSON object on stdin including `cwd`. Read it best-effort
|
|
46
|
+
* to resolve the repo when the hook runs from elsewhere. Never blocks on a TTY and
|
|
47
|
+
* never throws — a missing/garbled payload just means "use the current cwd".
|
|
48
|
+
*/
|
|
49
|
+
function codexCwdFromStdin() {
|
|
50
|
+
try {
|
|
51
|
+
if (process.stdin.isTTY)
|
|
52
|
+
return null;
|
|
53
|
+
const raw = node_fs_1.default.readFileSync(0, 'utf8');
|
|
54
|
+
if (!raw)
|
|
55
|
+
return null;
|
|
56
|
+
const j = JSON.parse(raw);
|
|
57
|
+
return typeof j?.cwd === 'string' && j.cwd ? j.cwd : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
28
63
|
function toRenderMessages(arr) {
|
|
29
64
|
return Array.isArray(arr) ? arr : [];
|
|
30
65
|
}
|
|
31
66
|
async function runFetch(opts = {}) {
|
|
32
67
|
// Absolute watchdog: under any circumstance, do not hold the prompt past 6s.
|
|
33
68
|
const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
|
|
69
|
+
// Codex hook: honor the stdin `cwd` so the repo resolves correctly, and force
|
|
70
|
+
// `--json` off (codex-hook is its own output envelope).
|
|
71
|
+
if (opts.codexHook) {
|
|
72
|
+
opts = { ...opts, json: false };
|
|
73
|
+
const cwd = codexCwdFromStdin();
|
|
74
|
+
if (cwd) {
|
|
75
|
+
try {
|
|
76
|
+
process.chdir(cwd);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* ignore — fall back to the current cwd */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
34
83
|
try {
|
|
35
84
|
const top = (0, git_1.gitToplevel)();
|
|
36
85
|
if (!top)
|
|
@@ -54,7 +103,25 @@ async function runFetch(opts = {}) {
|
|
|
54
103
|
openItems: [],
|
|
55
104
|
recent: [],
|
|
56
105
|
health: { state: 'note', line: 'convene: not configured — run `convene login` (coordination context unavailable)' },
|
|
57
|
-
}));
|
|
106
|
+
}), opts.codexHook);
|
|
107
|
+
return done(0);
|
|
108
|
+
}
|
|
109
|
+
// `--since-last`: render the catch-up digest since the read cursor instead
|
|
110
|
+
// of the time-windowed feed. Read-only (no advance), fail-open. Suppressed if
|
|
111
|
+
// SessionStart already surfaced a catch-up this boot (per-instance sentinel).
|
|
112
|
+
if (opts.sinceLast) {
|
|
113
|
+
const instance = (0, cache_1.readSessionInstance)(slug);
|
|
114
|
+
if (instance && (0, cache_1.catchupAlreadySurfaced)(slug, instance))
|
|
115
|
+
return done(0); // already shown this boot
|
|
116
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
117
|
+
const res = await api.sessionOpen(slug, { advance: false }, FETCH_TIMEOUT_MS);
|
|
118
|
+
if (res.ok && res.json && !res.json.degraded) {
|
|
119
|
+
if (opts.json)
|
|
120
|
+
emit(JSON.stringify(res.json));
|
|
121
|
+
else
|
|
122
|
+
emit((0, render_1.renderSessionOpenBlock)({ slug, member, session, digest: (0, catchup_1.toDigest)(res.json) }), opts.codexHook);
|
|
123
|
+
}
|
|
124
|
+
// DEGRADED/failure under --since-last emits nothing (structural suppression).
|
|
58
125
|
return done(0);
|
|
59
126
|
}
|
|
60
127
|
// Cache short-circuit for rapid successive prompts.
|
|
@@ -86,7 +153,7 @@ async function runFetch(opts = {}) {
|
|
|
86
153
|
openItems: toRenderMessages(cache?.data?.inbox ?? []),
|
|
87
154
|
recent: toRenderMessages(cache?.data?.messages ?? []),
|
|
88
155
|
health,
|
|
89
|
-
}));
|
|
156
|
+
}), opts.codexHook);
|
|
90
157
|
return done(0);
|
|
91
158
|
}
|
|
92
159
|
catch {
|
|
@@ -110,6 +177,6 @@ async function runFetch(opts = {}) {
|
|
|
110
177
|
openItems: toRenderMessages(data?.inbox ?? []),
|
|
111
178
|
recent: toRenderMessages(data?.messages ?? []),
|
|
112
179
|
health: { state: 'ok', syncedAgoSec: ctx.syncedAgoSec, openCount: Number(data?.open_for_you ?? (data?.inbox?.length ?? 0)) },
|
|
113
|
-
}));
|
|
180
|
+
}), opts.codexHook);
|
|
114
181
|
}
|
|
115
182
|
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.gatePush = gatePush;
|
|
4
|
+
/**
|
|
5
|
+
* `convene gate-push` (WP9) — the PUSH GATE. Wired as a PreToolUse hook on
|
|
6
|
+
* `Bash(git push …)` (PreToolUse mode) and a PostToolUse hook (`--post`) that
|
|
7
|
+
* releases the lane. The hook WIRING itself is the WP13 capstone — this file is
|
|
8
|
+
* only the verb.
|
|
9
|
+
*
|
|
10
|
+
* POSTURE = FAIL-OPEN-LOUD (P0-FAILSAFE):
|
|
11
|
+
* - A top-level watchdog force-exits 0 at WATCHDOG_MS=4000 no matter what, so a
|
|
12
|
+
* hung network/git can NEVER wedge a push.
|
|
13
|
+
* - Network calls (claim / lane-state) use an EXPLICIT short timeout (2500ms),
|
|
14
|
+
* never the api.ts 10s default; local git (isAncestor/revParse) runs in
|
|
15
|
+
* PARALLEL with the network call.
|
|
16
|
+
* - TRANSPORT failure (ApiResult.status===0 = connection-refused/DNS) →
|
|
17
|
+
* fail-OPEN-LOUD immediately (exit 0 + a `systemMessage` "deploy lane
|
|
18
|
+
* UNVERIFIED — proceeding UNGATED").
|
|
19
|
+
* - TIMEOUT / 5xx (bus up but contended) → retry 2× with backoff INSIDE the
|
|
20
|
+
* watchdog, then still fail-open-loud + best-effort post a [STATUS]
|
|
21
|
+
* "gate skipped" so the skip is auditable in real time.
|
|
22
|
+
*
|
|
23
|
+
* exit 2 (BLOCK) ONLY on a CONFIRMED positive:
|
|
24
|
+
* (a) LANE_HELD by a DIFFERENT instance (409 from the claim CAS), OR
|
|
25
|
+
* (b) an open DIRECTED HALT for this session (from lane-state.halts), OR
|
|
26
|
+
* (c) HEAD CONFIRMED-BEHIND after `git fetch origin <ref>` then
|
|
27
|
+
* isAncestor(remote, HEAD) === false.
|
|
28
|
+
* A SOFT foreign-lane conflict (a foreign live lane but no directed halt) is NOT
|
|
29
|
+
* a hard deny — it emits permissionDecision:'ask'.
|
|
30
|
+
*
|
|
31
|
+
* Auto-CLAIM on the way through: when the lane is free we CLAIM it (serialize via
|
|
32
|
+
* the server CAS, not a read-then-decide race). --post releases it (on success
|
|
33
|
+
* AND failure), an idempotent no-op if this instance no longer holds.
|
|
34
|
+
*
|
|
35
|
+
* --break-glass / CONVENE_DEPLOY_BREAKGLASS=1 → exit 0 + a loud audited takeover.
|
|
36
|
+
*
|
|
37
|
+
* NEVER calls die() and NEVER routes through ctx.getContext() (which die()s on a
|
|
38
|
+
* missing config) — it owns its own watchdog exactly like fetch.ts.
|
|
39
|
+
*
|
|
40
|
+
* The exit-2 stderr reason is a FIXED TEMPLATE with NO free-text interpolation of
|
|
41
|
+
* intent / holder_session / body — only a validated holder_handle, a numeric
|
|
42
|
+
* heartbeat age, and the one-step recovery hint.
|
|
43
|
+
*/
|
|
44
|
+
const git_1 = require("../git");
|
|
45
|
+
const config_1 = require("../config");
|
|
46
|
+
const cache_1 = require("../cache");
|
|
47
|
+
const api_1 = require("../api");
|
|
48
|
+
const guard_1 = require("./guard");
|
|
49
|
+
const WATCHDOG_MS = 4000;
|
|
50
|
+
const NET_TIMEOUT_MS = 2500; // explicit short timeout — NEVER the api.ts 10s default
|
|
51
|
+
const RETRIES = 2;
|
|
52
|
+
/** Only `holder_handle` (validated) reaches the model-facing reason. */
|
|
53
|
+
function safeHandle(s) {
|
|
54
|
+
if (typeof s !== 'string')
|
|
55
|
+
return 'another session';
|
|
56
|
+
const cleaned = s.replace(/[^a-zA-Z0-9_.\-/]+/g, '').slice(0, 64);
|
|
57
|
+
return cleaned ? `@${cleaned}` : 'another session';
|
|
58
|
+
}
|
|
59
|
+
function fmtAge(sec) {
|
|
60
|
+
const n = typeof sec === 'number' && Number.isFinite(sec) ? Math.max(0, Math.round(sec)) : null;
|
|
61
|
+
if (n == null)
|
|
62
|
+
return 'unknown';
|
|
63
|
+
if (n < 60)
|
|
64
|
+
return `${n}s`;
|
|
65
|
+
const m = Math.round(n / 60);
|
|
66
|
+
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`;
|
|
67
|
+
}
|
|
68
|
+
/** A transport failure (connection-refused/DNS) is status 0; a timeout/5xx is "up but contended". */
|
|
69
|
+
function isTransportFailure(status) {
|
|
70
|
+
return status === 0;
|
|
71
|
+
}
|
|
72
|
+
function isRetryable(status) {
|
|
73
|
+
// 408 request-timeout, 429 too-many, 5xx — bus up but contended → retry inside the watchdog.
|
|
74
|
+
return status === 408 || status === 429 || status >= 500;
|
|
75
|
+
}
|
|
76
|
+
/** Parse git's pre-push stdin into the deploy-ish ref(s) being pushed. */
|
|
77
|
+
function parseRefsFromStdin(stdin) {
|
|
78
|
+
const refs = [];
|
|
79
|
+
for (const line of stdin.split('\n')) {
|
|
80
|
+
const parts = line.trim().split(/\s+/);
|
|
81
|
+
if (parts.length < 4)
|
|
82
|
+
continue;
|
|
83
|
+
const [localRef, localSha] = parts;
|
|
84
|
+
if (!localSha || /^0+$/.test(localSha))
|
|
85
|
+
continue; // deletion — nothing landed
|
|
86
|
+
if (localRef.startsWith('refs/heads/') || localRef.startsWith('refs/tags/'))
|
|
87
|
+
refs.push(localRef);
|
|
88
|
+
}
|
|
89
|
+
return refs;
|
|
90
|
+
}
|
|
91
|
+
/** Async, timeout-bounded stdin read (git closes the pipe immediately). */
|
|
92
|
+
function readStdin(timeoutMs) {
|
|
93
|
+
if (process.stdin.isTTY)
|
|
94
|
+
return Promise.resolve(null);
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
let data = '';
|
|
97
|
+
let settled = false;
|
|
98
|
+
const finish = (v) => {
|
|
99
|
+
if (settled)
|
|
100
|
+
return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
process.stdin.removeAllListeners();
|
|
104
|
+
resolve(v);
|
|
105
|
+
};
|
|
106
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
107
|
+
process.stdin.setEncoding('utf8');
|
|
108
|
+
process.stdin.on('data', (c) => {
|
|
109
|
+
data += c;
|
|
110
|
+
});
|
|
111
|
+
process.stdin.on('end', () => finish(data));
|
|
112
|
+
process.stdin.on('error', () => finish(null));
|
|
113
|
+
process.stdin.resume();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function emitJson(obj) {
|
|
117
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
118
|
+
}
|
|
119
|
+
/** PreToolUse "allow but tell the human" verdict — NOT a hard deny. */
|
|
120
|
+
function ask(reason) {
|
|
121
|
+
emitJson({
|
|
122
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** A loud, non-blocking advisory (fail-open) surfaced to the agent. */
|
|
126
|
+
function loudOpen(systemMessage) {
|
|
127
|
+
emitJson({ systemMessage });
|
|
128
|
+
}
|
|
129
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
130
|
+
async function run(opts) {
|
|
131
|
+
const top = (0, git_1.gitToplevel)();
|
|
132
|
+
if (!top)
|
|
133
|
+
return 0; // not a git repo → no-op
|
|
134
|
+
const cwd = top;
|
|
135
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
136
|
+
const slug = opts.project || proj?.slug || null;
|
|
137
|
+
if (!slug)
|
|
138
|
+
return 0; // repo not on the bus → no-op (zero network)
|
|
139
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
140
|
+
const member = cfg.member;
|
|
141
|
+
const session = member ? (0, git_1.sessionId)(member, top) : null;
|
|
142
|
+
// Determine the ref(s) being pushed.
|
|
143
|
+
let refs;
|
|
144
|
+
if (opts.stdin) {
|
|
145
|
+
const stdin = await readStdin(1500);
|
|
146
|
+
const trimmed = (stdin ?? '').trim();
|
|
147
|
+
if (trimmed.startsWith('{')) {
|
|
148
|
+
// Claude Code PreToolUse/PostToolUse payload (JSON). Gate ONLY a real
|
|
149
|
+
// `git push` — classify the command with the SAME anchored classifier as
|
|
150
|
+
// `guard`, so an ordinary Bash command (even one whose ARGS contain
|
|
151
|
+
// "deploy"/"release"/a ref name) is a zero-network no-op and NEVER claims a
|
|
152
|
+
// lane. A bare `git push` (no refspec) falls back to the current branch.
|
|
153
|
+
const cls = (0, guard_1.classifyCommand)((0, guard_1.commandFromPayload)(stdin));
|
|
154
|
+
if (cls.kind === 'push') {
|
|
155
|
+
const cb = (0, git_1.currentBranch)(cwd);
|
|
156
|
+
refs = cls.refs.length ? cls.refs : cb ? [`refs/heads/${cb}`] : [];
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
refs = [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// git pre-push hook context: real refspecs arrive on stdin.
|
|
164
|
+
refs = stdin ? parseRefsFromStdin(stdin) : [];
|
|
165
|
+
if (!refs.length) {
|
|
166
|
+
const b = (0, git_1.currentBranch)(cwd);
|
|
167
|
+
refs = b ? [`refs/heads/${b}`] : [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Explicit invocation (e.g. `convene deploy`): gate the current branch.
|
|
173
|
+
const b = (0, git_1.currentBranch)(cwd);
|
|
174
|
+
refs = b ? [`refs/heads/${b}`] : [];
|
|
175
|
+
}
|
|
176
|
+
// A deploy ref is one the anchored classifier recognizes (heads/main, tags, …).
|
|
177
|
+
const deployRefs = refs.filter((r) => (0, guard_1.classifyPushRefs)(r));
|
|
178
|
+
const ref = deployRefs[0] ?? refs[0] ?? 'refs/heads/main';
|
|
179
|
+
const lane = `deploy:${ref}`;
|
|
180
|
+
// PostToolUse: release the lane (idempotent no-op if we no longer hold it).
|
|
181
|
+
if (opts.post) {
|
|
182
|
+
if (!cfg.apiKey || !session)
|
|
183
|
+
return 0;
|
|
184
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
185
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
186
|
+
await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
// Non-deploy ref → allow instantly with ZERO network.
|
|
190
|
+
if (!deployRefs.length)
|
|
191
|
+
return 0;
|
|
192
|
+
// Break-glass: self-authorized takeover. Exit 0 + a loud audited status.
|
|
193
|
+
const breakGlass = opts.breakGlass || process.env.CONVENE_DEPLOY_BREAKGLASS === '1';
|
|
194
|
+
// Not authenticated → fail-OPEN-loud (we cannot verify the lane).
|
|
195
|
+
if (!cfg.apiKey || !session) {
|
|
196
|
+
loudOpen('convene: deploy lane UNVERIFIED — not logged in, proceeding UNGATED.');
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
200
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
201
|
+
if (breakGlass) {
|
|
202
|
+
// Self-authorized: the verdict is ALLOW (exit 0) regardless of the bus. Emit
|
|
203
|
+
// the loud audited advisory FIRST, then do the force-take + status + claim as
|
|
204
|
+
// BEST-EFFORT in parallel under a single short cap — so a slow/unreachable bus
|
|
205
|
+
// can never push break-glass past the watchdog (it must still exit 0 promptly).
|
|
206
|
+
loudOpen(`convene: BREAK-GLASS — forced deploy lane ${lane}. This takeover is audited.`);
|
|
207
|
+
await Promise.race([
|
|
208
|
+
Promise.allSettled([
|
|
209
|
+
api.laneForceRelease(slug, lane, NET_TIMEOUT_MS),
|
|
210
|
+
api.post(slug, { type: 'status', body: `BREAK-GLASS deploy on ${lane} by @${member}` }, `bg:${lane}:${Date.now()}`, NET_TIMEOUT_MS),
|
|
211
|
+
api.laneClaim(slug, lane, { intent: 'deploy' }, NET_TIMEOUT_MS),
|
|
212
|
+
]),
|
|
213
|
+
sleep(NET_TIMEOUT_MS), // never block the exit-0 verdict longer than one timeout
|
|
214
|
+
]).catch(() => undefined);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (opts.dryRun) {
|
|
218
|
+
loudOpen(`convene: gate-push would gate lane ${lane} (dry-run, no network).`);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
// ── Local git (compat) runs in PARALLEL with the network claim ──────────────
|
|
222
|
+
const headSha = (0, git_1.revParse)('HEAD', cwd) ?? '';
|
|
223
|
+
// The compat check fetches the real remote tip, then tests fast-forward.
|
|
224
|
+
const compatP = (async () => {
|
|
225
|
+
try {
|
|
226
|
+
const fetched = (0, git_1.gitFetch)(ref, 'origin', cwd);
|
|
227
|
+
if (!fetched)
|
|
228
|
+
return { behind: false }; // can't verify → don't block on compat
|
|
229
|
+
const remote = (0, git_1.revParse)(`origin/${ref.replace(/^refs\/heads\//, '')}`, cwd) ?? (0, git_1.revParse)(`origin/${ref}`, cwd);
|
|
230
|
+
if (!remote || !headSha)
|
|
231
|
+
return { behind: false };
|
|
232
|
+
// behind ⇔ remote is NOT an ancestor of HEAD (a non-fast-forward push).
|
|
233
|
+
return { behind: !(0, git_1.isAncestor)(remote, headSha, cwd) };
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return { behind: false };
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
// ── Network claim with transport/timeout discrimination + retry ─────────────
|
|
240
|
+
let claimRes = null;
|
|
241
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
242
|
+
claimRes = await api.laneClaim(slug, lane, { intent: 'deploy' }, NET_TIMEOUT_MS);
|
|
243
|
+
if (claimRes.status === 200 || claimRes.status === 409)
|
|
244
|
+
break; // resolved
|
|
245
|
+
if (isTransportFailure(claimRes.status)) {
|
|
246
|
+
// Genuinely unreachable → fail-OPEN-loud immediately, no retry.
|
|
247
|
+
loudOpen(`convene: deploy lane UNVERIFIED — bus unreachable, proceeding UNGATED on ${lane}.`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
if (!isRetryable(claimRes.status))
|
|
251
|
+
break;
|
|
252
|
+
if (attempt < RETRIES)
|
|
253
|
+
await sleep(150 * (attempt + 1)); // backoff inside the watchdog
|
|
254
|
+
}
|
|
255
|
+
// 409 LANE_HELD by a different instance → check for a directed halt (hard) vs
|
|
256
|
+
// soft foreign lane (ask). exit 2 only on a CONFIRMED positive.
|
|
257
|
+
if (claimRes && claimRes.status === 409) {
|
|
258
|
+
const d = claimRes.json?.details ?? claimRes.json ?? {};
|
|
259
|
+
const holder = safeHandle(d.holder_handle);
|
|
260
|
+
// Is there an open DIRECTED HALT for this session? That is a HARD block.
|
|
261
|
+
const halt = await directedHaltFor(api, slug, ref);
|
|
262
|
+
if (halt) {
|
|
263
|
+
blockReason(`convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not push.`);
|
|
264
|
+
return 2;
|
|
265
|
+
}
|
|
266
|
+
// Soft foreign-lane conflict: ask, don't hard-deny.
|
|
267
|
+
const age = fmtAge(typeof d.heartbeat_age_sec === 'number' ? d.heartbeat_age_sec : undefined);
|
|
268
|
+
ask(`Deploy lane ${lane} is held by ${holder} (heartbeat ${age} ago). ` +
|
|
269
|
+
`To proceed anyway: \`convene deploy --break-glass\` (audited), or wait for release. Allow this push?`);
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
// Timeout/5xx after retries (bus up but contended) → fail-OPEN-LOUD + audit post.
|
|
273
|
+
if (!claimRes || (claimRes.status !== 200 && claimRes.status !== 409)) {
|
|
274
|
+
await api
|
|
275
|
+
.post(slug, { type: 'status', body: `gate skipped on ${lane} — bus contended/unverified at push time` }, `gateskip:${lane}:${Date.now()}`, NET_TIMEOUT_MS)
|
|
276
|
+
.catch(() => undefined);
|
|
277
|
+
loudOpen(`convene: deploy lane UNVERIFIED — bus contended, proceeding UNGATED on ${lane} (skip posted).`);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
// 200 → we hold the lane (claimed fresh or self-reclaimed). Now the COMPAT gate.
|
|
281
|
+
const compat = await compatP;
|
|
282
|
+
if (compat.behind) {
|
|
283
|
+
// Behind HEAD after a fresh fetch → CONFIRMED positive → release + hard block.
|
|
284
|
+
await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
|
|
285
|
+
blockReason(`convene: BLOCKED — HEAD is behind origin/${ref.replace(/^refs\/heads\//, '')} after fetch. ` +
|
|
286
|
+
`Run \`git pull --rebase\` then push again.`);
|
|
287
|
+
return 2;
|
|
288
|
+
}
|
|
289
|
+
// Also honor a directed halt even when the lane was free (defense in depth).
|
|
290
|
+
const halt = await directedHaltFor(api, slug, ref);
|
|
291
|
+
if (halt) {
|
|
292
|
+
await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
|
|
293
|
+
blockReason(`convene: BLOCKED — an active halt is directed at this session. Surface to the human; do not push.`);
|
|
294
|
+
return 2;
|
|
295
|
+
}
|
|
296
|
+
// Clear: lane claimed, fast-forward. Allow (PostToolUse releases later).
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Fixed-template block reason on stderr (PreToolUse exit 2 surfaces stderr to the
|
|
301
|
+
* agent). NO free-text interpolation of intent/holder_session/body.
|
|
302
|
+
*/
|
|
303
|
+
function blockReason(reason) {
|
|
304
|
+
process.stderr.write(reason + '\n');
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Is there an open halt/interrupt directed at THIS session? The server computes
|
|
308
|
+
* relevance from principal + session (the client never passes a session string
|
|
309
|
+
* that authorizes which halts apply). Any uncertainty (transport/parse) → no
|
|
310
|
+
* halt (fail-open). Returns true only on a confirmed, server-relevance-filtered
|
|
311
|
+
* directed halt.
|
|
312
|
+
*/
|
|
313
|
+
async function directedHaltFor(api, slug, ref) {
|
|
314
|
+
const res = await api.laneState(slug, `deploy:${ref}`, NET_TIMEOUT_MS).catch(() => null);
|
|
315
|
+
if (!res || !res.ok || !res.json)
|
|
316
|
+
return false; // can't verify → fail-open
|
|
317
|
+
const halts = Array.isArray(res.json.halts) ? res.json.halts : [];
|
|
318
|
+
return halts.length > 0;
|
|
319
|
+
}
|
|
320
|
+
async function gatePush(opts = {}) {
|
|
321
|
+
let code = 0;
|
|
322
|
+
const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
|
|
323
|
+
try {
|
|
324
|
+
code = await run(opts);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
code = 0; // fail-open: never wedge a push on our own error
|
|
328
|
+
}
|
|
329
|
+
clearTimeout(watchdog);
|
|
330
|
+
process.exit(code);
|
|
331
|
+
}
|