@oss-autopilot/core 3.2.0 → 3.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/dist/cli-registry.js +33 -3
- package/dist/cli.bundle.cjs +96 -93
- package/dist/commands/check-integration.js +8 -8
- package/dist/commands/comments.js +3 -0
- package/dist/commands/config.js +14 -7
- package/dist/commands/daily-render.js +10 -5
- package/dist/commands/daily.js +6 -1
- package/dist/commands/dashboard-lifecycle.js +1 -1
- package/dist/commands/dashboard-process.js +4 -4
- package/dist/commands/dashboard-server.js +7 -6
- package/dist/commands/dashboard.js +2 -2
- package/dist/commands/detect-formatters.js +3 -3
- package/dist/commands/doctor.js +5 -5
- package/dist/commands/guidelines.js +15 -3
- package/dist/commands/list-move-tier.js +5 -5
- package/dist/commands/local-repos.js +9 -9
- package/dist/commands/parse-list.js +10 -10
- package/dist/commands/scout-bridge.js +2 -2
- package/dist/commands/setup.js +24 -13
- package/dist/commands/skip-add.js +6 -3
- package/dist/commands/skip-file-parser.js +3 -3
- package/dist/commands/startup.js +11 -8
- package/dist/commands/state-cmd.js +1 -1
- package/dist/commands/status.js +7 -0
- package/dist/commands/validation.js +3 -3
- package/dist/commands/vet-list.js +12 -8
- package/dist/commands/vet.js +1 -2
- package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
- package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
- package/dist/core/anti-llm-policy.js +5 -5
- package/dist/core/auth.js +5 -5
- package/dist/core/daily-logic.js +8 -4
- package/dist/core/dates.js +3 -3
- package/dist/core/errors.d.ts +29 -0
- package/dist/core/errors.js +63 -0
- package/dist/core/formatter-detection.js +9 -9
- package/dist/core/gist-state-store.d.ts +18 -2
- package/dist/core/gist-state-store.js +73 -13
- package/dist/core/guidelines-store.js +2 -2
- package/dist/core/http-cache.js +6 -6
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/issue-conversation.js +3 -1
- package/dist/core/paths.js +4 -4
- package/dist/core/pr-monitor.js +1 -2
- package/dist/core/pr-template.js +1 -1
- package/dist/core/state-persistence.js +7 -7
- package/dist/core/state.d.ts +27 -0
- package/dist/core/state.js +66 -13
- package/dist/core/untrusted-content.d.ts +48 -0
- package/dist/core/untrusted-content.js +106 -0
- package/dist/core/urls.js +2 -2
- package/dist/formatters/json.d.ts +53 -3
- package/dist/formatters/json.js +49 -14
- package/package.json +1 -1
package/dist/core/errors.d.ts
CHANGED
|
@@ -32,6 +32,20 @@ export declare class ValidationError extends OssAutopilotError {
|
|
|
32
32
|
export declare class GistPermissionError extends ConfigurationError {
|
|
33
33
|
constructor(message?: string);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown when state.json fetched from a Gist is malformed or fails Zod
|
|
37
|
+
* validation. The Gist content is preserved as a `.rejected-<ts>.json` file
|
|
38
|
+
* in the local cache directory so the user can inspect or recover from it
|
|
39
|
+
* before the next push overwrites the Gist with fresh state.
|
|
40
|
+
*
|
|
41
|
+
* See issue #1201.
|
|
42
|
+
*/
|
|
43
|
+
export declare class GistCorruptError extends ConfigurationError {
|
|
44
|
+
readonly gistId: string;
|
|
45
|
+
readonly rejectedPath: string | null;
|
|
46
|
+
readonly cause: unknown;
|
|
47
|
+
constructor(gistId: string, rejectedPath: string | null, cause: unknown);
|
|
48
|
+
}
|
|
35
49
|
/**
|
|
36
50
|
* Thrown when an optimistic compare-and-swap on state.json detects that
|
|
37
51
|
* another process wrote the file between load and save. See issue #1030.
|
|
@@ -73,6 +87,21 @@ export declare function getHttpStatusCode(error: unknown): number | undefined;
|
|
|
73
87
|
export declare function isRateLimitError(error: unknown): boolean;
|
|
74
88
|
/** Return true for errors that should propagate (not degrade gracefully): rate limits, auth failures, abuse detection. */
|
|
75
89
|
export declare function isRateLimitOrAuthError(err: unknown): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Check if an error is a transient network/server-side failure that's safe
|
|
92
|
+
* to retry or fall back from (vs. a permanent error that should surface).
|
|
93
|
+
*
|
|
94
|
+
* Returns true for:
|
|
95
|
+
* - Node socket errors: ECONNRESET, ETIMEDOUT, ENETUNREACH, ENOTFOUND, ECONNREFUSED
|
|
96
|
+
* - HTTP 5xx (server errors)
|
|
97
|
+
* - AbortError (timeout)
|
|
98
|
+
*
|
|
99
|
+
* Returns false for everything else (including 4xx, schema errors, config errors).
|
|
100
|
+
*
|
|
101
|
+
* Used by {@link getStateManagerAsync} to decide whether a Gist init failure
|
|
102
|
+
* is recoverable enough to silently fall back to local-only mode (#1202).
|
|
103
|
+
*/
|
|
104
|
+
export declare function isTransientNetworkError(err: unknown): boolean;
|
|
76
105
|
/**
|
|
77
106
|
* Build a `.catch()` handler for the "non-fatal parallel fetch" pattern used
|
|
78
107
|
* by daily.ts and dashboard-data.ts (#960). When a sibling fetch fails during
|
package/dist/core/errors.js
CHANGED
|
@@ -49,6 +49,31 @@ export class GistPermissionError extends ConfigurationError {
|
|
|
49
49
|
this.name = 'GistPermissionError';
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Thrown when state.json fetched from a Gist is malformed or fails Zod
|
|
54
|
+
* validation. The Gist content is preserved as a `.rejected-<ts>.json` file
|
|
55
|
+
* in the local cache directory so the user can inspect or recover from it
|
|
56
|
+
* before the next push overwrites the Gist with fresh state.
|
|
57
|
+
*
|
|
58
|
+
* See issue #1201.
|
|
59
|
+
*/
|
|
60
|
+
export class GistCorruptError extends ConfigurationError {
|
|
61
|
+
gistId;
|
|
62
|
+
rejectedPath;
|
|
63
|
+
cause;
|
|
64
|
+
constructor(gistId, rejectedPath, cause) {
|
|
65
|
+
const causeMsg = cause instanceof Error ? cause.message : String(cause);
|
|
66
|
+
const recoverHint = rejectedPath
|
|
67
|
+
? `\nCorrupt content preserved at: ${rejectedPath}\n` +
|
|
68
|
+
'Inspect or restore manually, then re-run. Falling back to a fresh state would overwrite your Gist.'
|
|
69
|
+
: '\nCould not preserve the rejected content; do not push without inspecting the Gist manually.';
|
|
70
|
+
super(`Gist ${gistId} state.json is corrupt or fails schema validation: ${causeMsg}${recoverHint}`);
|
|
71
|
+
this.gistId = gistId;
|
|
72
|
+
this.rejectedPath = rejectedPath;
|
|
73
|
+
this.cause = cause;
|
|
74
|
+
this.name = 'GistCorruptError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
52
77
|
/**
|
|
53
78
|
* Thrown when an optimistic compare-and-swap on state.json detects that
|
|
54
79
|
* another process wrote the file between load and save. See issue #1030.
|
|
@@ -128,6 +153,44 @@ export function isRateLimitOrAuthError(err) {
|
|
|
128
153
|
}
|
|
129
154
|
return false;
|
|
130
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Check if an error is a transient network/server-side failure that's safe
|
|
158
|
+
* to retry or fall back from (vs. a permanent error that should surface).
|
|
159
|
+
*
|
|
160
|
+
* Returns true for:
|
|
161
|
+
* - Node socket errors: ECONNRESET, ETIMEDOUT, ENETUNREACH, ENOTFOUND, ECONNREFUSED
|
|
162
|
+
* - HTTP 5xx (server errors)
|
|
163
|
+
* - AbortError (timeout)
|
|
164
|
+
*
|
|
165
|
+
* Returns false for everything else (including 4xx, schema errors, config errors).
|
|
166
|
+
*
|
|
167
|
+
* Used by {@link getStateManagerAsync} to decide whether a Gist init failure
|
|
168
|
+
* is recoverable enough to silently fall back to local-only mode (#1202).
|
|
169
|
+
*/
|
|
170
|
+
export function isTransientNetworkError(err) {
|
|
171
|
+
if (!err || typeof err !== 'object')
|
|
172
|
+
return false;
|
|
173
|
+
const status = getHttpStatusCode(err);
|
|
174
|
+
if (typeof status === 'number' && status >= 500 && status < 600)
|
|
175
|
+
return true;
|
|
176
|
+
// Node socket-level errors expose `code`
|
|
177
|
+
const code = err.code;
|
|
178
|
+
if (typeof code === 'string') {
|
|
179
|
+
if (code === 'ECONNRESET' ||
|
|
180
|
+
code === 'ETIMEDOUT' ||
|
|
181
|
+
code === 'ENETUNREACH' ||
|
|
182
|
+
code === 'ENOTFOUND' ||
|
|
183
|
+
code === 'ECONNREFUSED' ||
|
|
184
|
+
code === 'EAI_AGAIN') {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Octokit's RequestError surfaces the underlying name; fetch timeout is AbortError
|
|
189
|
+
const name = err.name;
|
|
190
|
+
if (typeof name === 'string' && (name === 'AbortError' || name === 'TimeoutError'))
|
|
191
|
+
return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
131
194
|
/**
|
|
132
195
|
* Build a `.catch()` handler for the "non-fatal parallel fetch" pattern used
|
|
133
196
|
* by daily.ts and dashboard-data.ts (#960). When a sibling fetch fails during
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Programmatically detects formatters/linters configured in a local repo directory
|
|
5
5
|
* and diagnoses CI formatting failures from log output.
|
|
6
6
|
*/
|
|
7
|
-
import * as fs from 'fs';
|
|
8
|
-
import * as path from 'path';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
9
|
import { debug } from './logger.js';
|
|
10
10
|
const MODULE = 'formatter-detection';
|
|
11
11
|
// ── Prettier config file patterns ──────────────────────────────────────────
|
|
@@ -42,7 +42,7 @@ const FORMAT_SCRIPT_NAMES = ['lint:fix', 'format', 'fmt', 'lint', 'format:check'
|
|
|
42
42
|
const CI_PATTERNS = [
|
|
43
43
|
{
|
|
44
44
|
formatter: 'prettier',
|
|
45
|
-
patterns: [/
|
|
45
|
+
patterns: [/code style issues found/i, /forgot to run prettier/i, /prettier --check/i],
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
formatter: 'ruff',
|
|
@@ -50,19 +50,19 @@ const CI_PATTERNS = [
|
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
formatter: 'black',
|
|
53
|
-
patterns: [/
|
|
53
|
+
patterns: [/oh no! .* files? would be reformatted/i, /black --check/i],
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
formatter: 'rustfmt',
|
|
57
|
-
patterns: [/
|
|
57
|
+
patterns: [/diff in .*\.rs/i, /rustfmt --check/i, /cargo fmt.*--check/i],
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
formatter: 'biome',
|
|
61
|
-
patterns: [/biome check/i, /biome ci/i, /
|
|
61
|
+
patterns: [/biome check/i, /biome ci/i, /found \d+ fixable diagnostics?/i],
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
formatter: 'eslint',
|
|
65
|
-
patterns: [/eslint.*--fix/i, /eslint
|
|
65
|
+
patterns: [/eslint.*--fix/i, /eslint\D+\d+ problems?/i],
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
formatter: 'gofmt',
|
|
@@ -82,7 +82,7 @@ const CI_PATTERNS = [
|
|
|
82
82
|
*/
|
|
83
83
|
function readJsonFile(filePath) {
|
|
84
84
|
try {
|
|
85
|
-
const content = fs.readFileSync(filePath, '
|
|
85
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
86
86
|
return JSON.parse(content);
|
|
87
87
|
}
|
|
88
88
|
catch (err) {
|
|
@@ -95,7 +95,7 @@ function readJsonFile(filePath) {
|
|
|
95
95
|
*/
|
|
96
96
|
function readTextFile(filePath) {
|
|
97
97
|
try {
|
|
98
|
-
return fs.readFileSync(filePath, '
|
|
98
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
99
99
|
}
|
|
100
100
|
catch (err) {
|
|
101
101
|
debug(MODULE, `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -121,6 +121,14 @@ export declare class GistStateStore {
|
|
|
121
121
|
private readonly octokit;
|
|
122
122
|
private lastRefreshAt;
|
|
123
123
|
private static readonly REFRESH_THROTTLE_MS;
|
|
124
|
+
/**
|
|
125
|
+
* Most recent error from a `refreshFromGist()` attempt, or `null` when the
|
|
126
|
+
* last attempt succeeded or was skipped by the throttle. Lets callers
|
|
127
|
+
* (StateManager) distinguish "throttled, nothing new to see" from "fetch
|
|
128
|
+
* failed, you're now operating on stale state" without changing the
|
|
129
|
+
* existing boolean return contract (#1193).
|
|
130
|
+
*/
|
|
131
|
+
lastRefreshError: Error | null;
|
|
124
132
|
constructor(octokit: OctokitLike);
|
|
125
133
|
/**
|
|
126
134
|
* Bootstrap the Gist store: locate or create the backing Gist,
|
|
@@ -199,9 +207,17 @@ export declare class GistStateStore {
|
|
|
199
207
|
*/
|
|
200
208
|
private fetchAndCache;
|
|
201
209
|
/**
|
|
202
|
-
* Parse `state.json` from the in-memory cache. Handles
|
|
210
|
+
* Parse `state.json` from the in-memory cache. Handles v1→v4 migration
|
|
203
211
|
* by running through the Zod schema (which requires version: 4).
|
|
204
|
-
*
|
|
212
|
+
*
|
|
213
|
+
* Throws {@link GistCorruptError} on parse or schema-validation failure.
|
|
214
|
+
* The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
|
|
215
|
+
* so the caller can recover (#1201).
|
|
216
|
+
*
|
|
217
|
+
* Returning fresh state on failure (the previous behavior) is unsafe in
|
|
218
|
+
* Gist mode because the next `push()` would overwrite the Gist with the
|
|
219
|
+
* empty fallback, silently destroying repoScores, dismissedIssues,
|
|
220
|
+
* guidelines pointers, and digest history.
|
|
205
221
|
*/
|
|
206
222
|
private parseStateFromCache;
|
|
207
223
|
/**
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
* cells, which matches the "last-write-wins by intent" model the rest of
|
|
32
32
|
* oss-autopilot already assumes for state.json.
|
|
33
33
|
*/
|
|
34
|
-
import * as fs from 'fs';
|
|
34
|
+
import * as fs from 'node:fs';
|
|
35
35
|
import { AgentStateSchema } from './state-schema.js';
|
|
36
36
|
import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3, migrateV3ToV4, } from './state-persistence.js';
|
|
37
37
|
import { getGistIdPath, getStateCachePath } from './paths.js';
|
|
38
38
|
import { debug, warn } from './logger.js';
|
|
39
|
-
import { GistPermissionError, GistConcurrencyError, isRateLimitError } from './errors.js';
|
|
39
|
+
import { GistPermissionError, GistConcurrencyError, GistCorruptError, isRateLimitError } from './errors.js';
|
|
40
40
|
const MODULE = 'gist-store';
|
|
41
41
|
/**
|
|
42
42
|
* Extract the ETag header from an Octokit response, tolerating both lower-
|
|
@@ -48,6 +48,24 @@ function extractEtag(headers) {
|
|
|
48
48
|
return null;
|
|
49
49
|
return headers.etag ?? headers.ETag ?? null;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Preserve corrupt Gist content for user recovery. Returns the path written,
|
|
53
|
+
* or null if the preservation itself failed (logged at warn). Mirrors the
|
|
54
|
+
* pattern in state-persistence.ts:401-407 for the local-state path.
|
|
55
|
+
*
|
|
56
|
+
* See {@link GistCorruptError} and issue #1201.
|
|
57
|
+
*/
|
|
58
|
+
function preserveRejectedGistContent(raw, gistId) {
|
|
59
|
+
try {
|
|
60
|
+
const rejectedPath = `${getStateCachePath()}.rejected-${Date.now()}`;
|
|
61
|
+
fs.writeFileSync(rejectedPath, raw, { encoding: 'utf8', mode: 0o600 });
|
|
62
|
+
return rejectedPath;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
warn(MODULE, `Could not preserve rejected Gist content for ${gistId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
51
69
|
/** Well-known Gist description used for search-based discovery. */
|
|
52
70
|
export const GIST_DESCRIPTION = 'oss-autopilot-state';
|
|
53
71
|
/** Primary state file name inside the Gist. */
|
|
@@ -70,6 +88,14 @@ export class GistStateStore {
|
|
|
70
88
|
octokit;
|
|
71
89
|
lastRefreshAt = 0;
|
|
72
90
|
static REFRESH_THROTTLE_MS = 30_000;
|
|
91
|
+
/**
|
|
92
|
+
* Most recent error from a `refreshFromGist()` attempt, or `null` when the
|
|
93
|
+
* last attempt succeeded or was skipped by the throttle. Lets callers
|
|
94
|
+
* (StateManager) distinguish "throttled, nothing new to see" from "fetch
|
|
95
|
+
* failed, you're now operating on stale state" without changing the
|
|
96
|
+
* existing boolean return contract (#1193).
|
|
97
|
+
*/
|
|
98
|
+
lastRefreshError = null;
|
|
73
99
|
constructor(octokit) {
|
|
74
100
|
this.octokit = octokit;
|
|
75
101
|
}
|
|
@@ -116,10 +142,12 @@ export class GistStateStore {
|
|
|
116
142
|
// All API paths failed — enter degraded mode
|
|
117
143
|
warn(MODULE, 'All Gist API paths failed, entering degraded mode', err);
|
|
118
144
|
// Try reading from local cache file
|
|
145
|
+
const cachePath = getStateCachePath();
|
|
146
|
+
let cacheRaw = null;
|
|
119
147
|
try {
|
|
120
|
-
const cachePath = getStateCachePath();
|
|
121
148
|
if (fs.existsSync(cachePath)) {
|
|
122
|
-
|
|
149
|
+
cacheRaw = fs.readFileSync(cachePath, 'utf8');
|
|
150
|
+
let obj = JSON.parse(cacheRaw);
|
|
123
151
|
// Chain migrations
|
|
124
152
|
if (typeof obj === 'object' && obj !== null) {
|
|
125
153
|
const record = obj;
|
|
@@ -136,7 +164,16 @@ export class GistStateStore {
|
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
166
|
catch (cacheErr) {
|
|
139
|
-
|
|
167
|
+
// Promote to warn (#1201) and preserve corrupt cache so fresh-state
|
|
168
|
+
// fallback doesn't silently destroy recoverable data on next push.
|
|
169
|
+
if (cacheRaw) {
|
|
170
|
+
const rejectedPath = preserveRejectedGistContent(cacheRaw, 'local-cache');
|
|
171
|
+
warn(MODULE, `Local state cache failed to parse in degraded mode: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}. ` +
|
|
172
|
+
`Corrupt cache preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
warn(MODULE, `Failed to read local cache in degraded mode: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`);
|
|
176
|
+
}
|
|
140
177
|
}
|
|
141
178
|
// No cache either — return fresh state in degraded mode
|
|
142
179
|
debug(MODULE, 'No local cache found, returning fresh state in degraded mode');
|
|
@@ -188,10 +225,12 @@ export class GistStateStore {
|
|
|
188
225
|
// All API paths failed — enter degraded mode
|
|
189
226
|
warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
|
|
190
227
|
// Try reading from local cache file
|
|
228
|
+
const cachePath = getStateCachePath();
|
|
229
|
+
let cacheRaw = null;
|
|
191
230
|
try {
|
|
192
|
-
const cachePath = getStateCachePath();
|
|
193
231
|
if (fs.existsSync(cachePath)) {
|
|
194
|
-
|
|
232
|
+
cacheRaw = fs.readFileSync(cachePath, 'utf8');
|
|
233
|
+
let obj = JSON.parse(cacheRaw);
|
|
195
234
|
// Chain migrations
|
|
196
235
|
if (typeof obj === 'object' && obj !== null) {
|
|
197
236
|
const record = obj;
|
|
@@ -208,7 +247,14 @@ export class GistStateStore {
|
|
|
208
247
|
}
|
|
209
248
|
}
|
|
210
249
|
catch (cacheErr) {
|
|
211
|
-
|
|
250
|
+
if (cacheRaw) {
|
|
251
|
+
const rejectedPath = preserveRejectedGistContent(cacheRaw, 'local-cache');
|
|
252
|
+
warn(MODULE, `bootstrapWithMigration: local cache failed to parse: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}. ` +
|
|
253
|
+
`Corrupt cache preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
warn(MODULE, `bootstrapWithMigration: failed to read local cache: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`);
|
|
257
|
+
}
|
|
212
258
|
}
|
|
213
259
|
// No cache either — use the provided existingState in degraded mode
|
|
214
260
|
debug(MODULE, 'bootstrapWithMigration: no local cache found, returning existing state in degraded mode');
|
|
@@ -381,15 +427,19 @@ export class GistStateStore {
|
|
|
381
427
|
if (!this.gistId)
|
|
382
428
|
return false;
|
|
383
429
|
const now = Date.now();
|
|
430
|
+
// Throttle hits are not failures — preserve any previous lastRefreshError
|
|
431
|
+
// for the caller to inspect.
|
|
384
432
|
if (now - this.lastRefreshAt < GistStateStore.REFRESH_THROTTLE_MS)
|
|
385
433
|
return false;
|
|
386
434
|
try {
|
|
387
435
|
await this.fetchAndCache(this.gistId);
|
|
388
436
|
this.lastRefreshAt = now;
|
|
437
|
+
this.lastRefreshError = null;
|
|
389
438
|
return true;
|
|
390
439
|
}
|
|
391
440
|
catch (err) {
|
|
392
441
|
warn(MODULE, `refreshFromGist failed: ${err}`);
|
|
442
|
+
this.lastRefreshError = err instanceof Error ? err : new Error(String(err));
|
|
393
443
|
return false;
|
|
394
444
|
}
|
|
395
445
|
}
|
|
@@ -436,9 +486,17 @@ export class GistStateStore {
|
|
|
436
486
|
return state;
|
|
437
487
|
}
|
|
438
488
|
/**
|
|
439
|
-
* Parse `state.json` from the in-memory cache. Handles
|
|
489
|
+
* Parse `state.json` from the in-memory cache. Handles v1→v4 migration
|
|
440
490
|
* by running through the Zod schema (which requires version: 4).
|
|
441
|
-
*
|
|
491
|
+
*
|
|
492
|
+
* Throws {@link GistCorruptError} on parse or schema-validation failure.
|
|
493
|
+
* The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
|
|
494
|
+
* so the caller can recover (#1201).
|
|
495
|
+
*
|
|
496
|
+
* Returning fresh state on failure (the previous behavior) is unsafe in
|
|
497
|
+
* Gist mode because the next `push()` would overwrite the Gist with the
|
|
498
|
+
* empty fallback, silently destroying repoScores, dismissedIssues,
|
|
499
|
+
* guidelines pointers, and digest history.
|
|
442
500
|
*/
|
|
443
501
|
parseStateFromCache() {
|
|
444
502
|
const raw = this.cachedFiles.get(STATE_FILE_NAME);
|
|
@@ -461,8 +519,10 @@ export class GistStateStore {
|
|
|
461
519
|
return AgentStateSchema.parse(obj);
|
|
462
520
|
}
|
|
463
521
|
catch (err) {
|
|
464
|
-
|
|
465
|
-
|
|
522
|
+
const rejectedPath = preserveRejectedGistContent(raw, this.gistId ?? 'unknown');
|
|
523
|
+
warn(MODULE, `Gist state.json failed to parse — refusing to overwrite with fresh state. ` +
|
|
524
|
+
`Corrupt content preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
525
|
+
throw new GistCorruptError(this.gistId ?? 'unknown', rejectedPath, err);
|
|
466
526
|
}
|
|
467
527
|
}
|
|
468
528
|
/**
|
|
@@ -541,7 +601,7 @@ export class GistStateStore {
|
|
|
541
601
|
try {
|
|
542
602
|
const gistIdPath = getGistIdPath();
|
|
543
603
|
if (fs.existsSync(gistIdPath)) {
|
|
544
|
-
const id = fs.readFileSync(gistIdPath, '
|
|
604
|
+
const id = fs.readFileSync(gistIdPath, 'utf8').trim();
|
|
545
605
|
return id || null;
|
|
546
606
|
}
|
|
547
607
|
}
|
|
@@ -2,7 +2,7 @@ import { OssAutopilotError } from './errors.js';
|
|
|
2
2
|
/** Filename prefix shared by every guidelines file in the Gist. */
|
|
3
3
|
export const GUIDELINES_FILE_PREFIX = 'guidelines--';
|
|
4
4
|
/** Hard byte budget for a single guidelines file (#867 design log §1). */
|
|
5
|
-
export const GUIDELINES_MAX_BYTES =
|
|
5
|
+
export const GUIDELINES_MAX_BYTES = 8192;
|
|
6
6
|
/** Suffix appended to the filename so it renders as markdown in Gist. */
|
|
7
7
|
const GUIDELINES_FILE_SUFFIX = '.md';
|
|
8
8
|
/**
|
|
@@ -83,7 +83,7 @@ export function getGuidelines(store, repo) {
|
|
|
83
83
|
export function setGuidelines(store, repo, content) {
|
|
84
84
|
if (!store)
|
|
85
85
|
throw new GuidelinesNotAvailableError();
|
|
86
|
-
const byteSize = Buffer.byteLength(content, '
|
|
86
|
+
const byteSize = Buffer.byteLength(content, 'utf8');
|
|
87
87
|
if (byteSize > GUIDELINES_MAX_BYTES) {
|
|
88
88
|
throw new GuidelinesTooLargeError(byteSize);
|
|
89
89
|
}
|
package/dist/core/http-cache.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* for the same endpoint (e.g., star counts for two PRs in the same repo)
|
|
10
10
|
* share a single HTTP round-trip.
|
|
11
11
|
*/
|
|
12
|
-
import * as fs from 'fs';
|
|
13
|
-
import * as path from 'path';
|
|
14
|
-
import * as crypto from 'crypto';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import * as crypto from 'node:crypto';
|
|
15
15
|
import { getCacheDir } from './paths.js';
|
|
16
16
|
import { debug } from './logger.js';
|
|
17
17
|
import { getHttpStatusCode } from './errors.js';
|
|
@@ -76,7 +76,7 @@ export class HttpCache {
|
|
|
76
76
|
get(url) {
|
|
77
77
|
const filePath = this.pathFor(url);
|
|
78
78
|
try {
|
|
79
|
-
const raw = fs.readFileSync(filePath, '
|
|
79
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
80
80
|
const entry = JSON.parse(raw);
|
|
81
81
|
// Sanity-check: the file should contain the URL we asked for
|
|
82
82
|
if (entry.url !== url) {
|
|
@@ -100,7 +100,7 @@ export class HttpCache {
|
|
|
100
100
|
cachedAt: new Date().toISOString(),
|
|
101
101
|
};
|
|
102
102
|
try {
|
|
103
|
-
fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: '
|
|
103
|
+
fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf8', mode: 0o600 });
|
|
104
104
|
debug(MODULE, `Cached response for ${url}`);
|
|
105
105
|
// Best-effort size cap (#1057 M27). Runs after each write rather than on
|
|
106
106
|
// a schedule so long-lived sessions can't accumulate past the cap.
|
|
@@ -191,7 +191,7 @@ export class HttpCache {
|
|
|
191
191
|
continue;
|
|
192
192
|
const filePath = path.join(this.cacheDir, file);
|
|
193
193
|
try {
|
|
194
|
-
const raw = fs.readFileSync(filePath, '
|
|
194
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
195
195
|
const entry = JSON.parse(raw);
|
|
196
196
|
const age = now - new Date(entry.cachedAt).getTime();
|
|
197
197
|
if (age > maxAgeMs) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX,
|
|
|
8
8
|
export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
|
|
9
9
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
10
10
|
export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
|
|
11
|
+
export { wrapUntrustedContent, extractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
|
|
11
12
|
export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
|
|
12
13
|
export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
|
|
13
14
|
export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
|
package/dist/core/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks,
|
|
|
9
9
|
// Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
|
|
10
10
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
11
11
|
export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
|
|
12
|
+
export { wrapUntrustedContent, extractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
|
|
12
13
|
export { getOctokit, checkRateLimit } from './github.js';
|
|
13
14
|
export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
|
|
14
15
|
export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
|
|
@@ -151,7 +151,9 @@ export class IssueConversationMonitor {
|
|
|
151
151
|
body: comment.body || '',
|
|
152
152
|
createdAt: comment.created_at,
|
|
153
153
|
isUser: author.toLowerCase() === username.toLowerCase(),
|
|
154
|
-
authorAssociation:
|
|
154
|
+
authorAssociation: typeof comment.author_association === 'string'
|
|
155
|
+
? comment.author_association
|
|
156
|
+
: '',
|
|
155
157
|
});
|
|
156
158
|
}
|
|
157
159
|
timeline.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
package/dist/core/paths.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Extracted from utils.ts under #1116.
|
|
8
8
|
*/
|
|
9
|
-
import * as fs from 'fs';
|
|
10
|
-
import * as path from 'path';
|
|
11
|
-
import * as os from 'os';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
12
|
/**
|
|
13
13
|
* Returns the oss-autopilot data directory path, creating it if it does not exist.
|
|
14
14
|
*
|
|
@@ -98,7 +98,7 @@ export function stateFileExists() {
|
|
|
98
98
|
export function getCLIVersion() {
|
|
99
99
|
try {
|
|
100
100
|
const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
|
|
101
|
-
return JSON.parse(fs.readFileSync(pkgPath, '
|
|
101
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
102
102
|
}
|
|
103
103
|
catch {
|
|
104
104
|
return '0.0.0';
|
package/dist/core/pr-monitor.js
CHANGED
|
@@ -16,9 +16,8 @@ import { getOctokit } from './github.js';
|
|
|
16
16
|
import { getStateManager } from './state.js';
|
|
17
17
|
import { daysBetween } from './dates.js';
|
|
18
18
|
import { parseGitHubUrl, extractOwnerRepo, isOwnRepo } from './urls.js';
|
|
19
|
-
import { DEFAULT_CONCURRENCY } from './concurrency.js';
|
|
19
|
+
import { DEFAULT_CONCURRENCY, runWorkerPool } from './concurrency.js';
|
|
20
20
|
import { determineStatus } from './status-determination.js';
|
|
21
|
-
import { runWorkerPool } from './concurrency.js';
|
|
22
21
|
import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitOrAuthError, } from './errors.js';
|
|
23
22
|
import { paginateAll } from './pagination.js';
|
|
24
23
|
import { debug, warn, timed } from './logger.js';
|
package/dist/core/pr-template.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function fetchPRTemplate(octokit, owner, repo) {
|
|
|
43
43
|
debug(MODULE, `${path} has no content, skipping`);
|
|
44
44
|
continue;
|
|
45
45
|
}
|
|
46
|
-
const template = Buffer.from(data.content, 'base64').toString('
|
|
46
|
+
const template = Buffer.from(data.content, 'base64').toString('utf8');
|
|
47
47
|
debug(MODULE, `Found PR template at ${path} (${template.length} chars)`);
|
|
48
48
|
return { template, source: path };
|
|
49
49
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Handles file I/O, locking, backup/restore, and schema migration (v1→v2→v3).
|
|
4
4
|
* No module-level mutable state — functions accept/return AgentState objects.
|
|
5
5
|
*/
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
8
|
import { AgentStateSchema } from './state-schema.js';
|
|
9
9
|
import { getStatePath, getBackupDir, getDataDir } from './paths.js';
|
|
10
10
|
import { errorMessage, ConcurrencyError } from './errors.js';
|
|
@@ -21,7 +21,7 @@ const LEGACY_BACKUP_DIR = path.join(process.cwd(), 'data', 'backups');
|
|
|
21
21
|
*/
|
|
22
22
|
function isLockStale(lockPath) {
|
|
23
23
|
try {
|
|
24
|
-
const existing = JSON.parse(fs.readFileSync(lockPath, '
|
|
24
|
+
const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
25
25
|
return Date.now() - existing.timestamp > LOCK_TIMEOUT_MS;
|
|
26
26
|
}
|
|
27
27
|
catch (err) {
|
|
@@ -72,7 +72,7 @@ export function acquireLock(lockPath) {
|
|
|
72
72
|
*/
|
|
73
73
|
export function releaseLock(lockPath) {
|
|
74
74
|
try {
|
|
75
|
-
const data = JSON.parse(fs.readFileSync(lockPath, '
|
|
75
|
+
const data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
76
76
|
if (data.pid === process.pid) {
|
|
77
77
|
fs.unlinkSync(lockPath);
|
|
78
78
|
}
|
|
@@ -273,7 +273,7 @@ function tryRestoreFromBackup() {
|
|
|
273
273
|
for (const backupFile of backupFiles) {
|
|
274
274
|
const backupPath = path.join(backupDir, backupFile);
|
|
275
275
|
try {
|
|
276
|
-
const data = fs.readFileSync(backupPath, '
|
|
276
|
+
const data = fs.readFileSync(backupPath, 'utf8');
|
|
277
277
|
let raw = JSON.parse(data);
|
|
278
278
|
// Chain migrations: v1 → v2 → v3
|
|
279
279
|
if (typeof raw === 'object' && raw !== null) {
|
|
@@ -325,7 +325,7 @@ export function loadState() {
|
|
|
325
325
|
const statePath = getStatePath();
|
|
326
326
|
try {
|
|
327
327
|
if (fs.existsSync(statePath)) {
|
|
328
|
-
const data = fs.readFileSync(statePath, '
|
|
328
|
+
const data = fs.readFileSync(statePath, 'utf8');
|
|
329
329
|
let raw = JSON.parse(data);
|
|
330
330
|
// Chain migrations: v1 → v2 → v3 → v4
|
|
331
331
|
let wasMigrated = false;
|
|
@@ -491,7 +491,7 @@ export function saveState(state, expectedMtimeMs = null) {
|
|
|
491
491
|
// Create backup of existing state (best-effort, non-fatal)
|
|
492
492
|
try {
|
|
493
493
|
if (fs.existsSync(statePath)) {
|
|
494
|
-
const timestamp = new Date().toISOString().replace(/[
|
|
494
|
+
const timestamp = new Date().toISOString().replace(/[.:]/g, '-');
|
|
495
495
|
const randomSuffix = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
496
496
|
const backupFile = path.join(backupDir, `state-${timestamp}-${randomSuffix}.json`);
|
|
497
497
|
fs.copyFileSync(statePath, backupFile);
|
package/dist/core/state.d.ts
CHANGED
|
@@ -26,6 +26,24 @@ export declare function maybeCheckpoint(stateManager: StateManager, callerModule
|
|
|
26
26
|
* Retains lightweight CRUD operations for config, issues, shelving, dismissal,
|
|
27
27
|
* and status overrides.
|
|
28
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* Surfaced when the in-memory cached state is no longer in sync with the
|
|
31
|
+
* canonical Gist — typically because `refreshFromGist()` failed (network
|
|
32
|
+
* blip, rate limit, expired token) or because the bootstrap fell back to
|
|
33
|
+
* the local cache file (#1193). Commands include this in their `--json`
|
|
34
|
+
* envelope so cron/dashboard consumers can react instead of silently
|
|
35
|
+
* operating on stale data.
|
|
36
|
+
*/
|
|
37
|
+
export interface StalenessInfo {
|
|
38
|
+
/** Why we're operating on cached data. Forward-compatible with future sources. */
|
|
39
|
+
source: 'cache';
|
|
40
|
+
/** Human-readable reason from the underlying error. */
|
|
41
|
+
reason: string;
|
|
42
|
+
/** ISO timestamp of the most recent successful refresh, or null if never. */
|
|
43
|
+
lastSuccessfulRefresh: string | null;
|
|
44
|
+
/** ISO timestamp when this staleness marker was first set. */
|
|
45
|
+
detectedAt: string;
|
|
46
|
+
}
|
|
29
47
|
export declare class StateManager {
|
|
30
48
|
protected state: AgentState;
|
|
31
49
|
protected inMemoryOnly: boolean;
|
|
@@ -34,6 +52,8 @@ export declare class StateManager {
|
|
|
34
52
|
private _batchDirty;
|
|
35
53
|
protected gistStore: GistStateStore | null;
|
|
36
54
|
protected gistDegraded: boolean;
|
|
55
|
+
private staleness;
|
|
56
|
+
private lastSuccessfulRefreshAt;
|
|
37
57
|
/**
|
|
38
58
|
* Create a new StateManager instance.
|
|
39
59
|
* @param inMemoryOnly - When true, state is held only in memory and never read from or
|
|
@@ -142,6 +162,13 @@ export declare class StateManager {
|
|
|
142
162
|
* Throttled to once per 30 seconds by GistStateStore. Returns true if state was refreshed.
|
|
143
163
|
*/
|
|
144
164
|
refreshFromGist(): Promise<boolean>;
|
|
165
|
+
/**
|
|
166
|
+
* Returns a staleness marker when the in-memory state diverged from the
|
|
167
|
+
* canonical Gist (refresh failure or degraded bootstrap), or `null` when
|
|
168
|
+
* state is current. Commands surface this via their `--json` warnings
|
|
169
|
+
* envelope (#1193).
|
|
170
|
+
*/
|
|
171
|
+
getStateStaleness(): StalenessInfo | null;
|
|
145
172
|
/**
|
|
146
173
|
* Store the latest daily digest and update the digest timestamp.
|
|
147
174
|
* @param digest - The daily digest to store
|