@oss-autopilot/core 1.9.0 → 1.11.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/cli-registry.js +64 -0
- package/dist/cli.bundle.cjs +78 -76
- package/dist/cli.js +17 -0
- package/dist/commands/comments.js +11 -0
- package/dist/commands/config.js +10 -7
- package/dist/commands/daily.js +25 -2
- package/dist/commands/dashboard-server.js +28 -6
- package/dist/commands/index.d.ts +7 -0
- package/dist/commands/index.js +7 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +29 -27
- package/dist/commands/state-cmd.d.ts +22 -0
- package/dist/commands/state-cmd.js +64 -0
- package/dist/core/errors.d.ts +6 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/gist-state-store.d.ts +181 -0
- package/dist/core/gist-state-store.js +473 -0
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.js +3 -2
- package/dist/core/state-persistence.d.ts +14 -2
- package/dist/core/state-persistence.js +46 -12
- package/dist/core/state-schema.d.ts +45 -41
- package/dist/core/state-schema.js +14 -19
- package/dist/core/state.d.ts +43 -29
- package/dist/core/state.js +151 -54
- package/dist/core/types.d.ts +4 -3
- package/dist/core/types.js +4 -3
- package/dist/core/utils.d.ts +30 -0
- package/dist/core/utils.js +36 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -49,6 +49,23 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
49
49
|
console.error('Then run your command again.');
|
|
50
50
|
process.exit(1);
|
|
51
51
|
}
|
|
52
|
+
// Activate Gist persistence if configured, before any command runs.
|
|
53
|
+
// Peek at the config file directly to avoid creating a local-only singleton
|
|
54
|
+
// (getStateManager() would lock in local mode before getStateManagerAsync runs).
|
|
55
|
+
let persistence;
|
|
56
|
+
try {
|
|
57
|
+
const { getStatePath } = await import('./core/index.js');
|
|
58
|
+
const fs = await import('fs');
|
|
59
|
+
const raw = fs.readFileSync(getStatePath(), 'utf-8');
|
|
60
|
+
persistence = JSON.parse(raw)?.config?.persistence;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// No state file or unreadable — local mode, lazy init
|
|
64
|
+
}
|
|
65
|
+
if (persistence === 'gist' && token) {
|
|
66
|
+
const { getStateManagerAsync } = await import('./core/index.js');
|
|
67
|
+
await getStateManagerAsync(token);
|
|
68
|
+
}
|
|
52
69
|
}
|
|
53
70
|
});
|
|
54
71
|
// First-run detection: if no subcommand was provided and no state file exists,
|
|
@@ -185,6 +185,17 @@ export async function runClaim(options) {
|
|
|
185
185
|
updatedAt: new Date().toISOString(),
|
|
186
186
|
vetted: false,
|
|
187
187
|
});
|
|
188
|
+
// Push state to Gist if in Gist mode.
|
|
189
|
+
// If getStateManagerAsync was not called before this command ran,
|
|
190
|
+
// isGistMode() will be false and checkpoint is correctly skipped.
|
|
191
|
+
try {
|
|
192
|
+
if (stateManager.isGistMode()) {
|
|
193
|
+
await stateManager.checkpoint();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* best-effort */
|
|
198
|
+
}
|
|
188
199
|
}
|
|
189
200
|
catch (error) {
|
|
190
201
|
console.error(`Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`);
|
package/dist/commands/config.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* Shows or updates configuration
|
|
4
4
|
*/
|
|
5
5
|
import { getStateManager } from '../core/index.js';
|
|
6
|
-
import {
|
|
7
|
-
import { ISSUE_SCOPES } from '../core/types.js';
|
|
6
|
+
import { ISSUE_SCOPES, DIFF_TOOLS } from '../core/types.js';
|
|
8
7
|
import { validateGitHubUsername } from './validation.js';
|
|
9
8
|
function validateScope(value) {
|
|
10
9
|
if (!ISSUE_SCOPES.includes(value)) {
|
|
@@ -106,14 +105,18 @@ export async function runConfig(options) {
|
|
|
106
105
|
case 'issueListPath':
|
|
107
106
|
stateManager.updateConfig({ issueListPath: value || undefined });
|
|
108
107
|
break;
|
|
109
|
-
case '
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
throw new ValidationError(`Invalid value for scoreThreshold: "${value}". Must be an integer between 1 and 10.`);
|
|
108
|
+
case 'diffTool': {
|
|
109
|
+
if (!DIFF_TOOLS.includes(value)) {
|
|
110
|
+
throw new Error(`Invalid diffTool "${value}". Valid options: ${DIFF_TOOLS.join(', ')}`);
|
|
113
111
|
}
|
|
114
|
-
stateManager.updateConfig({
|
|
112
|
+
stateManager.updateConfig({ diffTool: value });
|
|
115
113
|
break;
|
|
116
114
|
}
|
|
115
|
+
case 'diffToolCustomCommand':
|
|
116
|
+
stateManager.updateConfig({
|
|
117
|
+
diffToolCustomCommand: value || undefined,
|
|
118
|
+
});
|
|
119
|
+
break;
|
|
117
120
|
default:
|
|
118
121
|
throw new Error(`Unknown config key: ${options.key}`);
|
|
119
122
|
}
|
package/dist/commands/daily.js
CHANGED
|
@@ -424,11 +424,21 @@ async function executeDailyCheckInternal(token) {
|
|
|
424
424
|
const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token);
|
|
425
425
|
// Phase 2: Update repo scores (signals, star counts, trust sync)
|
|
426
426
|
await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
|
|
427
|
-
// Phase 3: Persist monthly analytics
|
|
427
|
+
// Phase 3: Persist monthly analytics and store merged/closed PR history.
|
|
428
428
|
// try-catch: analytics are supplementary — save failure should not crash the daily check.
|
|
429
429
|
try {
|
|
430
430
|
getStateManager().batch(() => {
|
|
431
431
|
updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
|
|
432
|
+
// Store recently merged/closed PRs in the persistent arrays.
|
|
433
|
+
// This ensures the mergedPRs/closedPRs ledger is populated even when
|
|
434
|
+
// the dashboard is never opened (which has its own fetch path).
|
|
435
|
+
// addMergedPRs/addClosedPRs deduplicate by URL, so overlaps are safe.
|
|
436
|
+
if (recentlyMergedPRs.length > 0) {
|
|
437
|
+
getStateManager().addMergedPRs(recentlyMergedPRs.map((pr) => ({ url: pr.url, title: pr.title, mergedAt: pr.mergedAt })));
|
|
438
|
+
}
|
|
439
|
+
if (recentlyClosedPRs.length > 0) {
|
|
440
|
+
getStateManager().addClosedPRs(recentlyClosedPRs.map((pr) => ({ url: pr.url, title: pr.title, closedAt: pr.closedAt })));
|
|
441
|
+
}
|
|
432
442
|
});
|
|
433
443
|
}
|
|
434
444
|
catch (error) {
|
|
@@ -440,7 +450,20 @@ async function executeDailyCheckInternal(token) {
|
|
|
440
450
|
// Phase 4: Partition PRs, generate and save digest
|
|
441
451
|
const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs);
|
|
442
452
|
// Phase 5: Build structured output (capacity, dismiss filter, action menu)
|
|
443
|
-
|
|
453
|
+
const result = generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
|
|
454
|
+
// Checkpoint: push state to Gist if in Gist mode.
|
|
455
|
+
// If getStateManagerAsync was not called before this command ran,
|
|
456
|
+
// isGistMode() will be false and checkpoint is correctly skipped.
|
|
457
|
+
try {
|
|
458
|
+
const sm = getStateManager();
|
|
459
|
+
if (sm.isGistMode()) {
|
|
460
|
+
await sm.checkpoint();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
warn(MODULE, `Gist checkpoint failed: ${errorMessage(err)}`);
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
444
467
|
}
|
|
445
468
|
/**
|
|
446
469
|
* Run the daily PR check and return a deduplicated digest.
|
|
@@ -209,8 +209,15 @@ export async function startDashboardServer(options) {
|
|
|
209
209
|
sendError(res, 429, 'Too many requests');
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
|
-
// Re-read state
|
|
213
|
-
|
|
212
|
+
// Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
|
|
213
|
+
let stateChanged = false;
|
|
214
|
+
if (stateManager.isGistMode()) {
|
|
215
|
+
stateChanged = await stateManager.refreshFromGist();
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
stateChanged = stateManager.reloadIfChanged();
|
|
219
|
+
}
|
|
220
|
+
if (stateChanged) {
|
|
214
221
|
try {
|
|
215
222
|
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
|
|
216
223
|
}
|
|
@@ -268,7 +275,12 @@ export async function startDashboardServer(options) {
|
|
|
268
275
|
// ── POST /api/action handler ─────────────────────────────────────────────
|
|
269
276
|
async function handleAction(req, res) {
|
|
270
277
|
// Reload state before mutating to avoid overwriting external CLI changes
|
|
271
|
-
stateManager.
|
|
278
|
+
if (stateManager.isGistMode()) {
|
|
279
|
+
await stateManager.refreshFromGist();
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
stateManager.reloadIfChanged();
|
|
283
|
+
}
|
|
272
284
|
let body;
|
|
273
285
|
try {
|
|
274
286
|
const raw = await readBody(req);
|
|
@@ -334,7 +346,12 @@ export async function startDashboardServer(options) {
|
|
|
334
346
|
return;
|
|
335
347
|
}
|
|
336
348
|
try {
|
|
337
|
-
stateManager.
|
|
349
|
+
if (stateManager.isGistMode()) {
|
|
350
|
+
await stateManager.refreshFromGist();
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
stateManager.reloadIfChanged();
|
|
354
|
+
}
|
|
338
355
|
warn(MODULE, 'Refreshing dashboard data from GitHub...');
|
|
339
356
|
const result = await fetchDashboardData(currentToken);
|
|
340
357
|
cachedDigest = result.digest;
|
|
@@ -449,8 +466,13 @@ export async function startDashboardServer(options) {
|
|
|
449
466
|
// so subsequent /api/data requests get live data instead of cached state.
|
|
450
467
|
if (token) {
|
|
451
468
|
fetchDashboardData(token)
|
|
452
|
-
.then((result) => {
|
|
453
|
-
stateManager.
|
|
469
|
+
.then(async (result) => {
|
|
470
|
+
if (stateManager.isGistMode()) {
|
|
471
|
+
await stateManager.refreshFromGist();
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
stateManager.reloadIfChanged();
|
|
475
|
+
}
|
|
454
476
|
cachedDigest = result.digest;
|
|
455
477
|
cachedCommentedIssues = result.commentedIssues;
|
|
456
478
|
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs);
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ export { runInit } from './init.js';
|
|
|
55
55
|
export { runSetup } from './setup.js';
|
|
56
56
|
/** Check whether setup has been completed. */
|
|
57
57
|
export { runCheckSetup } from './setup.js';
|
|
58
|
+
/** Show current persistence mode, Gist ID, and sync status. */
|
|
59
|
+
export { runStateShow } from './state-cmd.js';
|
|
60
|
+
/** Force push state to the backing Gist (no-op in local mode). */
|
|
61
|
+
export { runStateSync } from './state-cmd.js';
|
|
62
|
+
/** Disconnect from Gist persistence and switch to local-only mode. */
|
|
63
|
+
export { runStateUnlink } from './state-cmd.js';
|
|
58
64
|
/** Parse a curated markdown issue list file into structured issue items. */
|
|
59
65
|
export { runParseList, pruneIssueList } from './parse-list.js';
|
|
60
66
|
/** Check if new files are properly referenced/integrated. */
|
|
@@ -76,3 +82,4 @@ export type { InitOutput } from './init.js';
|
|
|
76
82
|
export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
|
|
77
83
|
export type { SetupSetOutput, SetupCompleteOutput, SetupRequiredOutput, SetupOutput, CheckSetupOutput, } from './setup.js';
|
|
78
84
|
export type { DailyCheckResult } from './daily.js';
|
|
85
|
+
export type { StateShowOutput, StateSyncOutput, StateUnlinkOutput } from './state-cmd.js';
|
package/dist/commands/index.js
CHANGED
|
@@ -59,6 +59,13 @@ export { runInit } from './init.js';
|
|
|
59
59
|
export { runSetup } from './setup.js';
|
|
60
60
|
/** Check whether setup has been completed. */
|
|
61
61
|
export { runCheckSetup } from './setup.js';
|
|
62
|
+
// ── State Persistence ────────────────────────────────────────────────────────
|
|
63
|
+
/** Show current persistence mode, Gist ID, and sync status. */
|
|
64
|
+
export { runStateShow } from './state-cmd.js';
|
|
65
|
+
/** Force push state to the backing Gist (no-op in local mode). */
|
|
66
|
+
export { runStateSync } from './state-cmd.js';
|
|
67
|
+
/** Disconnect from Gist persistence and switch to local-only mode. */
|
|
68
|
+
export { runStateUnlink } from './state-cmd.js';
|
|
62
69
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
63
70
|
/** Parse a curated markdown issue list file into structured issue items. */
|
|
64
71
|
export { runParseList, pruneIssueList } from './parse-list.js';
|
package/dist/commands/setup.d.ts
CHANGED
package/dist/commands/setup.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { getStateManager, DEFAULT_CONFIG } from '../core/index.js';
|
|
6
6
|
import { ValidationError } from '../core/errors.js';
|
|
7
7
|
import { validateGitHubUsername } from './validation.js';
|
|
8
|
-
import { PROJECT_CATEGORIES, ISSUE_SCOPES } from '../core/types.js';
|
|
8
|
+
import { PROJECT_CATEGORIES, ISSUE_SCOPES, DIFF_TOOLS, } from '../core/types.js';
|
|
9
9
|
/** Parse and validate a positive integer setting value. */
|
|
10
10
|
function parsePositiveInt(value, settingName) {
|
|
11
11
|
const parsed = Number(value);
|
|
@@ -14,14 +14,6 @@ function parsePositiveInt(value, settingName) {
|
|
|
14
14
|
}
|
|
15
15
|
return parsed;
|
|
16
16
|
}
|
|
17
|
-
/** Parse and validate an integer within a specific range [min, max]. */
|
|
18
|
-
function parseBoundedInt(value, settingName, min, max) {
|
|
19
|
-
const parsed = Number(value);
|
|
20
|
-
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
21
|
-
throw new ValidationError(`Invalid value for ${settingName}: "${value}". Must be an integer between ${min} and ${max}.`);
|
|
22
|
-
}
|
|
23
|
-
return parsed;
|
|
24
|
-
}
|
|
25
17
|
/**
|
|
26
18
|
* Interactive setup wizard or direct setting application.
|
|
27
19
|
*
|
|
@@ -78,10 +70,6 @@ export async function runSetup(options) {
|
|
|
78
70
|
stateManager.updateConfig({ labels: value.split(',').map((l) => l.trim()) });
|
|
79
71
|
results[key] = value;
|
|
80
72
|
break;
|
|
81
|
-
case 'showHealthCheck':
|
|
82
|
-
stateManager.updateConfig({ showHealthCheck: value !== 'false' });
|
|
83
|
-
results[key] = value !== 'false' ? 'true' : 'false';
|
|
84
|
-
break;
|
|
85
73
|
case 'squashByDefault':
|
|
86
74
|
if (value === 'ask') {
|
|
87
75
|
stateManager.updateConfig({ squashByDefault: 'ask' });
|
|
@@ -92,12 +80,6 @@ export async function runSetup(options) {
|
|
|
92
80
|
results[key] = value !== 'false' ? 'true' : 'false';
|
|
93
81
|
}
|
|
94
82
|
break;
|
|
95
|
-
case 'scoreThreshold': {
|
|
96
|
-
const threshold = parseBoundedInt(value, 'scoreThreshold', 1, 10);
|
|
97
|
-
stateManager.updateConfig({ scoreThreshold: threshold });
|
|
98
|
-
results[key] = String(threshold);
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
83
|
case 'minStars': {
|
|
102
84
|
const stars = Number(value);
|
|
103
85
|
if (!Number.isInteger(stars) || stars < 0) {
|
|
@@ -208,10 +190,30 @@ export async function runSetup(options) {
|
|
|
208
190
|
results[key] = dedupedScopes.length > 0 ? dedupedScopes.join(', ') : '(empty — using labels only)';
|
|
209
191
|
break;
|
|
210
192
|
}
|
|
193
|
+
case 'persistence':
|
|
194
|
+
if (value !== 'local' && value !== 'gist') {
|
|
195
|
+
throw new ValidationError(`Invalid value for persistence: "${value}". Must be "local" or "gist".`);
|
|
196
|
+
}
|
|
197
|
+
stateManager.updateConfig({ persistence: value });
|
|
198
|
+
results[key] = value;
|
|
199
|
+
break;
|
|
211
200
|
case 'issueListPath':
|
|
212
201
|
stateManager.updateConfig({ issueListPath: value || undefined });
|
|
213
202
|
results[key] = value || '(cleared)';
|
|
214
203
|
break;
|
|
204
|
+
case 'diffTool': {
|
|
205
|
+
if (!DIFF_TOOLS.includes(value)) {
|
|
206
|
+
warnings.push(`Invalid diffTool "${value}". Valid: ${DIFF_TOOLS.join(', ')}`);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
stateManager.updateConfig({ diffTool: value });
|
|
210
|
+
results[key] = value;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'diffToolCustomCommand':
|
|
214
|
+
stateManager.updateConfig({ diffToolCustomCommand: value || undefined });
|
|
215
|
+
results[key] = value || '(cleared)';
|
|
216
|
+
break;
|
|
215
217
|
case 'complete':
|
|
216
218
|
if (value === 'true') {
|
|
217
219
|
stateManager.markSetupComplete();
|
|
@@ -239,7 +241,7 @@ export async function runSetup(options) {
|
|
|
239
241
|
projectCategories: config.projectCategories ?? [],
|
|
240
242
|
preferredOrgs: config.preferredOrgs ?? [],
|
|
241
243
|
scope: config.scope ?? [],
|
|
242
|
-
|
|
244
|
+
persistence: config.persistence ?? 'local',
|
|
243
245
|
},
|
|
244
246
|
};
|
|
245
247
|
}
|
|
@@ -296,13 +298,6 @@ export async function runSetup(options) {
|
|
|
296
298
|
default: [],
|
|
297
299
|
type: 'list',
|
|
298
300
|
},
|
|
299
|
-
{
|
|
300
|
-
setting: 'scoreThreshold',
|
|
301
|
-
prompt: 'Minimum vet score (1-10) for issues to keep after vetting? Issues below this are auto-filtered.',
|
|
302
|
-
current: config.scoreThreshold,
|
|
303
|
-
default: 6,
|
|
304
|
-
type: 'number',
|
|
305
|
-
},
|
|
306
301
|
{
|
|
307
302
|
setting: 'aiPolicyBlocklist',
|
|
308
303
|
prompt: 'Repos with anti-AI contribution policies to block (owner/repo, comma-separated)?',
|
|
@@ -324,6 +319,13 @@ export async function runSetup(options) {
|
|
|
324
319
|
default: [],
|
|
325
320
|
type: 'list',
|
|
326
321
|
},
|
|
322
|
+
{
|
|
323
|
+
setting: 'persistence',
|
|
324
|
+
prompt: 'Where should state be stored? "local" for file only, "gist" for GitHub Gist (survives device loss)',
|
|
325
|
+
current: config.persistence ?? 'local',
|
|
326
|
+
default: 'local',
|
|
327
|
+
type: 'string',
|
|
328
|
+
},
|
|
327
329
|
],
|
|
328
330
|
};
|
|
329
331
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State persistence management commands.
|
|
3
|
+
* Provides --show, --sync, and --unlink subcommands for the Gist persistence layer.
|
|
4
|
+
*/
|
|
5
|
+
export interface StateShowOutput {
|
|
6
|
+
persistence: 'local' | 'gist';
|
|
7
|
+
gistId: string | null;
|
|
8
|
+
gistDegraded: boolean;
|
|
9
|
+
lastRunAt: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface StateSyncOutput {
|
|
12
|
+
pushed: boolean;
|
|
13
|
+
gistId: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface StateUnlinkOutput {
|
|
16
|
+
unlinked: boolean;
|
|
17
|
+
localStatePath: string;
|
|
18
|
+
previousGistId: string | null;
|
|
19
|
+
}
|
|
20
|
+
export declare function runStateShow(): Promise<StateShowOutput>;
|
|
21
|
+
export declare function runStateSync(): Promise<StateSyncOutput>;
|
|
22
|
+
export declare function runStateUnlink(): Promise<StateUnlinkOutput>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State persistence management commands.
|
|
3
|
+
* Provides --show, --sync, and --unlink subcommands for the Gist persistence layer.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import { getStateManager, resetStateManager } from '../core/state.js';
|
|
7
|
+
import { atomicWriteFileSync } from '../core/state-persistence.js';
|
|
8
|
+
import { getStatePath, getGistIdPath } from '../core/utils.js';
|
|
9
|
+
import { warn } from '../core/logger.js';
|
|
10
|
+
const MODULE = 'state-cmd';
|
|
11
|
+
export async function runStateShow() {
|
|
12
|
+
const sm = getStateManager();
|
|
13
|
+
const state = sm.getState();
|
|
14
|
+
return {
|
|
15
|
+
persistence: state.config.persistence ?? 'local',
|
|
16
|
+
gistId: state.gistId ?? null,
|
|
17
|
+
gistDegraded: sm.isGistDegraded(),
|
|
18
|
+
lastRunAt: state.lastRunAt,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export async function runStateSync() {
|
|
22
|
+
const sm = getStateManager();
|
|
23
|
+
if (!sm.isGistMode()) {
|
|
24
|
+
return { pushed: false, gistId: null };
|
|
25
|
+
}
|
|
26
|
+
const pushed = await sm.checkpoint();
|
|
27
|
+
if (!pushed) {
|
|
28
|
+
throw new Error('Failed to push state to Gist after retry. Check network connectivity and token permissions.');
|
|
29
|
+
}
|
|
30
|
+
return { pushed: true, gistId: sm.getState().gistId ?? null };
|
|
31
|
+
}
|
|
32
|
+
export async function runStateUnlink() {
|
|
33
|
+
const sm = getStateManager();
|
|
34
|
+
const state = sm.getState();
|
|
35
|
+
const previousGistId = state.gistId ?? null;
|
|
36
|
+
const statePath = getStatePath();
|
|
37
|
+
// Refresh from Gist to get the latest state before unlinking
|
|
38
|
+
if (sm.isGistMode()) {
|
|
39
|
+
await sm.refreshFromGist();
|
|
40
|
+
}
|
|
41
|
+
// Write current state to local state.json with persistence set to local
|
|
42
|
+
const localState = JSON.parse(JSON.stringify(sm.getState()));
|
|
43
|
+
delete localState.gistId;
|
|
44
|
+
localState.config.persistence = 'local';
|
|
45
|
+
atomicWriteFileSync(statePath, JSON.stringify(localState, null, 2), 0o600);
|
|
46
|
+
// Remove the gist-id file so future startups don't try to use the Gist
|
|
47
|
+
const gistIdPath = getGistIdPath();
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(gistIdPath)) {
|
|
50
|
+
fs.unlinkSync(gistIdPath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
warn(MODULE, `Failed to remove gist-id file: ${err}`);
|
|
55
|
+
}
|
|
56
|
+
// Do NOT delete the remote Gist — the user may want it as a backup
|
|
57
|
+
// Reset the singleton so subsequent calls in this process use local mode
|
|
58
|
+
resetStateManager();
|
|
59
|
+
return {
|
|
60
|
+
unlinked: true,
|
|
61
|
+
localStatePath: statePath,
|
|
62
|
+
previousGistId,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export declare class ConfigurationError extends OssAutopilotError {
|
|
|
26
26
|
export declare class ValidationError extends OssAutopilotError {
|
|
27
27
|
constructor(message: string);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Gist API scope error (token lacks the "gist" scope).
|
|
31
|
+
*/
|
|
32
|
+
export declare class GistPermissionError extends ConfigurationError {
|
|
33
|
+
constructor(message?: string);
|
|
34
|
+
}
|
|
29
35
|
/**
|
|
30
36
|
* Extract a human-readable message from an unknown error value.
|
|
31
37
|
*/
|
package/dist/core/errors.js
CHANGED
|
@@ -36,6 +36,18 @@ export class ValidationError extends OssAutopilotError {
|
|
|
36
36
|
this.name = 'ValidationError';
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Gist API scope error (token lacks the "gist" scope).
|
|
41
|
+
*/
|
|
42
|
+
export class GistPermissionError extends ConfigurationError {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message ??
|
|
45
|
+
'Your GitHub token does not have Gist permissions. ' +
|
|
46
|
+
'Run `gh auth refresh -s gist` to add the required scope, ' +
|
|
47
|
+
'or create a token with the "gist" scope.');
|
|
48
|
+
this.name = 'GistPermissionError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
39
51
|
/**
|
|
40
52
|
* Extract a human-readable message from an unknown error value.
|
|
41
53
|
*/
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gist-based persistence layer for oss-autopilot state.
|
|
3
|
+
*
|
|
4
|
+
* Manages a single private GitHub Gist that stores `state.json` (structured state)
|
|
5
|
+
* and potentially freeform markdown documents. Provides an in-memory file cache
|
|
6
|
+
* for session-scoped reads and a local cache write-through for degraded-mode fallback.
|
|
7
|
+
*
|
|
8
|
+
* Bootstrap flow:
|
|
9
|
+
* 1. Check for locally stored Gist ID file (`~/.oss-autopilot/gist-id`)
|
|
10
|
+
* 2. If found, fetch that Gist directly via `GET /gists/:id`
|
|
11
|
+
* 3. If not found locally, search the user's Gists for description `oss-autopilot-state`
|
|
12
|
+
* 4. If found via search, store the ID locally and fetch it
|
|
13
|
+
* 5. If not found anywhere, create a new private Gist with seed files and store the ID
|
|
14
|
+
* 6. Cache all Gist file contents in memory for session-scoped reads
|
|
15
|
+
* 7. Write state to a local cache file for fallback
|
|
16
|
+
*/
|
|
17
|
+
import { AgentState } from './types.js';
|
|
18
|
+
/** Well-known Gist description used for search-based discovery. */
|
|
19
|
+
export declare const GIST_DESCRIPTION = "oss-autopilot-state";
|
|
20
|
+
/** Primary state file name inside the Gist. */
|
|
21
|
+
export declare const STATE_FILE_NAME = "state.json";
|
|
22
|
+
/** Result of a successful bootstrap. */
|
|
23
|
+
export interface BootstrapResult {
|
|
24
|
+
gistId: string;
|
|
25
|
+
state: AgentState;
|
|
26
|
+
created: boolean;
|
|
27
|
+
/** True when state was loaded from local cache due to API failure. */
|
|
28
|
+
degraded?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Minimal Octokit-shaped interface for the Gist API methods we use.
|
|
32
|
+
* Accepts the real ThrottledOctokit or a plain mock object in tests.
|
|
33
|
+
*/
|
|
34
|
+
export interface OctokitLike {
|
|
35
|
+
gists: {
|
|
36
|
+
get: (params: {
|
|
37
|
+
gist_id: string;
|
|
38
|
+
}) => Promise<{
|
|
39
|
+
data: GistResponseData;
|
|
40
|
+
}>;
|
|
41
|
+
list: (params: {
|
|
42
|
+
per_page: number;
|
|
43
|
+
page: number;
|
|
44
|
+
}) => Promise<{
|
|
45
|
+
data: GistListItem[];
|
|
46
|
+
}>;
|
|
47
|
+
create: (params: {
|
|
48
|
+
description: string;
|
|
49
|
+
public: boolean;
|
|
50
|
+
files: Record<string, {
|
|
51
|
+
content: string;
|
|
52
|
+
}>;
|
|
53
|
+
}) => Promise<{
|
|
54
|
+
data: GistResponseData;
|
|
55
|
+
}>;
|
|
56
|
+
update: (params: {
|
|
57
|
+
gist_id: string;
|
|
58
|
+
files: Record<string, {
|
|
59
|
+
content: string;
|
|
60
|
+
}>;
|
|
61
|
+
}) => Promise<{
|
|
62
|
+
data: GistResponseData;
|
|
63
|
+
}>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Shape of a single Gist in a list response (subset). */
|
|
67
|
+
interface GistListItem {
|
|
68
|
+
id: string;
|
|
69
|
+
description: string | null;
|
|
70
|
+
}
|
|
71
|
+
/** Shape of a full Gist response (subset). */
|
|
72
|
+
interface GistResponseData {
|
|
73
|
+
id: string;
|
|
74
|
+
description: string | null;
|
|
75
|
+
files: Record<string, {
|
|
76
|
+
filename: string;
|
|
77
|
+
content?: string;
|
|
78
|
+
} | null>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Gist-backed state store with in-memory file cache and local write-through.
|
|
82
|
+
*/
|
|
83
|
+
export declare class GistStateStore {
|
|
84
|
+
private gistId;
|
|
85
|
+
readonly cachedFiles: Map<string, string>;
|
|
86
|
+
readonly dirtyFiles: Set<string>;
|
|
87
|
+
private readonly octokit;
|
|
88
|
+
private lastRefreshAt;
|
|
89
|
+
private static readonly REFRESH_THROTTLE_MS;
|
|
90
|
+
constructor(octokit: OctokitLike);
|
|
91
|
+
/**
|
|
92
|
+
* Bootstrap the Gist store: locate or create the backing Gist,
|
|
93
|
+
* populate the in-memory cache, and write the local cache file.
|
|
94
|
+
*/
|
|
95
|
+
bootstrap(): Promise<BootstrapResult>;
|
|
96
|
+
/**
|
|
97
|
+
* Bootstrap with migration from an existing local state.
|
|
98
|
+
* If a Gist already exists (found via local ID or search), uses it — no migration needed.
|
|
99
|
+
* If no Gist exists, creates one seeded with the provided existingState instead of a fresh state.
|
|
100
|
+
* @returns BootstrapResult extended with `migrated: true` if a new Gist was created from local state
|
|
101
|
+
*/
|
|
102
|
+
bootstrapWithMigration(existingState: AgentState): Promise<BootstrapResult & {
|
|
103
|
+
migrated: boolean;
|
|
104
|
+
}>;
|
|
105
|
+
/** Return the resolved Gist ID (available after bootstrap). */
|
|
106
|
+
getGistId(): string | null;
|
|
107
|
+
/**
|
|
108
|
+
* Mark a file as dirty so it will be included in the next `push()` call.
|
|
109
|
+
*/
|
|
110
|
+
markDirty(filename: string): void;
|
|
111
|
+
/**
|
|
112
|
+
* Read a freeform document from the in-memory cache.
|
|
113
|
+
* Returns null if the file has not been loaded (or does not exist in the Gist).
|
|
114
|
+
* Synchronous — all Gist contents are loaded into memory at bootstrap.
|
|
115
|
+
*/
|
|
116
|
+
getDocument(filename: string): string | null;
|
|
117
|
+
/**
|
|
118
|
+
* Write a freeform document into the in-memory cache and mark it dirty
|
|
119
|
+
* so it will be included in the next `push()` call.
|
|
120
|
+
*/
|
|
121
|
+
setDocument(filename: string, content: string): void;
|
|
122
|
+
/**
|
|
123
|
+
* Return all filenames in the in-memory cache whose names start with `prefix`.
|
|
124
|
+
* Useful for listing all guidelines files (e.g. prefix `guidelines--`).
|
|
125
|
+
*/
|
|
126
|
+
listDocuments(prefix: string): string[];
|
|
127
|
+
/**
|
|
128
|
+
* Stage new state JSON for the next `push()`. Updates the in-memory cache
|
|
129
|
+
* for `state.json` and marks it dirty.
|
|
130
|
+
*/
|
|
131
|
+
setState(stateJson: string): void;
|
|
132
|
+
/**
|
|
133
|
+
* Push all dirty files to the backing Gist. Retries once on failure.
|
|
134
|
+
*
|
|
135
|
+
* Returns `true` on success (or when there is nothing to push).
|
|
136
|
+
* Returns `false` if both attempts fail.
|
|
137
|
+
* Throws if the Gist ID has not been resolved yet (bootstrap not called).
|
|
138
|
+
*/
|
|
139
|
+
push(): Promise<boolean>;
|
|
140
|
+
/**
|
|
141
|
+
* Re-fetch the Gist and update the in-memory cache.
|
|
142
|
+
* Throttled to at most once per 30 seconds.
|
|
143
|
+
*/
|
|
144
|
+
refreshFromGist(): Promise<boolean>;
|
|
145
|
+
/**
|
|
146
|
+
* Preflight check: verify the token has Gist API scope.
|
|
147
|
+
* Costs one cheap API call; catches permission issues early with a clear message.
|
|
148
|
+
*/
|
|
149
|
+
private checkGistScope;
|
|
150
|
+
/**
|
|
151
|
+
* Fetch a Gist by ID, populate the in-memory cache, parse state,
|
|
152
|
+
* and write the local cache file.
|
|
153
|
+
*/
|
|
154
|
+
private fetchAndCache;
|
|
155
|
+
/**
|
|
156
|
+
* Parse `state.json` from the in-memory cache. Handles v2 migration
|
|
157
|
+
* by running through the Zod schema (which requires version: 3).
|
|
158
|
+
* Falls back to fresh state if the file is missing or unparseable.
|
|
159
|
+
*/
|
|
160
|
+
private parseStateFromCache;
|
|
161
|
+
/**
|
|
162
|
+
* Search the authenticated user's Gists for one with the well-known description.
|
|
163
|
+
* Pages through up to 10 pages (100 Gists per page) to find it.
|
|
164
|
+
*/
|
|
165
|
+
private searchForGist;
|
|
166
|
+
/**
|
|
167
|
+
* Create a new private Gist with seed files and store it in memory.
|
|
168
|
+
*/
|
|
169
|
+
private createGist;
|
|
170
|
+
/**
|
|
171
|
+
* Create a new private Gist seeded with the provided state (for migration).
|
|
172
|
+
*/
|
|
173
|
+
private createGistFromState;
|
|
174
|
+
/** Read the locally persisted Gist ID, or return null if not found. */
|
|
175
|
+
private readLocalGistId;
|
|
176
|
+
/** Persist the Gist ID locally for fast lookup on next session. */
|
|
177
|
+
private writeLocalGistId;
|
|
178
|
+
/** Write state to the local cache file for degraded-mode fallback. */
|
|
179
|
+
private writeLocalStateCache;
|
|
180
|
+
}
|
|
181
|
+
export {};
|