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
package/dist/api.js
CHANGED
|
@@ -8,11 +8,24 @@ class ConveneApi {
|
|
|
8
8
|
apiKey;
|
|
9
9
|
session;
|
|
10
10
|
tool;
|
|
11
|
-
|
|
11
|
+
instance;
|
|
12
|
+
constructor(baseUrl, apiKey, session = null, tool = null,
|
|
13
|
+
/**
|
|
14
|
+
* Server-trusted opaque session-instance id (X-Convene-Session-Instance).
|
|
15
|
+
* The server scopes it to principal.memberId to stamp holder_instance; it
|
|
16
|
+
* never authorizes anything on its own. Optional — absent on legacy callers.
|
|
17
|
+
*/
|
|
18
|
+
instance = null) {
|
|
12
19
|
this.baseUrl = baseUrl;
|
|
13
20
|
this.apiKey = apiKey;
|
|
14
21
|
this.session = session;
|
|
15
22
|
this.tool = tool;
|
|
23
|
+
this.instance = instance;
|
|
24
|
+
}
|
|
25
|
+
/** Attach/replace the session-instance id after construction (SessionStart mint). */
|
|
26
|
+
withInstance(instance) {
|
|
27
|
+
this.instance = instance;
|
|
28
|
+
return this;
|
|
16
29
|
}
|
|
17
30
|
async request(method, apiPath, opts = {}) {
|
|
18
31
|
const ctrl = new AbortController();
|
|
@@ -25,6 +38,8 @@ class ConveneApi {
|
|
|
25
38
|
headers['x-convene-session'] = this.session;
|
|
26
39
|
if (this.tool)
|
|
27
40
|
headers['x-convene-tool'] = this.tool;
|
|
41
|
+
if (this.instance)
|
|
42
|
+
headers['x-convene-session-instance'] = this.instance;
|
|
28
43
|
if (opts.idempotencyKey)
|
|
29
44
|
headers['idempotency-key'] = opts.idempotencyKey;
|
|
30
45
|
const res = await fetch(`${this.baseUrl}${brand_1.BRAND.apiBase}${apiPath}`, {
|
|
@@ -71,6 +86,23 @@ class ConveneApi {
|
|
|
71
86
|
const p = slug ? `/projects/${encodeURIComponent(slug)}/inbox` : '/inbox';
|
|
72
87
|
return this.request('GET', p, { timeoutMs });
|
|
73
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* GET /poll — the long-poll stream `convene watch` consumes. `since` is the
|
|
91
|
+
* resume cursor (Last-Event-ID semantics, a messages.id); `wait` is the server
|
|
92
|
+
* hold (seconds, capped server-side at 50). Returns `{messages, cursor}`; the
|
|
93
|
+
* server already relevance-filters to this session, the client further filters
|
|
94
|
+
* to halt/interrupt. The timeout MUST exceed `wait*1000` so the long-poll isn't
|
|
95
|
+
* aborted before the server's own deadline.
|
|
96
|
+
*/
|
|
97
|
+
poll(slug, q = {}, timeoutMs) {
|
|
98
|
+
const params = new URLSearchParams();
|
|
99
|
+
if (q.since != null)
|
|
100
|
+
params.set('since', String(q.since));
|
|
101
|
+
if (q.wait != null)
|
|
102
|
+
params.set('wait', String(q.wait));
|
|
103
|
+
const qs = params.toString();
|
|
104
|
+
return this.request('GET', `/projects/${encodeURIComponent(slug)}/poll${qs ? `?${qs}` : ''}`, { timeoutMs });
|
|
105
|
+
}
|
|
74
106
|
resolveRepo(repo, timeoutMs) {
|
|
75
107
|
return this.request('GET', `/projects/resolve?repo=${encodeURIComponent(repo)}`, { timeoutMs });
|
|
76
108
|
}
|
|
@@ -106,5 +138,64 @@ class ConveneApi {
|
|
|
106
138
|
getProject(slug, timeoutMs) {
|
|
107
139
|
return this.request('GET', `/projects/${encodeURIComponent(slug)}`, { timeoutMs });
|
|
108
140
|
}
|
|
141
|
+
// ── Catch-up / session-open (WP2) ──────────────────────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* GET /session-open — the "open already knowing the world" digest. `advance`
|
|
144
|
+
* advances the read cursor in the SAME server transaction that returns the
|
|
145
|
+
* digest (rendered ⇒ advanced atomic). `since` overrides the stored cursor.
|
|
146
|
+
* Bounded by an explicit short timeout — never the 10s default.
|
|
147
|
+
*/
|
|
148
|
+
sessionOpen(slug, q = {}, timeoutMs) {
|
|
149
|
+
const params = new URLSearchParams();
|
|
150
|
+
if (q.since != null)
|
|
151
|
+
params.set('since', String(q.since));
|
|
152
|
+
if (q.advance)
|
|
153
|
+
params.set('advance', 'true');
|
|
154
|
+
if (q.maxItems != null)
|
|
155
|
+
params.set('maxItems', String(q.maxItems));
|
|
156
|
+
const qs = params.toString();
|
|
157
|
+
return this.request('GET', `/projects/${encodeURIComponent(slug)}/session-open${qs ? `?${qs}` : ''}`, { timeoutMs });
|
|
158
|
+
}
|
|
159
|
+
/** POST /catchup/seen — render-then-advance cursor (monotonic GREATEST on the server). */
|
|
160
|
+
catchupSeen(slug, seq, timeoutMs) {
|
|
161
|
+
return this.request('POST', `/projects/${encodeURIComponent(slug)}/catchup/seen`, {
|
|
162
|
+
body: { seq },
|
|
163
|
+
idempotencyKey: `seen:${seq}`,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// ── Lanes (WP6) ────────────────────────────────────────────────────────────
|
|
168
|
+
/** GET /lanes — live board + recent releases (read-only, fail-open). */
|
|
169
|
+
lanes(slug, timeoutMs) {
|
|
170
|
+
return this.request('GET', `/projects/${encodeURIComponent(slug)}/lanes`, { timeoutMs });
|
|
171
|
+
}
|
|
172
|
+
/** GET /lane-state — the trusted read (server-derived holder_instance_self + ages). */
|
|
173
|
+
laneState(slug, intent, timeoutMs) {
|
|
174
|
+
const qs = intent ? `?intent=${encodeURIComponent(intent)}` : '';
|
|
175
|
+
return this.request('GET', `/projects/${encodeURIComponent(slug)}/lane-state${qs}`, { timeoutMs });
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* POST /lanes/:lane/claim — 200 {granted:true} on success (incl. self-renew),
|
|
179
|
+
* 409 LANE_HELD on a foreign conflict. holder_instance is stamped server-side
|
|
180
|
+
* from the X-Convene-Session-Instance header attached by this client.
|
|
181
|
+
*/
|
|
182
|
+
laneClaim(slug, lane, body = {}, timeoutMs) {
|
|
183
|
+
return this.request('POST', `/projects/${encodeURIComponent(slug)}/lanes/${encodeURIComponent(lane)}/claim`, { body, timeoutMs });
|
|
184
|
+
}
|
|
185
|
+
/** POST /lanes/:lane/release — holder-only; 200 even on a no-op (idempotent). */
|
|
186
|
+
laneRelease(slug, lane, timeoutMs) {
|
|
187
|
+
return this.request('POST', `/projects/${encodeURIComponent(slug)}/lanes/${encodeURIComponent(lane)}/release`, { timeoutMs });
|
|
188
|
+
}
|
|
189
|
+
/** POST /lanes/:lane/force-release — owner-only (server-gated by middleware). */
|
|
190
|
+
laneForceRelease(slug, lane, timeoutMs) {
|
|
191
|
+
return this.request('POST', `/projects/${encodeURIComponent(slug)}/lanes/${encodeURIComponent(lane)}/force-release`, { timeoutMs });
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* POST /gate-push — thin-client parity verdict (LANE-AUTHORITATIVE, compat
|
|
195
|
+
* advisory). Returns {verdict:'allow'|'wait'|'rebase', holder?}.
|
|
196
|
+
*/
|
|
197
|
+
gatePush(slug, body, timeoutMs) {
|
|
198
|
+
return this.request('POST', `/projects/${encodeURIComponent(slug)}/gate-push`, { body, timeoutMs });
|
|
199
|
+
}
|
|
109
200
|
}
|
|
110
201
|
exports.ConveneApi = ConveneApi;
|
package/dist/cache.js
CHANGED
|
@@ -3,9 +3,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.writeWatchHighWater = void 0;
|
|
6
7
|
exports.readCache = readCache;
|
|
7
8
|
exports.writeCache = writeCache;
|
|
8
9
|
exports.ageSeconds = ageSeconds;
|
|
10
|
+
exports.readSessionInstance = readSessionInstance;
|
|
11
|
+
exports.mintSessionInstance = mintSessionInstance;
|
|
12
|
+
exports.ensureSessionInstance = ensureSessionInstance;
|
|
13
|
+
exports.markCatchupSurfaced = markCatchupSurfaced;
|
|
14
|
+
exports.catchupAlreadySurfaced = catchupAlreadySurfaced;
|
|
15
|
+
exports.readWatchHighWater = readWatchHighWater;
|
|
16
|
+
exports.persistHighWater = persistHighWater;
|
|
17
|
+
exports.appendWatchEntry = appendWatchEntry;
|
|
18
|
+
exports.appendWatch = appendWatch;
|
|
19
|
+
exports.readWatchSince = readWatchSince;
|
|
20
|
+
exports.touchWatchHeartbeat = touchWatchHeartbeat;
|
|
21
|
+
exports.watchHeartbeatAgeSec = watchHeartbeatAgeSec;
|
|
9
22
|
/**
|
|
10
23
|
* Tiny per-project file cache so rapid successive prompts don't each hit the
|
|
11
24
|
* network (P0-LATENCY). Short TTL; on a fetch failure the stale cache still
|
|
@@ -37,3 +50,201 @@ function writeCache(slug, data) {
|
|
|
37
50
|
function ageSeconds(entry) {
|
|
38
51
|
return Math.max(0, Math.round((Date.now() - entry.fetchedAt) / 1000));
|
|
39
52
|
}
|
|
53
|
+
// ── session-instance id (WP2) ────────────────────────────────────────────────
|
|
54
|
+
// An opaque per-session UUID minted at SessionStart and persisted in CACHE_DIR.
|
|
55
|
+
// Sent as X-Convene-Session-Instance so the server can stamp holder_instance and
|
|
56
|
+
// disambiguate same-basename worktrees / two terminals in one checkout. It NEVER
|
|
57
|
+
// authorizes anything by itself — the server scopes it to principal.memberId.
|
|
58
|
+
function slugFile(slug, ext) {
|
|
59
|
+
return node_path_1.default.join(config_1.CACHE_DIR, `${slug.replace(/[^a-zA-Z0-9_-]/g, '_')}.${ext}`);
|
|
60
|
+
}
|
|
61
|
+
function newUuid() {
|
|
62
|
+
try {
|
|
63
|
+
return require('node:crypto').randomUUID();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The opaque session-instance id for this slug, or null if none minted yet. */
|
|
70
|
+
function readSessionInstance(slug) {
|
|
71
|
+
try {
|
|
72
|
+
const v = node_fs_1.default.readFileSync(slugFile(slug, 'instance'), 'utf8').trim();
|
|
73
|
+
return v || null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Mint (always overwrite) a fresh opaque session-instance UUID at SessionStart.
|
|
81
|
+
* A fresh boot is a fresh instance, so overwriting is correct.
|
|
82
|
+
*/
|
|
83
|
+
function mintSessionInstance(slug) {
|
|
84
|
+
const id = newUuid();
|
|
85
|
+
try {
|
|
86
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
87
|
+
node_fs_1.default.writeFileSync(slugFile(slug, 'instance'), id + '\n', { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* best-effort; the caller still uses the in-memory value */
|
|
91
|
+
}
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
/** The session-instance id, minting one if absent (used by non-SessionStart verbs). */
|
|
95
|
+
function ensureSessionInstance(slug) {
|
|
96
|
+
return readSessionInstance(slug) || mintSessionInstance(slug);
|
|
97
|
+
}
|
|
98
|
+
// ── per-boot catch-up dedup sentinel (WP2) ───────────────────────────────────
|
|
99
|
+
// SessionStart writes a sentinel keyed by the session-instance once it has
|
|
100
|
+
// surfaced a catch-up; the first UserPromptSubmit `fetch` of that boot reads it
|
|
101
|
+
// and suppresses a duplicate rollup. Keyed by instance so a NEW boot (new
|
|
102
|
+
// instance) always re-surfaces.
|
|
103
|
+
function sentinelFile(slug) {
|
|
104
|
+
return slugFile(slug, 'catchup-seen');
|
|
105
|
+
}
|
|
106
|
+
/** Mark that SessionStart has already surfaced a catch-up for this instance. */
|
|
107
|
+
function markCatchupSurfaced(slug, instance) {
|
|
108
|
+
try {
|
|
109
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
110
|
+
node_fs_1.default.writeFileSync(sentinelFile(slug), instance + '\n', { mode: 0o600 });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* best-effort */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** True iff a catch-up was already surfaced for this exact session-instance. */
|
|
117
|
+
function catchupAlreadySurfaced(slug, instance) {
|
|
118
|
+
try {
|
|
119
|
+
return node_fs_1.default.readFileSync(sentinelFile(slug), 'utf8').trim() === instance;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function watchFile(slug) {
|
|
126
|
+
return slugFile(slug, 'watch.jsonl');
|
|
127
|
+
}
|
|
128
|
+
function watchHighWaterFile(slug) {
|
|
129
|
+
return slugFile(slug, 'watch.hw');
|
|
130
|
+
}
|
|
131
|
+
function watchHeartbeatFile(slug) {
|
|
132
|
+
return slugFile(slug, 'watch.hb');
|
|
133
|
+
}
|
|
134
|
+
/** Read the persisted high-water seq for the watch jsonl (0 if none). */
|
|
135
|
+
function readWatchHighWater(slug) {
|
|
136
|
+
try {
|
|
137
|
+
const n = parseInt(node_fs_1.default.readFileSync(watchHighWaterFile(slug), 'utf8').trim(), 10);
|
|
138
|
+
return Number.isFinite(n) ? n : 0;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Persist the high-water seq for the watch jsonl — MONOTONIC. A lower seq is
|
|
146
|
+
* ignored (GREATEST semantics) so a reader can never rewind past material it
|
|
147
|
+
* already drained. NEVER truncates the jsonl.
|
|
148
|
+
*/
|
|
149
|
+
function persistHighWater(slug, seq) {
|
|
150
|
+
try {
|
|
151
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
152
|
+
const cur = readWatchHighWater(slug);
|
|
153
|
+
if (seq > cur)
|
|
154
|
+
node_fs_1.default.writeFileSync(watchHighWaterFile(slug), String(seq) + '\n', { mode: 0o600 });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* best-effort */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Back-compat alias for the WP2 stub name; identical monotonic behavior. */
|
|
161
|
+
exports.writeWatchHighWater = persistHighWater;
|
|
162
|
+
/**
|
|
163
|
+
* APPEND a watch entry to the jsonl (append-only — never rewrites the file).
|
|
164
|
+
* The append is atomic at the OS level for a single small write, so a concurrent
|
|
165
|
+
* reader sees whole lines. Best-effort; never throws (the watch daemon is
|
|
166
|
+
* fail-open). Entries WITHOUT a finite numeric seq are dropped — an un-keyed
|
|
167
|
+
* entry can't participate in the high-water drain and would be re-rendered
|
|
168
|
+
* forever.
|
|
169
|
+
*/
|
|
170
|
+
function appendWatchEntry(slug, entry) {
|
|
171
|
+
if (!entry || typeof entry.seq !== 'number' || !Number.isFinite(entry.seq))
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
175
|
+
node_fs_1.default.appendFileSync(watchFile(slug), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* best-effort */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Back-compat alias for the WP2 stub name. */
|
|
182
|
+
function appendWatch(slug, entry) {
|
|
183
|
+
appendWatchEntry(slug, entry);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Read all watch entries with seq > highWater, ascending by seq, de-duplicated
|
|
187
|
+
* on seq (the daemon may re-append the same entry after a resume — the reader
|
|
188
|
+
* tolerates duplicates by keeping the first per seq). Does NOT advance the
|
|
189
|
+
* high-water and does NOT truncate — the caller decides what was actually
|
|
190
|
+
* rendered and calls persistHighWater(slug, lastRenderedSeq). A malformed line
|
|
191
|
+
* is skipped, never fatal.
|
|
192
|
+
*/
|
|
193
|
+
function readWatchSince(slug, highWater) {
|
|
194
|
+
let raw;
|
|
195
|
+
try {
|
|
196
|
+
raw = node_fs_1.default.readFileSync(watchFile(slug), 'utf8');
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const line of raw.split('\n')) {
|
|
204
|
+
const s = line.trim();
|
|
205
|
+
if (!s)
|
|
206
|
+
continue;
|
|
207
|
+
let e;
|
|
208
|
+
try {
|
|
209
|
+
e = JSON.parse(s);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue; // a torn/partial line — skip, the next drain re-reads it whole
|
|
213
|
+
}
|
|
214
|
+
if (typeof e.seq !== 'number' || !Number.isFinite(e.seq))
|
|
215
|
+
continue;
|
|
216
|
+
if (e.seq <= highWater)
|
|
217
|
+
continue;
|
|
218
|
+
if (seen.has(e.seq))
|
|
219
|
+
continue;
|
|
220
|
+
seen.add(e.seq);
|
|
221
|
+
out.push(e);
|
|
222
|
+
}
|
|
223
|
+
out.sort((a, b) => a.seq - b.seq);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
// ── watch heartbeat (WP12) ───────────────────────────────────────────────────
|
|
227
|
+
// The daemon stamps a heartbeat each loop iteration; the health line / doctor
|
|
228
|
+
// read its age. A stale/absent heartbeat ⇒ watch is down ⇒ surface DEGRADED.
|
|
229
|
+
/** Stamp the watch heartbeat (epoch ms). Best-effort; never throws. */
|
|
230
|
+
function touchWatchHeartbeat(slug) {
|
|
231
|
+
try {
|
|
232
|
+
node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
233
|
+
node_fs_1.default.writeFileSync(watchHeartbeatFile(slug), String(Date.now()) + '\n', { mode: 0o600 });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* best-effort */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Age of the watch heartbeat in seconds, or null if never stamped/unreadable. */
|
|
240
|
+
function watchHeartbeatAgeSec(slug) {
|
|
241
|
+
try {
|
|
242
|
+
const ms = parseInt(node_fs_1.default.readFileSync(watchHeartbeatFile(slug), 'utf8').trim(), 10);
|
|
243
|
+
if (!Number.isFinite(ms))
|
|
244
|
+
return null;
|
|
245
|
+
return Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -5,15 +5,37 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.login = login;
|
|
7
7
|
exports.whoami = whoami;
|
|
8
|
+
exports.assessLaneIdentity = assessLaneIdentity;
|
|
8
9
|
exports.doctor = doctor;
|
|
9
10
|
/** login / whoami / doctor. */
|
|
10
11
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
13
|
const brand_1 = require("../brand");
|
|
12
14
|
const api_1 = require("../api");
|
|
13
15
|
const config_1 = require("../config");
|
|
14
16
|
const git_1 = require("../git");
|
|
15
17
|
const hook_1 = require("../hook");
|
|
18
|
+
const cache_1 = require("../cache");
|
|
16
19
|
const ctx_1 = require("../ctx");
|
|
20
|
+
/** A watch heartbeat older than this (or absent) means the watcher is down. */
|
|
21
|
+
const WATCH_STALE_SEC = 90;
|
|
22
|
+
/** (Re)launch `convene watch` detached so it outlives this doctor process. */
|
|
23
|
+
function relaunchWatch() {
|
|
24
|
+
try {
|
|
25
|
+
const bin = process.argv[1] || '';
|
|
26
|
+
if (!bin)
|
|
27
|
+
return false;
|
|
28
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [bin, 'watch'], {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
});
|
|
32
|
+
child.unref();
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
17
39
|
function readStdin() {
|
|
18
40
|
try {
|
|
19
41
|
return node_fs_1.default.readFileSync(0, 'utf8').trim();
|
|
@@ -67,6 +89,67 @@ async function whoami() {
|
|
|
67
89
|
process.stdout.write(`server: ${me.ok ? 'reachable' : 'UNREACHABLE'}\n`);
|
|
68
90
|
// The API key is never printed.
|
|
69
91
|
}
|
|
92
|
+
/** A lane this session holds whose heartbeat is older than this looks wedged. */
|
|
93
|
+
const LANE_STALE_SEC = 600;
|
|
94
|
+
/**
|
|
95
|
+
* PLAN §11 "two-humans-on-one-handle" mitigation. `holder_instance` disambiguates
|
|
96
|
+
* worktrees, but a genuinely shared API key still shares a member identity — so a
|
|
97
|
+
* deploy lane can be held under YOUR handle by a session that ISN'T this one. This
|
|
98
|
+
* surfaces that, plus a hold this session owns that has gone stale. Two flags:
|
|
99
|
+
* - COLLISION (✗): a live lane held under your handle by a DIFFERENT instance —
|
|
100
|
+
* a shared key or a sibling worktree/terminal. A release/force under your key
|
|
101
|
+
* affects it, so you should know before you act.
|
|
102
|
+
* - STALE-MINE (✗): a lane THIS session holds whose heartbeat has gone cold — a
|
|
103
|
+
* likely-wedged hold worth releasing so it stops blocking others.
|
|
104
|
+
* Pure + deterministic so it unit-tests without the network. When no
|
|
105
|
+
* session-instance was minted (`haveInstance=false`) we cannot trust
|
|
106
|
+
* holder_instance_self to tell "me" from "a sibling", so we report UNVERIFIED
|
|
107
|
+
* rather than false-alarming every lane as a collision.
|
|
108
|
+
*/
|
|
109
|
+
function assessLaneIdentity(lanes, myHandle, haveInstance, staleSec = LANE_STALE_SEC) {
|
|
110
|
+
const name = 'identity';
|
|
111
|
+
if (!myHandle)
|
|
112
|
+
return { name, ok: true, detail: 'skipped (no member identity)' };
|
|
113
|
+
const mine = lanes.filter((l) => l.holder_handle === myHandle);
|
|
114
|
+
if (mine.length === 0)
|
|
115
|
+
return { name, ok: true, detail: 'no deploy lane held under your identity' };
|
|
116
|
+
const label = (l) => {
|
|
117
|
+
const bits = [];
|
|
118
|
+
if (l.holder_tool)
|
|
119
|
+
bits.push(`tool=${l.holder_tool}`);
|
|
120
|
+
if (l.holder_session)
|
|
121
|
+
bits.push(`session=${l.holder_session}`);
|
|
122
|
+
bits.push(`heartbeat ${l.heartbeat_age_sec}s ago`);
|
|
123
|
+
return `${l.lane} (${bits.join(', ')})`;
|
|
124
|
+
};
|
|
125
|
+
if (!haveInstance) {
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
ok: true,
|
|
129
|
+
detail: `holding ${mine.length} lane(s) under your handle, but no session-instance is minted — ` +
|
|
130
|
+
`"you vs a sibling session under the same key" is UNVERIFIED; run inside a Claude Code session for the full check`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const collisions = mine.filter((l) => !l.holder_instance_self);
|
|
134
|
+
if (collisions.length) {
|
|
135
|
+
return {
|
|
136
|
+
name,
|
|
137
|
+
ok: false,
|
|
138
|
+
detail: 'lane held under your handle by ANOTHER session — likely a shared API key or a sibling worktree/terminal ' +
|
|
139
|
+
`(a release/force under your key affects it): ${collisions.map(label).join('; ')}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const stale = mine.filter((l) => l.holder_instance_self && l.heartbeat_age_sec > staleSec);
|
|
143
|
+
if (stale.length) {
|
|
144
|
+
return {
|
|
145
|
+
name,
|
|
146
|
+
ok: false,
|
|
147
|
+
detail: 'a lane THIS session holds looks stale — likely a wedged hold; release it if that deploy is done ' +
|
|
148
|
+
`(\`convene lane release <lane>\`): ${stale.map(label).join('; ')}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { name, ok: true, detail: `holding ${mine.length} lane(s), all fresh and owned by this session` };
|
|
152
|
+
}
|
|
70
153
|
async function doctor(opts) {
|
|
71
154
|
const checks = [];
|
|
72
155
|
const cfg = (0, config_1.resolveConfig)();
|
|
@@ -143,6 +226,72 @@ async function doctor(opts) {
|
|
|
143
226
|
? 'UserPromptSubmit `convene fetch` registered'
|
|
144
227
|
: 'hook NOT registered (run `convene init` or `convene doctor --fix`)',
|
|
145
228
|
});
|
|
229
|
+
// 7. watch heartbeat — a stale/absent heartbeat means the mid-task halt watcher
|
|
230
|
+
// is DOWN (so directed halts won't surface between turns). Only meaningful for a
|
|
231
|
+
// repo on the bus; --fix may (re)launch `convene watch` detached.
|
|
232
|
+
if (proj?.slug) {
|
|
233
|
+
let age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
|
|
234
|
+
let watchOk = age != null && age <= WATCH_STALE_SEC;
|
|
235
|
+
if (!watchOk && opts.fix) {
|
|
236
|
+
if (relaunchWatch()) {
|
|
237
|
+
// Give the freshly-launched daemon a beat to stamp its first heartbeat.
|
|
238
|
+
const until = Date.now() + 2500;
|
|
239
|
+
while (Date.now() < until) {
|
|
240
|
+
age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
|
|
241
|
+
if (age != null && age <= WATCH_STALE_SEC)
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
watchOk = age != null && age <= WATCH_STALE_SEC;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
checks.push({
|
|
248
|
+
name: 'watch',
|
|
249
|
+
ok: watchOk,
|
|
250
|
+
detail: watchOk
|
|
251
|
+
? `halt watcher alive (heartbeat ${age}s ago)`
|
|
252
|
+
: age == null
|
|
253
|
+
? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
|
|
254
|
+
: `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// 8. lane identity (PLAN §11 two-humans-on-one-handle). A deploy lane held under
|
|
258
|
+
// your handle by a different session-instance (shared key / sibling worktree) or
|
|
259
|
+
// a stale hold this session owns. Fail-open: a lane-state read failure is
|
|
260
|
+
// informational, never a hard doctor failure. Sends this session's minted
|
|
261
|
+
// instance so the server can compute holder_instance_self trustworthily.
|
|
262
|
+
if (proj?.slug && cfg.apiKey) {
|
|
263
|
+
const session = cfg.member && top ? (0, git_1.sessionId)(cfg.member, top) : null;
|
|
264
|
+
const instance = (0, cache_1.readSessionInstance)(proj.slug);
|
|
265
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
266
|
+
const [state, board] = await Promise.all([
|
|
267
|
+
api.laneState(proj.slug, null, 4000),
|
|
268
|
+
api.lanes(proj.slug, 4000),
|
|
269
|
+
]);
|
|
270
|
+
if (!state.ok || !Array.isArray(state.json?.lanes)) {
|
|
271
|
+
checks.push({ name: 'identity', ok: true, detail: 'skipped (lane-state unreachable)' });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Enrich each trusted lane-state row with display tool/session from the
|
|
275
|
+
// board (best-effort — the board read may fail independently).
|
|
276
|
+
const boardByLane = new Map();
|
|
277
|
+
if (board.ok && Array.isArray(board.json?.lanes)) {
|
|
278
|
+
for (const b of board.json.lanes)
|
|
279
|
+
boardByLane.set(b.lane, b);
|
|
280
|
+
}
|
|
281
|
+
const rows = state.json.lanes.map((l) => {
|
|
282
|
+
const b = boardByLane.get(l.lane);
|
|
283
|
+
return {
|
|
284
|
+
lane: l.lane,
|
|
285
|
+
holder_handle: l.holder_handle ?? null,
|
|
286
|
+
holder_instance_self: !!l.holder_instance_self,
|
|
287
|
+
heartbeat_age_sec: Number(l.heartbeat_age_sec) || 0,
|
|
288
|
+
holder_tool: b?.holder_tool ?? null,
|
|
289
|
+
holder_session: b?.holder_session ?? null,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
checks.push(assessLaneIdentity(rows, cfg.member ?? null, instance != null));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
146
295
|
for (const c of checks) {
|
|
147
296
|
process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
|
|
148
297
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.worktreeBasename = void 0;
|
|
4
|
+
exports.toDigest = toDigest;
|
|
5
|
+
exports.catchup = catchup;
|
|
6
|
+
/**
|
|
7
|
+
* `convene catchup` (alias `latest`) — the "open already knowing the world"
|
|
8
|
+
* digest, rendered as the <convene-session-open> block.
|
|
9
|
+
*
|
|
10
|
+
* DUAL FAILURE POSTURE (PLAN §4.1):
|
|
11
|
+
* - In --session-start mode it is FAIL-OPEN: any error/timeout/DEGRADED exits 0
|
|
12
|
+
* silently (a watchdog backstops a hang), because this runs as the
|
|
13
|
+
* SessionStart hook and must never wedge a boot.
|
|
14
|
+
* - On an EXPLICIT human invocation it DIES LOUD: a fetch failure prints to
|
|
15
|
+
* stderr and exits 1, so the human knows the digest is unavailable.
|
|
16
|
+
*
|
|
17
|
+
* DEGRADED suppression is structural: the <convene-session-open> block is emitted
|
|
18
|
+
* ONLY from a fresh res.ok payload. Under DEGRADED (or any failure) we never
|
|
19
|
+
* reconstruct it from cache — session-start emits nothing; explicit mode dies.
|
|
20
|
+
*/
|
|
21
|
+
const git_1 = require("../git");
|
|
22
|
+
Object.defineProperty(exports, "worktreeBasename", { enumerable: true, get: function () { return git_1.worktreeBasename; } });
|
|
23
|
+
const config_1 = require("../config");
|
|
24
|
+
const cache_1 = require("../cache");
|
|
25
|
+
const api_1 = require("../api");
|
|
26
|
+
const render_1 = require("../render");
|
|
27
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
28
|
+
const WATCHDOG_MS = 6000;
|
|
29
|
+
const MAX_ITEMS = 400;
|
|
30
|
+
function emit(s) {
|
|
31
|
+
process.stdout.write(s + '\n');
|
|
32
|
+
}
|
|
33
|
+
/** Map a server /session-open payload into the render digest (display-shaped). */
|
|
34
|
+
function toDigest(payload) {
|
|
35
|
+
const since = payload?.since ?? {};
|
|
36
|
+
return {
|
|
37
|
+
since: {
|
|
38
|
+
seq: Number(since.seq ?? 0),
|
|
39
|
+
relative: since.relative ?? null,
|
|
40
|
+
is_new_member: Boolean(since.is_new_member),
|
|
41
|
+
truncated: Boolean(since.truncated),
|
|
42
|
+
},
|
|
43
|
+
head_seq: Number(payload?.head_seq ?? 0),
|
|
44
|
+
counts: payload?.catchup?.counts ?? {},
|
|
45
|
+
sample: Array.isArray(payload?.catchup?.sample) ? payload.catchup.sample : [],
|
|
46
|
+
lanes: Array.isArray(payload?.lanes) ? payload.lanes : [],
|
|
47
|
+
inbox: Array.isArray(payload?.inbox) ? payload.inbox : [],
|
|
48
|
+
halts: Array.isArray(payload?.halts) ? payload.halts : [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Core: fetch + render the catch-up block. Returns the rendered block (or null on
|
|
53
|
+
* an empty/failed fetch in a way the caller decides how to surface). `failOpen`
|
|
54
|
+
* controls whether a failure throws (explicit mode) or returns null (hook mode).
|
|
55
|
+
*/
|
|
56
|
+
async function runCatchup(opts) {
|
|
57
|
+
const failOpen = Boolean(opts.sessionStart);
|
|
58
|
+
const top = (0, git_1.gitToplevel)();
|
|
59
|
+
if (!top)
|
|
60
|
+
return; // not a git repo
|
|
61
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
62
|
+
if (!proj?.slug)
|
|
63
|
+
return; // not on the bus → no-op
|
|
64
|
+
const slug = proj.slug;
|
|
65
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
66
|
+
if (!cfg.apiKey || !cfg.member) {
|
|
67
|
+
if (failOpen)
|
|
68
|
+
return;
|
|
69
|
+
process.stderr.write('convene: not configured — run `convene login`\n');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const member = cfg.member;
|
|
73
|
+
const session = (0, git_1.sessionId)(member, top);
|
|
74
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
75
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
76
|
+
const since = opts.since != null ? Number(opts.since) : undefined;
|
|
77
|
+
// Default advance=true (the cursor moves as material is shown); --no-advance opts out.
|
|
78
|
+
const advance = opts.advance !== false;
|
|
79
|
+
const res = await api.sessionOpen(slug, { since: Number.isFinite(since) ? since : undefined, advance, maxItems: MAX_ITEMS }, FETCH_TIMEOUT_MS);
|
|
80
|
+
if (!res.ok || !res.json || res.json.degraded) {
|
|
81
|
+
if (opts.json) {
|
|
82
|
+
emit(JSON.stringify({ degraded: true, error: res.error ?? 'degraded' }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (failOpen)
|
|
86
|
+
return; // session-start: emit nothing under DEGRADED/failure
|
|
87
|
+
process.stderr.write(`convene: catch-up unavailable (${res.status || 'network'}): ${res.error ?? 'could not reach the bus'}\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (opts.json) {
|
|
91
|
+
emit(JSON.stringify(res.json));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
emit((0, render_1.renderSessionOpenBlock)({ slug, member, session, digest: toDigest(res.json) }));
|
|
95
|
+
}
|
|
96
|
+
// In session-start mode, drop the per-boot dedup sentinel so the first
|
|
97
|
+
// UserPromptSubmit fetch of this boot suppresses a duplicate rollup.
|
|
98
|
+
if (opts.sessionStart)
|
|
99
|
+
(0, cache_1.markCatchupSurfaced)(slug, instance);
|
|
100
|
+
}
|
|
101
|
+
/** `convene catchup` / `convene latest`. */
|
|
102
|
+
async function catchup(opts = {}) {
|
|
103
|
+
if (opts.sessionStart) {
|
|
104
|
+
// Fail-open hook posture: hard watchdog + swallow everything → exit 0.
|
|
105
|
+
const watchdog = setTimeout(() => process.exit(0), WATCHDOG_MS);
|
|
106
|
+
try {
|
|
107
|
+
await runCatchup(opts);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* fail-open */
|
|
111
|
+
}
|
|
112
|
+
clearTimeout(watchdog);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
// Explicit invocation: die-loud on failure (runCatchup calls process.exit(1)).
|
|
116
|
+
try {
|
|
117
|
+
await runCatchup(opts);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
process.stderr.write(`convene: ${err?.message || err}\n`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|