@the-open-engine/zeroshot 6.1.0 → 6.3.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/README.md +141 -474
- package/cli/commands/cmdproof.js +180 -6
- package/cli/event-copy.js +12 -0
- package/cli/index.js +311 -96
- package/cli/message-formatters-normal.js +14 -2
- package/cli/message-formatters-watch.js +9 -2
- package/cluster-hooks/block-ask-user-question.py +53 -0
- package/cluster-hooks/block-dangerous-git.py +145 -0
- package/lib/clusters-registry.js +48 -0
- package/lib/compose-utils.js +101 -0
- package/lib/detached-startup.js +9 -0
- package/lib/id-detector.js +8 -9
- package/lib/path-check.js +63 -0
- package/lib/run-mode.js +22 -0
- package/lib/start-cluster.js +39 -23
- package/package.json +3 -4
- package/scripts/check-path.js +19 -0
- package/scripts/validate-templates.js +11 -29
- package/src/agent/agent-hook-executor.js +1 -1
- package/src/agent/agent-task-executor.js +4 -3
- package/src/agent/pr-verification.js +27 -1
- package/src/agents/git-pusher-template.js +121 -4
- package/src/attach/socket-discovery.js +2 -3
- package/src/claude-credentials.js +75 -0
- package/src/claude-task-runner.js +1 -0
- package/src/isolation-manager.js +12 -9
- package/src/issue-providers/README.md +45 -15
- package/src/issue-providers/index.js +2 -0
- package/src/issue-providers/jira-provider.js +8 -1
- package/src/issue-providers/linear-provider.js +405 -0
- package/src/ledger.js +42 -3
- package/src/lib/gc.js +2 -3
- package/src/message-bus.js +10 -0
- package/src/orchestrator.js +264 -144
- package/src/preflight.js +12 -16
- package/src/providers/base-provider.js +7 -6
- package/src/status-footer.js +13 -1
- package/src/template-validation/index.js +3 -0
- package/src/template-validation/report-formatter.js +55 -0
- package/src/worktree-claude-config.js +2 -13
- package/task-lib/runner.js +1 -0
- package/task-lib/watcher.js +1 -0
|
@@ -44,8 +44,15 @@ class JiraProvider extends IssueProvider {
|
|
|
44
44
|
return true;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// Jira issue key pattern (KEY-123)
|
|
47
|
+
// Jira issue key pattern (KEY-123) - shared with Linear (ENG-42 vs PROJ-123).
|
|
48
|
+
// Defer to Linear when it's configured and Jira isn't, so the sole
|
|
49
|
+
// configured tracker wins instead of registration order.
|
|
48
50
|
if (/^[A-Z][A-Z0-9]+-\d+$/.test(input)) {
|
|
51
|
+
const jiraConfigured = !!(settings.jiraInstance || settings.jiraProject);
|
|
52
|
+
const linearConfigured = !!(settings.linearApiKey || settings.linearTeam);
|
|
53
|
+
if (!jiraConfigured && linearConfigured) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
49
56
|
return true;
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear Provider - Fetch issues from Linear via GraphQL API
|
|
3
|
+
* Linear is not a git host; no CLI binary required (talks to api.linear.app directly)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const IssueProvider = require('./base-provider');
|
|
7
|
+
|
|
8
|
+
const AUTH_CHECK_TIMEOUT_MS = 2000;
|
|
9
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
10
|
+
const LINEAR_API_URL = 'https://api.linear.app/graphql';
|
|
11
|
+
const LINEAR_URL_PATTERN = /linear\.app\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)/;
|
|
12
|
+
const LINEAR_KEY_PATTERN = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
13
|
+
|
|
14
|
+
class LinearProvider extends IssueProvider {
|
|
15
|
+
static id = 'linear';
|
|
16
|
+
static displayName = 'Linear';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect Linear issue keys and URLs
|
|
20
|
+
* Matches:
|
|
21
|
+
* - Linear issue URLs (linear.app/<workspace>/issue/<TEAM>-<n>/...)
|
|
22
|
+
* - Linear issue keys (TEAM-123 format)
|
|
23
|
+
* - Bare numbers when defaultIssueSource=linear (requires linearTeam setting)
|
|
24
|
+
*
|
|
25
|
+
* Note: Linear doesn't use git context since it's not a git hosting platform.
|
|
26
|
+
* Git context will never match 'linear', so this provider only activates via
|
|
27
|
+
* explicit Linear URLs, Linear issue keys, or settings.
|
|
28
|
+
*
|
|
29
|
+
* Note: Linear and Jira share the same KEY-NUMBER key format (e.g. ENG-42 vs
|
|
30
|
+
* PROJ-123), so a bare key is ambiguous between the two providers. This is
|
|
31
|
+
* resolved by configuration, not registration order: JiraProvider.detectIdentifier
|
|
32
|
+
* defers to Linear for KEY-NUMBER input when Linear is configured
|
|
33
|
+
* (linearApiKey/linearTeam) and Jira is not. If neither or both are configured,
|
|
34
|
+
* Jira wins by registration order. Use --linear or defaultIssueSource=linear
|
|
35
|
+
* for unambiguous Linear selection regardless of configuration.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} input - Issue identifier (URL, key, or number)
|
|
38
|
+
* @param {Object} settings - User settings
|
|
39
|
+
* @param {Object|null} gitContext - Git context (unused for Linear, but kept for API consistency)
|
|
40
|
+
* @returns {boolean} True if this provider should handle the input
|
|
41
|
+
*/
|
|
42
|
+
static detectIdentifier(input, settings, gitContext = null) {
|
|
43
|
+
// Linear issue URLs
|
|
44
|
+
if (LINEAR_URL_PATTERN.test(input)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Linear issue key pattern (TEAM-123)
|
|
49
|
+
if (LINEAR_KEY_PATTERN.test(input)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Bare numbers - use shared priority cascade logic
|
|
54
|
+
// Linear requires linearTeam setting to convert bare numbers to issue keys
|
|
55
|
+
// Note: gitContext check will never match 'linear' since Linear isn't a git host
|
|
56
|
+
return IssueProvider.detectBareNumber(input, settings, gitContext, 'linear', {
|
|
57
|
+
requiredSettings: ['linearTeam'],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static getRequiredTool() {
|
|
62
|
+
// No CLI binary required - talks to Linear's GraphQL API directly via fetch
|
|
63
|
+
return {
|
|
64
|
+
name: null,
|
|
65
|
+
checkCmd: null,
|
|
66
|
+
installHint: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate response status and parse GraphQL JSON body.
|
|
72
|
+
* Shared by checkAuth and fetchIssue so both surface 401/429/non-OK/invalid-body
|
|
73
|
+
* failures the same way instead of falling through to a generic JSON-parse error.
|
|
74
|
+
* @private
|
|
75
|
+
*/
|
|
76
|
+
static async _handleGraphQLResponse(response) {
|
|
77
|
+
if (response.status === 401) {
|
|
78
|
+
throw new Error('Linear API key invalid');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (response.status === 429) {
|
|
82
|
+
throw new Error('Linear API rate limit exceeded, try again later');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Linear API request failed with status ${response.status}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return await response.json();
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`Linear API returned an invalid response (status ${response.status})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the Linear API key: settings-owned, with env var fallback.
|
|
98
|
+
* @param {Object} [settings] - User settings (may be undefined/null)
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
static _resolveApiKey(settings) {
|
|
102
|
+
return settings?.linearApiKey || process.env.LINEAR_API_KEY || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recovery hints pointing at the settings-based fix for a missing/invalid key.
|
|
107
|
+
* @returns {string[]}
|
|
108
|
+
*/
|
|
109
|
+
static _recoveryHints() {
|
|
110
|
+
return [
|
|
111
|
+
'Set it: zeroshot settings set linearApiKey <key>',
|
|
112
|
+
'Get a key at https://linear.app/settings/api',
|
|
113
|
+
'Or export LINEAR_API_KEY=lin_api_...',
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check Linear API key authentication via a minimal GraphQL query
|
|
119
|
+
*/
|
|
120
|
+
static async checkAuth() {
|
|
121
|
+
// Lazy require avoids circular dependency (settings.js lazy-loads issue-providers).
|
|
122
|
+
const { loadSettings } = require('../../lib/settings');
|
|
123
|
+
const apiKey = LinearProvider._resolveApiKey(loadSettings());
|
|
124
|
+
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
return {
|
|
127
|
+
authenticated: false,
|
|
128
|
+
error: 'Linear API key not configured',
|
|
129
|
+
recovery: LinearProvider._recoveryHints(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timeout = setTimeout(() => controller.abort(), AUTH_CHECK_TIMEOUT_MS);
|
|
136
|
+
|
|
137
|
+
let response;
|
|
138
|
+
try {
|
|
139
|
+
response = await fetch(LINEAR_API_URL, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Authorization: apiKey,
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({ query: '{ viewer { id } }' }),
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
});
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const json = await LinearProvider._handleGraphQLResponse(response);
|
|
153
|
+
if (json.errors) {
|
|
154
|
+
return {
|
|
155
|
+
authenticated: false,
|
|
156
|
+
error: 'Linear API key invalid',
|
|
157
|
+
recovery: ['Verify LINEAR_API_KEY at https://linear.app/settings/api'],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { authenticated: true, error: null, recovery: [] };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err.message === 'Linear API key invalid') {
|
|
164
|
+
return {
|
|
165
|
+
authenticated: false,
|
|
166
|
+
error: err.message,
|
|
167
|
+
recovery: ['Verify LINEAR_API_KEY at https://linear.app/settings/api'],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
authenticated: false,
|
|
173
|
+
error: err.message,
|
|
174
|
+
recovery: ['Check network connectivity', 'Verify https://api.linear.app is reachable'],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Linear-specific settings schema
|
|
181
|
+
*/
|
|
182
|
+
static getSettingsSchema() {
|
|
183
|
+
return {
|
|
184
|
+
linearTeam: {
|
|
185
|
+
type: 'string',
|
|
186
|
+
nullable: true,
|
|
187
|
+
default: null,
|
|
188
|
+
description: "Default Linear team key for bare numbers (e.g., 'ENG')",
|
|
189
|
+
pattern: /^[A-Z][A-Z0-9]*$/,
|
|
190
|
+
patternMsg: 'linearTeam must be a valid Linear team key (e.g., ENG, PROJ)',
|
|
191
|
+
},
|
|
192
|
+
linearApiKey: {
|
|
193
|
+
type: 'string',
|
|
194
|
+
nullable: true,
|
|
195
|
+
default: null,
|
|
196
|
+
description: 'Linear personal API key (get one at https://linear.app/settings/api)',
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch the workspace's Linear teams and, if there's exactly one, persist its
|
|
203
|
+
* key as `linearTeam` so future bare-number runs skip this round-trip.
|
|
204
|
+
* @private
|
|
205
|
+
*/
|
|
206
|
+
static async _resolveTeamKey(apiKey) {
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
209
|
+
|
|
210
|
+
let response;
|
|
211
|
+
try {
|
|
212
|
+
response = await fetch(LINEAR_API_URL, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
'Content-Type': 'application/json',
|
|
216
|
+
Authorization: apiKey,
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({ query: '{ teams { nodes { key } } }' }),
|
|
219
|
+
signal: controller.signal,
|
|
220
|
+
});
|
|
221
|
+
} finally {
|
|
222
|
+
clearTimeout(timeout);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const json = await LinearProvider._handleGraphQLResponse(response);
|
|
226
|
+
if (json.errors) {
|
|
227
|
+
throw new Error(json.errors.map((e) => e.message).join('; '));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const teams = json.data?.teams?.nodes || [];
|
|
231
|
+
if (teams.length === 0) {
|
|
232
|
+
throw new Error('No Linear teams found for this API key.');
|
|
233
|
+
}
|
|
234
|
+
if (teams.length > 1) {
|
|
235
|
+
const keys = teams.map((t) => t.key).join(', ');
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Multiple Linear teams found (${keys}). Set one: zeroshot settings set linearTeam <KEY>`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const key = teams[0].key;
|
|
242
|
+
const { loadSettings, saveSettings } = require('../../lib/settings');
|
|
243
|
+
const current = loadSettings();
|
|
244
|
+
current.linearTeam = key;
|
|
245
|
+
saveSettings(current);
|
|
246
|
+
return key;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolve the issue key, auto-deriving `linearTeam` for bare numbers when it
|
|
251
|
+
* isn't configured yet.
|
|
252
|
+
* @private
|
|
253
|
+
*/
|
|
254
|
+
async _resolveIssueKey(identifier, settings, apiKey) {
|
|
255
|
+
if (/^\d+$/.test(identifier) && !settings.linearTeam) {
|
|
256
|
+
const team = await LinearProvider._resolveTeamKey(apiKey);
|
|
257
|
+
return `${team}-${identifier}`;
|
|
258
|
+
}
|
|
259
|
+
return this._extractIssueKey(identifier, settings);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async fetchIssue(identifier, settings) {
|
|
263
|
+
const apiKey = LinearProvider._resolveApiKey(settings);
|
|
264
|
+
if (!apiKey) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Failed to fetch Linear issue: Linear API key not configured. ${LinearProvider._recoveryHints().join(' ')}`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const issueKey = await this._resolveIssueKey(identifier, settings, apiKey);
|
|
272
|
+
|
|
273
|
+
const query = `
|
|
274
|
+
query($id: String!) {
|
|
275
|
+
issue(id: $id) {
|
|
276
|
+
identifier
|
|
277
|
+
number
|
|
278
|
+
title
|
|
279
|
+
description
|
|
280
|
+
url
|
|
281
|
+
# Linear defaults to a 50-node first page with no pagination here; long
|
|
282
|
+
# label sets or comment threads beyond that are silently truncated.
|
|
283
|
+
labels {
|
|
284
|
+
nodes {
|
|
285
|
+
name
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
comments {
|
|
289
|
+
nodes {
|
|
290
|
+
user {
|
|
291
|
+
name
|
|
292
|
+
}
|
|
293
|
+
createdAt
|
|
294
|
+
body
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
`;
|
|
300
|
+
|
|
301
|
+
const controller = new AbortController();
|
|
302
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
303
|
+
|
|
304
|
+
let response;
|
|
305
|
+
try {
|
|
306
|
+
response = await fetch(LINEAR_API_URL, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: {
|
|
309
|
+
'Content-Type': 'application/json',
|
|
310
|
+
Authorization: apiKey,
|
|
311
|
+
},
|
|
312
|
+
body: JSON.stringify({ query, variables: { id: issueKey } }),
|
|
313
|
+
signal: controller.signal,
|
|
314
|
+
});
|
|
315
|
+
} finally {
|
|
316
|
+
clearTimeout(timeout);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const json = await LinearProvider._handleGraphQLResponse(response);
|
|
320
|
+
|
|
321
|
+
if (json.errors) {
|
|
322
|
+
throw new Error(json.errors.map((e) => e.message).join('; '));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!json.data?.issue) {
|
|
326
|
+
throw new Error(`Linear issue not found: ${issueKey}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return this._parseIssue(json.data.issue);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw new Error(`Failed to fetch Linear issue: ${error.message}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Extract Linear issue key from URL or return as-is
|
|
337
|
+
* @private
|
|
338
|
+
*/
|
|
339
|
+
_extractIssueKey(identifier, settings) {
|
|
340
|
+
// URL format: https://linear.app/<workspace>/issue/TEAM-123/...
|
|
341
|
+
const urlMatch = identifier.match(LINEAR_URL_PATTERN);
|
|
342
|
+
if (urlMatch) {
|
|
343
|
+
return urlMatch[1];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Bare number: construct from linearTeam setting
|
|
347
|
+
if (/^\d+$/.test(identifier) && settings.linearTeam) {
|
|
348
|
+
return `${settings.linearTeam}-${identifier}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Assume it's already a key
|
|
352
|
+
return identifier;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Parse Linear issue into standardized InputData format
|
|
357
|
+
* @private
|
|
358
|
+
*/
|
|
359
|
+
_parseIssue(issue) {
|
|
360
|
+
const labels = issue.labels?.nodes || [];
|
|
361
|
+
const comments = issue.comments?.nodes || [];
|
|
362
|
+
const description = issue.description || '';
|
|
363
|
+
|
|
364
|
+
let context = `# Linear Issue ${issue.identifier}\n\n`;
|
|
365
|
+
context += `## Title\n${issue.title}\n\n`;
|
|
366
|
+
|
|
367
|
+
if (description) {
|
|
368
|
+
context += `## Description\n${description}\n\n`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (labels.length > 0) {
|
|
372
|
+
context += `## Labels\n`;
|
|
373
|
+
context += labels.map((l) => `- ${l.name}`).join('\n');
|
|
374
|
+
context += '\n\n';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (comments.length > 0) {
|
|
378
|
+
context += `## Comments\n\n`;
|
|
379
|
+
for (const comment of comments) {
|
|
380
|
+
const author = comment.user?.name || 'unknown';
|
|
381
|
+
context += `### ${author} (${comment.createdAt})\n`;
|
|
382
|
+
context += `${comment.body}\n\n`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const mappedLabels = labels.map((l) => ({ name: l.name }));
|
|
387
|
+
const mappedComments = comments.map((c) => ({
|
|
388
|
+
author: { login: c.user?.name || 'unknown' },
|
|
389
|
+
createdAt: c.createdAt,
|
|
390
|
+
body: c.body,
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
number: issue.number,
|
|
395
|
+
title: issue.title,
|
|
396
|
+
body: description,
|
|
397
|
+
labels: mappedLabels,
|
|
398
|
+
comments: mappedComments,
|
|
399
|
+
url: issue.url || null,
|
|
400
|
+
context,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = LinearProvider;
|
package/src/ledger.js
CHANGED
|
@@ -18,9 +18,10 @@ const {
|
|
|
18
18
|
} = require('./guidance-topics');
|
|
19
19
|
|
|
20
20
|
class Ledger extends EventEmitter {
|
|
21
|
-
constructor(dbPath = ':memory:') {
|
|
21
|
+
constructor(dbPath = ':memory:', options = {}) {
|
|
22
22
|
super();
|
|
23
23
|
this.dbPath = dbPath;
|
|
24
|
+
this.readonly = options.readonly === true;
|
|
24
25
|
const busyTimeoutMs = (() => {
|
|
25
26
|
const raw = process.env.ZEROSHOT_SQLITE_BUSY_TIMEOUT_MS;
|
|
26
27
|
if (!raw) return 5000;
|
|
@@ -28,12 +29,23 @@ class Ledger extends EventEmitter {
|
|
|
28
29
|
return Number.isFinite(value) && value >= 0 ? value : 5000;
|
|
29
30
|
})();
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
// Read-only connections (CLI list/status/logs) never take a write lock on
|
|
33
|
+
// another process's live database and skip schema DDL entirely - the schema
|
|
34
|
+
// is guaranteed to already exist for any cluster with a live daemon.
|
|
35
|
+
this.db = this.readonly
|
|
36
|
+
? new Database(dbPath, { readonly: true, fileMustExist: true, timeout: busyTimeoutMs })
|
|
37
|
+
: new Database(dbPath, { timeout: busyTimeoutMs });
|
|
32
38
|
this.cache = new Map(); // LRU cache for queries
|
|
33
39
|
this.cacheLimit = 1000;
|
|
34
40
|
this._closed = false; // Track closed state to prevent write-after-close
|
|
35
41
|
this._lastTimestamp = 0;
|
|
36
|
-
|
|
42
|
+
|
|
43
|
+
if (this.readonly) {
|
|
44
|
+
this._prepareStatements();
|
|
45
|
+
this._loadLastTimestamp();
|
|
46
|
+
} else {
|
|
47
|
+
this._initSchema();
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
_initSchema() {
|
|
@@ -475,7 +487,13 @@ class Ledger extends EventEmitter {
|
|
|
475
487
|
if (!cluster_id) {
|
|
476
488
|
throw new Error('cluster_id is required for getTokensByRole');
|
|
477
489
|
}
|
|
490
|
+
return this._computeTokensByRole(cluster_id);
|
|
491
|
+
}
|
|
478
492
|
|
|
493
|
+
/**
|
|
494
|
+
* @private
|
|
495
|
+
*/
|
|
496
|
+
_computeTokensByRole(cluster_id) {
|
|
479
497
|
// Query all TOKEN_USAGE messages for this cluster
|
|
480
498
|
const sql = `SELECT * FROM messages WHERE cluster_id = ? AND topic = 'TOKEN_USAGE' ORDER BY timestamp ASC`;
|
|
481
499
|
const stmt = this.db.prepare(sql);
|
|
@@ -531,6 +549,27 @@ class Ledger extends EventEmitter {
|
|
|
531
549
|
return byRole;
|
|
532
550
|
}
|
|
533
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Read messageCount and tokensByRole as one consistent point-in-time view.
|
|
554
|
+
* Runs both queries inside a single BEGIN DEFERRED transaction so a concurrent
|
|
555
|
+
* writer's commit can never be observed by one query but not the other
|
|
556
|
+
* (the exact straddle that produced the msgs=0/$0 phantom in `zeroshot list`).
|
|
557
|
+
* @param {String} cluster_id - Cluster ID
|
|
558
|
+
* @returns {{ messageCount: number, tokensByRole: Object }}
|
|
559
|
+
*/
|
|
560
|
+
readSnapshot(cluster_id) {
|
|
561
|
+
if (!cluster_id) {
|
|
562
|
+
throw new Error('cluster_id is required for readSnapshot');
|
|
563
|
+
}
|
|
564
|
+
if (!this._snapshotTxn) {
|
|
565
|
+
this._snapshotTxn = this.db.transaction((id) => ({
|
|
566
|
+
messageCount: this.stmts.count.get(id).count,
|
|
567
|
+
tokensByRole: this._computeTokensByRole(id),
|
|
568
|
+
})).deferred;
|
|
569
|
+
}
|
|
570
|
+
return this._snapshotTxn(cluster_id);
|
|
571
|
+
}
|
|
572
|
+
|
|
534
573
|
/**
|
|
535
574
|
* Subscribe to new messages
|
|
536
575
|
* @param {Function} callback - Called with each new message
|
package/src/lib/gc.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const os = require('os');
|
|
12
|
+
const { readClustersFileSync } = require('../../lib/clusters-registry');
|
|
12
13
|
|
|
13
14
|
const DEFAULT_STORAGE_DIR = path.join(os.homedir(), '.zeroshot');
|
|
14
15
|
|
|
@@ -35,10 +36,8 @@ function resolveActiveClusterIdFromEnv() {
|
|
|
35
36
|
*/
|
|
36
37
|
function readKnownClusterIds(storageDir) {
|
|
37
38
|
const ids = new Set();
|
|
38
|
-
const clustersFile = path.join(storageDir, 'clusters.json');
|
|
39
39
|
try {
|
|
40
|
-
|
|
41
|
-
const raw = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
|
|
40
|
+
const raw = readClustersFileSync(storageDir);
|
|
42
41
|
for (const id of Object.keys(raw)) ids.add(id);
|
|
43
42
|
} catch {
|
|
44
43
|
// Corrupt/missing — treat as empty (safe: nothing deleted incorrectly)
|
package/src/message-bus.js
CHANGED
|
@@ -180,6 +180,16 @@ class MessageBus extends EventEmitter {
|
|
|
180
180
|
return this.ledger.getTokensByRole(cluster_id);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Read messageCount and tokensByRole as one consistent point-in-time view
|
|
185
|
+
* (passthrough to ledger)
|
|
186
|
+
* @param {String} cluster_id - Cluster ID
|
|
187
|
+
* @returns {{ messageCount: number, tokensByRole: Object }}
|
|
188
|
+
*/
|
|
189
|
+
readSnapshot(cluster_id) {
|
|
190
|
+
return this.ledger.readSnapshot(cluster_id);
|
|
191
|
+
}
|
|
192
|
+
|
|
183
193
|
/**
|
|
184
194
|
* Register a WebSocket client for broadcasts
|
|
185
195
|
* @param {WebSocket} ws - WebSocket connection
|