@graphorin/cli 0.6.1 → 0.7.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/CHANGELOG.md +63 -0
- package/README.md +3 -3
- package/dist/bin/graphorin.js +51 -13
- package/dist/bin/graphorin.js.map +1 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +2 -1
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/consolidator.d.ts +56 -1
- package/dist/commands/consolidator.d.ts.map +1 -1
- package/dist/commands/consolidator.js +88 -2
- package/dist/commands/consolidator.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/index.d.ts +4 -4
- package/dist/commands/index.js +4 -4
- package/dist/commands/init.d.ts +11 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -11
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/memory.d.ts +26 -1
- package/dist/commands/memory.d.ts.map +1 -1
- package/dist/commands/memory.js +56 -3
- package/dist/commands/memory.js.map +1 -1
- package/dist/commands/pricing.d.ts +6 -0
- package/dist/commands/pricing.d.ts.map +1 -1
- package/dist/commands/pricing.js +5 -2
- package/dist/commands/pricing.js.map +1 -1
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +2 -2
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +1 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/storage.d.ts +41 -1
- package/dist/commands/storage.d.ts.map +1 -1
- package/dist/commands/storage.js +75 -1
- package/dist/commands/storage.js.map +1 -1
- package/dist/commands/token.d.ts.map +1 -1
- package/dist/commands/token.js +2 -2
- package/dist/commands/token.js.map +1 -1
- package/dist/commands/tools-lint.js +1 -1
- package/dist/commands/tools-lint.js.map +1 -1
- package/dist/commands/traces.d.ts +14 -2
- package/dist/commands/traces.d.ts.map +1 -1
- package/dist/commands/traces.js +39 -22
- package/dist/commands/traces.js.map +1 -1
- package/dist/commands/triggers.d.ts.map +1 -1
- package/dist/commands/triggers.js +5 -2
- package/dist/commands/triggers.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/output.js +6 -0
- package/dist/internal/output.js.map +1 -1
- package/dist/internal/store-context.js +13 -2
- package/dist/internal/store-context.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -14
- package/src/bin/graphorin.ts +1387 -0
- package/src/commands/audit.ts +256 -0
- package/src/commands/auth.ts +238 -0
- package/src/commands/consolidator.ts +382 -0
- package/src/commands/doctor.ts +253 -0
- package/src/commands/guard.ts +144 -0
- package/src/commands/index.ts +223 -0
- package/src/commands/init.ts +194 -0
- package/src/commands/memory.ts +1052 -0
- package/src/commands/migrate-config.ts +77 -0
- package/src/commands/migrate-export.ts +117 -0
- package/src/commands/migrate.ts +83 -0
- package/src/commands/pricing.ts +244 -0
- package/src/commands/secrets.ts +309 -0
- package/src/commands/skills.ts +272 -0
- package/src/commands/start.ts +180 -0
- package/src/commands/storage.ts +659 -0
- package/src/commands/telemetry.ts +91 -0
- package/src/commands/token.ts +361 -0
- package/src/commands/tools-lint.ts +430 -0
- package/src/commands/traces.ts +188 -0
- package/src/commands/triggers.ts +237 -0
- package/src/index.ts +30 -0
- package/src/internal/exit.ts +62 -0
- package/src/internal/load-config.ts +107 -0
- package/src/internal/offline.ts +81 -0
- package/src/internal/output.ts +146 -0
- package/src/internal/prompts.ts +58 -0
- package/src/internal/store-context.ts +165 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `graphorin consolidator` - inspect and steer the background memory
|
|
3
|
+
* consolidation pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Surface (per Phase 15 § Consolidator):
|
|
6
|
+
*
|
|
7
|
+
* - `graphorin consolidator status` - current tier + pending CONFLICT-
|
|
8
|
+
* CHECK runs + DLQ size + recent run history.
|
|
9
|
+
* - `graphorin consolidator set-tier <free|cheap|standard|enterprise>`
|
|
10
|
+
* - persist the requested tier so the next process startup picks it
|
|
11
|
+
* up.
|
|
12
|
+
* - `graphorin consolidator stop` - pause the consolidator until the
|
|
13
|
+
* operator resumes it (next `graphorin start`).
|
|
14
|
+
*
|
|
15
|
+
* The consolidator runs **inside** the server process, so the CLI
|
|
16
|
+
* cannot direct-control a live in-memory instance from a different
|
|
17
|
+
* process. `status` reads the SQLite tables the running consolidator
|
|
18
|
+
* writes to (`consolidator_state`, `consolidator_runs`,
|
|
19
|
+
* `consolidator_failed_batches`, `conflict_check_pending`); `set-tier`
|
|
20
|
+
* and `stop` honestly report UNSUPPORTED until a daemon-side control
|
|
21
|
+
* channel exists (IP-4) - nothing polls an admin table.
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ConsolidatorTier } from '@graphorin/memory';
|
|
27
|
+
|
|
28
|
+
import { EXIT_CODES } from '../internal/exit.js';
|
|
29
|
+
import {
|
|
30
|
+
brand,
|
|
31
|
+
type CommonOutputOptions,
|
|
32
|
+
defaultPrintSink,
|
|
33
|
+
emitReport,
|
|
34
|
+
statusMarker,
|
|
35
|
+
} from '../internal/output.js';
|
|
36
|
+
import { openStoreContext } from '../internal/store-context.js';
|
|
37
|
+
|
|
38
|
+
const VALID_TIERS: ReadonlyArray<ConsolidatorTier> = Object.freeze([
|
|
39
|
+
'free',
|
|
40
|
+
'cheap',
|
|
41
|
+
'standard',
|
|
42
|
+
'full',
|
|
43
|
+
'custom',
|
|
44
|
+
] as const);
|
|
45
|
+
|
|
46
|
+
/** @stable */
|
|
47
|
+
export interface ConsolidatorCommonOptions extends CommonOutputOptions {
|
|
48
|
+
readonly config?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @stable */
|
|
52
|
+
export interface ConsolidatorStatusResult {
|
|
53
|
+
readonly tierHint?: ConsolidatorTier;
|
|
54
|
+
readonly recentRuns: number;
|
|
55
|
+
readonly successfulRuns: number;
|
|
56
|
+
readonly failedRuns: number;
|
|
57
|
+
readonly dlqSize: number;
|
|
58
|
+
readonly pendingConflicts: number;
|
|
59
|
+
readonly lastRunAt?: string;
|
|
60
|
+
readonly lastRunStatus?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @stable */
|
|
64
|
+
export async function runConsolidatorStatus(
|
|
65
|
+
options: ConsolidatorCommonOptions = {},
|
|
66
|
+
): Promise<ConsolidatorStatusResult> {
|
|
67
|
+
const ctx = await openStoreContext({
|
|
68
|
+
// W-068: read-only command - never auto-migrate a live database.
|
|
69
|
+
migrationPolicy: 'check',
|
|
70
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const conn = ctx.store.connection;
|
|
74
|
+
const recent = conn.get<{ n: number }>(
|
|
75
|
+
'SELECT COUNT(*) AS n FROM consolidator_runs WHERE started_at > ?',
|
|
76
|
+
[Date.now() - 24 * 60 * 60_000],
|
|
77
|
+
);
|
|
78
|
+
// MCON-5: the store writes 'running' | 'completed' | 'failed' |
|
|
79
|
+
// 'partial' | 'deferred' (consolidator-store.ts) - the old queries
|
|
80
|
+
// asked for 'success'/'error'/'pending' and always returned 0.
|
|
81
|
+
const success = conn.get<{ n: number }>(
|
|
82
|
+
"SELECT COUNT(*) AS n FROM consolidator_runs WHERE status = 'completed' AND started_at > ?",
|
|
83
|
+
[Date.now() - 24 * 60 * 60_000],
|
|
84
|
+
);
|
|
85
|
+
const failed = conn.get<{ n: number }>(
|
|
86
|
+
"SELECT COUNT(*) AS n FROM consolidator_runs WHERE status = 'failed' AND started_at > ?",
|
|
87
|
+
[Date.now() - 24 * 60 * 60_000],
|
|
88
|
+
);
|
|
89
|
+
const dlq = conn.get<{ n: number }>('SELECT COUNT(*) AS n FROM consolidator_failed_batches');
|
|
90
|
+
// Pending conflict work lives in conflict_check_pending, not in
|
|
91
|
+
// consolidator_runs ('pending' was never a run status).
|
|
92
|
+
const pending = conn.get<{ n: number }>(
|
|
93
|
+
'SELECT COUNT(*) AS n FROM conflict_check_pending WHERE resolved_at IS NULL',
|
|
94
|
+
);
|
|
95
|
+
const last = conn.get<{ started_at: number; status: string }>(
|
|
96
|
+
'SELECT started_at, status FROM consolidator_runs ORDER BY started_at DESC LIMIT 1',
|
|
97
|
+
);
|
|
98
|
+
const out: ConsolidatorStatusResult = Object.freeze({
|
|
99
|
+
recentRuns: recent?.n ?? 0,
|
|
100
|
+
successfulRuns: success?.n ?? 0,
|
|
101
|
+
failedRuns: failed?.n ?? 0,
|
|
102
|
+
dlqSize: dlq?.n ?? 0,
|
|
103
|
+
pendingConflicts: pending?.n ?? 0,
|
|
104
|
+
...(last !== undefined ? { lastRunAt: new Date(last.started_at).toISOString() } : {}),
|
|
105
|
+
...(last !== undefined ? { lastRunStatus: last.status } : {}),
|
|
106
|
+
});
|
|
107
|
+
emitReport(options, out, () => {
|
|
108
|
+
const print = options.print ?? defaultPrintSink;
|
|
109
|
+
const tier = out.tierHint ?? '<from running config>';
|
|
110
|
+
print(brand(`consolidator status (tier hint: ${tier})`));
|
|
111
|
+
print(
|
|
112
|
+
` ${statusMarker('ok')} runs in last 24h: ${out.recentRuns} (${out.successfulRuns} success, ${out.failedRuns} error)`,
|
|
113
|
+
);
|
|
114
|
+
print(` ${statusMarker(out.dlqSize > 0 ? 'warn' : 'ok')} dead-letter queue: ${out.dlqSize}`);
|
|
115
|
+
print(
|
|
116
|
+
` ${statusMarker(out.pendingConflicts > 0 ? 'warn' : 'ok')} pending CONFLICT-CHECK runs: ${out.pendingConflicts}`,
|
|
117
|
+
);
|
|
118
|
+
if (out.lastRunAt !== undefined) {
|
|
119
|
+
print(` last run: ${out.lastRunAt} (${out.lastRunStatus})`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return out;
|
|
123
|
+
} finally {
|
|
124
|
+
await ctx.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @stable */
|
|
129
|
+
export interface ConsolidatorSetTierOptions extends ConsolidatorCommonOptions {
|
|
130
|
+
readonly tier: ConsolidatorTier;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @stable */
|
|
134
|
+
export async function runConsolidatorSetTier(options: ConsolidatorSetTierOptions): Promise<{
|
|
135
|
+
readonly tier: ConsolidatorTier;
|
|
136
|
+
readonly applied: false;
|
|
137
|
+
readonly unsupported: true;
|
|
138
|
+
}> {
|
|
139
|
+
if (!isTier(options.tier)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`[graphorin/cli] invalid tier '${String(options.tier)}'. Allowed: ${VALID_TIERS.join(' | ')}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const ctx = await openStoreContext({
|
|
145
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
// IP-4: the old implementation upserted a `consolidator_admin` row
|
|
149
|
+
// NOTHING polls (neither the daemon nor process startup reads it -
|
|
150
|
+
// createConsolidator takes the tier from opts) and reported
|
|
151
|
+
// success. Honest answer until a daemon-side control channel
|
|
152
|
+
// exists: UNSUPPORTED with the working alternative.
|
|
153
|
+
const result = { tier: options.tier, applied: false, unsupported: true } as const;
|
|
154
|
+
emitReport(options, result, () => {
|
|
155
|
+
const print = options.print ?? defaultPrintSink;
|
|
156
|
+
print(
|
|
157
|
+
brand(
|
|
158
|
+
`runtime tier switching is not wired yet - set the tier in the server config (consolidator.tier: '${options.tier}') and restart, or use the server API when available.`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
process.exitCode = EXIT_CODES.UNSUPPORTED;
|
|
163
|
+
return result;
|
|
164
|
+
} finally {
|
|
165
|
+
await ctx.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @stable */
|
|
170
|
+
export interface ConsolidatorStopOptions extends ConsolidatorCommonOptions {}
|
|
171
|
+
|
|
172
|
+
/** @stable */
|
|
173
|
+
export async function runConsolidatorStop(
|
|
174
|
+
options: ConsolidatorStopOptions = {},
|
|
175
|
+
): Promise<{ readonly stopped: false; readonly unsupported: true }> {
|
|
176
|
+
const ctx = await openStoreContext({
|
|
177
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
// IP-4: the old implementation persisted a pause flag NOTHING
|
|
181
|
+
// honoured and told the operator the daemon would stop - the worst
|
|
182
|
+
// possible lie when someone is trying to stop runaway LLM spend.
|
|
183
|
+
const result = { stopped: false, unsupported: true } as const;
|
|
184
|
+
emitReport(options, result, () => {
|
|
185
|
+
const print = options.print ?? defaultPrintSink;
|
|
186
|
+
print(
|
|
187
|
+
brand(
|
|
188
|
+
'a CLI stop channel is not wired yet - to stop consolidation NOW, stop the server process (the consolidator runs inside it).',
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
print(
|
|
192
|
+
brand(
|
|
193
|
+
'To stay stopped across restarts set consolidator.tier to a zero-budget tier in the config.',
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
process.exitCode = EXIT_CODES.UNSUPPORTED;
|
|
198
|
+
return result;
|
|
199
|
+
} finally {
|
|
200
|
+
await ctx.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One dead-letter batch as surfaced by `consolidator dlq list`. @stable */
|
|
205
|
+
export interface ConsolidatorDlqEntry {
|
|
206
|
+
readonly id: string;
|
|
207
|
+
readonly userId: string | null;
|
|
208
|
+
readonly phase: string | null;
|
|
209
|
+
readonly errorKind: string | null;
|
|
210
|
+
readonly retryCount: number;
|
|
211
|
+
readonly failedAt: string;
|
|
212
|
+
/** `true` when retries are exhausted (`next_retry_at IS NULL`). */
|
|
213
|
+
readonly exhausted: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @stable */
|
|
217
|
+
export interface ConsolidatorDlqListOptions extends ConsolidatorCommonOptions {
|
|
218
|
+
/** Narrow to one user's batches (`scope_user_id`). */
|
|
219
|
+
readonly user?: string;
|
|
220
|
+
readonly limit?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* W-065: make the permanent `dead-letter queue: N` status warning
|
|
225
|
+
* actionable. Operator-level (DB-wide) view, like the `dlqSize`
|
|
226
|
+
* counter in `runConsolidatorStatus` - `listFailedBatches` on the
|
|
227
|
+
* store is scoped to one `SessionScope.userId`, which a CLI does not
|
|
228
|
+
* have; use `--user` to narrow.
|
|
229
|
+
*
|
|
230
|
+
* @stable
|
|
231
|
+
*/
|
|
232
|
+
export async function runConsolidatorDlqList(
|
|
233
|
+
options: ConsolidatorDlqListOptions = {},
|
|
234
|
+
): Promise<ReadonlyArray<ConsolidatorDlqEntry>> {
|
|
235
|
+
const ctx = await openStoreContext({
|
|
236
|
+
// W-068: read-only command - never auto-migrate a live database.
|
|
237
|
+
migrationPolicy: 'check',
|
|
238
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
const conn = ctx.store.connection;
|
|
242
|
+
const limit = options.limit ?? 100;
|
|
243
|
+
const rows =
|
|
244
|
+
options.user !== undefined
|
|
245
|
+
? conn.all<DlqRow>(
|
|
246
|
+
'SELECT id, scope_user_id, phase, error_kind, retry_count, failed_at, next_retry_at FROM consolidator_failed_batches WHERE scope_user_id = ? ORDER BY failed_at DESC LIMIT ?',
|
|
247
|
+
[options.user, limit],
|
|
248
|
+
)
|
|
249
|
+
: conn.all<DlqRow>(
|
|
250
|
+
'SELECT id, scope_user_id, phase, error_kind, retry_count, failed_at, next_retry_at FROM consolidator_failed_batches ORDER BY failed_at DESC LIMIT ?',
|
|
251
|
+
[limit],
|
|
252
|
+
);
|
|
253
|
+
const out: ReadonlyArray<ConsolidatorDlqEntry> = Object.freeze(
|
|
254
|
+
rows.map((row) =>
|
|
255
|
+
Object.freeze({
|
|
256
|
+
id: row.id,
|
|
257
|
+
userId: row.scope_user_id,
|
|
258
|
+
phase: row.phase,
|
|
259
|
+
errorKind: row.error_kind,
|
|
260
|
+
retryCount: row.retry_count,
|
|
261
|
+
failedAt: new Date(row.failed_at).toISOString(),
|
|
262
|
+
exhausted: row.next_retry_at === null,
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
emitReport(options, out, () => {
|
|
267
|
+
const print = options.print ?? defaultPrintSink;
|
|
268
|
+
if (out.length === 0) {
|
|
269
|
+
print(brand('dead-letter queue is empty.'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
print(brand(`dead-letter queue: ${out.length} batch(es)`));
|
|
273
|
+
for (const entry of out) {
|
|
274
|
+
const state = entry.exhausted ? 'EXHAUSTED' : 'awaiting retry';
|
|
275
|
+
print(
|
|
276
|
+
` ${entry.id} user=${entry.userId ?? '-'} phase=${entry.phase ?? '-'} kind=${entry.errorKind ?? '-'} retries=${entry.retryCount} failedAt=${entry.failedAt} [${state}]`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
return out;
|
|
281
|
+
} finally {
|
|
282
|
+
await ctx.close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @stable */
|
|
287
|
+
export interface ConsolidatorDlqClearOptions extends ConsolidatorCommonOptions {
|
|
288
|
+
/**
|
|
289
|
+
* Only clear EXHAUSTED batches (`next_retry_at IS NULL`). Default
|
|
290
|
+
* `true` - batches still awaiting retry belong to
|
|
291
|
+
* `claimReadyBatches` and are only removed with an explicit
|
|
292
|
+
* `--exhausted-only=false`.
|
|
293
|
+
*/
|
|
294
|
+
readonly exhaustedOnly?: boolean;
|
|
295
|
+
/** ISO date / epoch-ms: only batches that failed before this instant. */
|
|
296
|
+
readonly before?: string;
|
|
297
|
+
/** Clear one batch by id. */
|
|
298
|
+
readonly id?: string;
|
|
299
|
+
/** Narrow to one user's batches (`scope_user_id`). */
|
|
300
|
+
readonly user?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @stable */
|
|
304
|
+
export interface ConsolidatorDlqClearResult {
|
|
305
|
+
readonly removed: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* W-065: clear dead-letter batches. Defaults are conservative:
|
|
310
|
+
* exhausted-only, all users, no age bound. The batch payload (message
|
|
311
|
+
* ids) is lost on delete - that is the explicit point of the command.
|
|
312
|
+
*
|
|
313
|
+
* @stable
|
|
314
|
+
*/
|
|
315
|
+
export async function runConsolidatorDlqClear(
|
|
316
|
+
options: ConsolidatorDlqClearOptions = {},
|
|
317
|
+
): Promise<ConsolidatorDlqClearResult> {
|
|
318
|
+
const ctx = await openStoreContext({
|
|
319
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
const conn = ctx.store.connection;
|
|
323
|
+
const conditions: string[] = [];
|
|
324
|
+
const params: unknown[] = [];
|
|
325
|
+
if (options.id !== undefined) {
|
|
326
|
+
conditions.push('id = ?');
|
|
327
|
+
params.push(options.id);
|
|
328
|
+
}
|
|
329
|
+
if (options.exhaustedOnly !== false) {
|
|
330
|
+
conditions.push('next_retry_at IS NULL');
|
|
331
|
+
}
|
|
332
|
+
if (options.before !== undefined) {
|
|
333
|
+
const ms = Number.isFinite(Number(options.before))
|
|
334
|
+
? Number(options.before)
|
|
335
|
+
: Date.parse(options.before);
|
|
336
|
+
if (!Number.isFinite(ms)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`[graphorin/cli] --before '${options.before}' is not a valid ISO date or epoch-ms value.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
conditions.push('failed_at < ?');
|
|
342
|
+
params.push(ms);
|
|
343
|
+
}
|
|
344
|
+
if (options.user !== undefined) {
|
|
345
|
+
conditions.push('scope_user_id = ?');
|
|
346
|
+
params.push(options.user);
|
|
347
|
+
}
|
|
348
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
349
|
+
const result = conn.run(`DELETE FROM consolidator_failed_batches ${where}`, params);
|
|
350
|
+
const out: ConsolidatorDlqClearResult = Object.freeze({ removed: result.changes ?? 0 });
|
|
351
|
+
emitReport(options, out, () => {
|
|
352
|
+
const print = options.print ?? defaultPrintSink;
|
|
353
|
+
const mark = out.removed > 0 ? statusMarker('ok') : statusMarker('info');
|
|
354
|
+
print(brand(`${mark} cleared ${out.removed} dead-letter batch(es).`));
|
|
355
|
+
});
|
|
356
|
+
return out;
|
|
357
|
+
} finally {
|
|
358
|
+
await ctx.close();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface DlqRow {
|
|
363
|
+
readonly id: string;
|
|
364
|
+
readonly scope_user_id: string | null;
|
|
365
|
+
readonly phase: string | null;
|
|
366
|
+
readonly error_kind: string | null;
|
|
367
|
+
readonly retry_count: number;
|
|
368
|
+
readonly failed_at: number;
|
|
369
|
+
readonly next_retry_at: number | null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isTier(value: unknown): value is ConsolidatorTier {
|
|
373
|
+
return typeof value === 'string' && (VALID_TIERS as ReadonlyArray<string>).includes(value);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Exit code emitted when a tier value fails validation. Re-exported
|
|
378
|
+
* so the binary can surface the same code to downstream callers.
|
|
379
|
+
*
|
|
380
|
+
* @stable
|
|
381
|
+
*/
|
|
382
|
+
export const CONSOLIDATOR_INVALID_TIER_EXIT = EXIT_CODES.RECOVERABLE_FAILURE;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
/**
|
|
3
|
+
* `graphorin doctor` - host health check.
|
|
4
|
+
*
|
|
5
|
+
* Wraps the read-only library helpers in `@graphorin/security/hardening`
|
|
6
|
+
* (`checkPerms`, `checkSecrets`, `checkEncryption`, `checkSystemd`)
|
|
7
|
+
* with `--fix-perms` repair, `--all` aggregation, JSON output for CI
|
|
8
|
+
* pipelines, and exit-code semantics:
|
|
9
|
+
*
|
|
10
|
+
* - exit `0` - every check passed (`fail` count is `0`).
|
|
11
|
+
* - exit `1` - at least one check returned `'fail'`.
|
|
12
|
+
* - exit `2` - invocation could not even start (e.g. config missing
|
|
13
|
+
* when `--check-secrets` requires the bootstrapped store).
|
|
14
|
+
*
|
|
15
|
+
* The doctor never writes to disk unless `--fix-perms` is supplied.
|
|
16
|
+
* When repair is requested the CLI calls `ensureFileMode(...)` /
|
|
17
|
+
* `ensureDirMode(...)` from `@graphorin/security/hardening` and re-runs
|
|
18
|
+
* the perms check so the report reflects the post-fix state.
|
|
19
|
+
*
|
|
20
|
+
* Source-of-truth: process-hardening + `graphorin doctor` policy
|
|
21
|
+
* (DEC-135).
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import process from 'node:process';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
type CheckResult,
|
|
32
|
+
checkEncryption,
|
|
33
|
+
checkPerms,
|
|
34
|
+
checkSecrets,
|
|
35
|
+
checkSystemd,
|
|
36
|
+
ensureDirMode,
|
|
37
|
+
ensureFileMode,
|
|
38
|
+
} from '@graphorin/security';
|
|
39
|
+
|
|
40
|
+
import { EXIT_CODES } from '../internal/exit.js';
|
|
41
|
+
import {
|
|
42
|
+
brand,
|
|
43
|
+
type CommonOutputOptions,
|
|
44
|
+
defaultPrintSink,
|
|
45
|
+
emitReport,
|
|
46
|
+
statusMarker,
|
|
47
|
+
} from '../internal/output.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @stable
|
|
51
|
+
*/
|
|
52
|
+
export interface DoctorCommandOptions extends CommonOutputOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Override the directory the doctor checks. Defaults to
|
|
55
|
+
* `~/.graphorin/`. Tests inject a fresh tmp dir.
|
|
56
|
+
*/
|
|
57
|
+
readonly home?: string;
|
|
58
|
+
/** Run the file-perms repair. */
|
|
59
|
+
readonly fixPerms?: boolean;
|
|
60
|
+
/** Run the file-perms check. Implied by `--all`. */
|
|
61
|
+
readonly checkPerms?: boolean;
|
|
62
|
+
/** Run the secrets-store check. */
|
|
63
|
+
readonly checkSecrets?: boolean;
|
|
64
|
+
/** Run the audit-encryption check. */
|
|
65
|
+
readonly checkEncryption?: boolean;
|
|
66
|
+
/** Run the systemd check. Linux-only. */
|
|
67
|
+
readonly checkSystemd?: boolean;
|
|
68
|
+
/** Optional systemd unit identifier (default `graphorin.service`). */
|
|
69
|
+
readonly systemdUnit?: string;
|
|
70
|
+
/** Run every check. Equivalent to passing every `--check-*` flag. */
|
|
71
|
+
readonly all?: boolean;
|
|
72
|
+
/** Test seam - supply a custom systemd executor. */
|
|
73
|
+
readonly systemdRun?: (cmd: string) => Promise<string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @stable
|
|
78
|
+
*/
|
|
79
|
+
export interface DoctorReport {
|
|
80
|
+
readonly version: string;
|
|
81
|
+
readonly home: string;
|
|
82
|
+
readonly platform: NodeJS.Platform;
|
|
83
|
+
readonly checks: ReadonlyArray<CheckResult>;
|
|
84
|
+
readonly summary: {
|
|
85
|
+
readonly ok: number;
|
|
86
|
+
readonly warn: number;
|
|
87
|
+
readonly fail: number;
|
|
88
|
+
readonly skip: number;
|
|
89
|
+
};
|
|
90
|
+
readonly fixedPerms?: ReadonlyArray<string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Programmatic entry point. Returns the {@link DoctorReport} so tests
|
|
95
|
+
* and downstream automations consume the structured payload directly.
|
|
96
|
+
*
|
|
97
|
+
* @stable
|
|
98
|
+
*/
|
|
99
|
+
export async function runDoctor(options: DoctorCommandOptions = {}): Promise<DoctorReport> {
|
|
100
|
+
const home = options.home ?? join(homedir(), '.graphorin');
|
|
101
|
+
const expected = expectedFileModes(home);
|
|
102
|
+
|
|
103
|
+
const enable = expandFlags(options);
|
|
104
|
+
const checks: CheckResult[] = [];
|
|
105
|
+
const fixed: string[] = [];
|
|
106
|
+
|
|
107
|
+
if (enable.perms) {
|
|
108
|
+
if (options.fixPerms === true) {
|
|
109
|
+
const before = await checkPerms({ expected });
|
|
110
|
+
for (const result of before) {
|
|
111
|
+
if (result.status === 'fail' && expected[result.check] !== undefined) {
|
|
112
|
+
const mode = expected[result.check] as number;
|
|
113
|
+
try {
|
|
114
|
+
if (mode === 0o700) {
|
|
115
|
+
await ensureDirMode(result.check, mode);
|
|
116
|
+
} else {
|
|
117
|
+
await ensureFileMode(result.check, mode);
|
|
118
|
+
}
|
|
119
|
+
fixed.push(result.check);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
checks.push({
|
|
122
|
+
check: result.check,
|
|
123
|
+
status: 'fail',
|
|
124
|
+
message: `--fix-perms failed: ${(err as Error).message}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Re-run after the repair so the final report reflects the
|
|
130
|
+
// post-fix state.
|
|
131
|
+
const after = await checkPerms({ expected });
|
|
132
|
+
checks.push(...after);
|
|
133
|
+
} else {
|
|
134
|
+
const result = await checkPerms({ expected });
|
|
135
|
+
checks.push(...result);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (enable.secrets) {
|
|
140
|
+
checks.push(...checkSecrets());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (enable.encryption) {
|
|
144
|
+
checks.push(...checkEncryption());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (enable.systemd) {
|
|
148
|
+
const unit =
|
|
149
|
+
options.systemdUnit ?? (process.platform === 'linux' ? 'graphorin.service' : undefined);
|
|
150
|
+
const result = await checkSystemd({
|
|
151
|
+
...(unit !== undefined ? { unit } : {}),
|
|
152
|
+
...(options.systemdRun !== undefined ? { run: options.systemdRun } : {}),
|
|
153
|
+
});
|
|
154
|
+
checks.push(...result);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const summary: Record<'ok' | 'warn' | 'fail' | 'skip', number> = {
|
|
158
|
+
ok: 0,
|
|
159
|
+
warn: 0,
|
|
160
|
+
fail: 0,
|
|
161
|
+
skip: 0,
|
|
162
|
+
};
|
|
163
|
+
for (const c of checks) {
|
|
164
|
+
summary[c.status] += 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const report: DoctorReport = Object.freeze({
|
|
168
|
+
version: pkg.version,
|
|
169
|
+
home,
|
|
170
|
+
platform: process.platform,
|
|
171
|
+
checks: Object.freeze(checks),
|
|
172
|
+
summary: Object.freeze(summary),
|
|
173
|
+
...(fixed.length > 0 ? { fixedPerms: Object.freeze(fixed) } : {}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
emitReport(options, report, () => emitHumanReport(report, options));
|
|
177
|
+
|
|
178
|
+
if (summary.fail > 0) {
|
|
179
|
+
process.exitCode = EXIT_CODES.RECOVERABLE_FAILURE;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return report;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Default expected file modes per the project's process-hardening
|
|
187
|
+
* policy (DEC-135).
|
|
188
|
+
*
|
|
189
|
+
* @internal
|
|
190
|
+
*/
|
|
191
|
+
export function expectedFileModes(home: string): Readonly<Record<string, number>> {
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
[home]: 0o700,
|
|
194
|
+
[`${home}/config.json`]: 0o600,
|
|
195
|
+
[`${home}/data.db`]: 0o600,
|
|
196
|
+
[`${home}/audit.db`]: 0o600,
|
|
197
|
+
[`${home}/secrets.kse`]: 0o600,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface DoctorChecks {
|
|
202
|
+
readonly perms: boolean;
|
|
203
|
+
readonly secrets: boolean;
|
|
204
|
+
readonly encryption: boolean;
|
|
205
|
+
readonly systemd: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function expandFlags(options: DoctorCommandOptions): DoctorChecks {
|
|
209
|
+
if (options.all === true) {
|
|
210
|
+
return { perms: true, secrets: true, encryption: true, systemd: true };
|
|
211
|
+
}
|
|
212
|
+
const explicit =
|
|
213
|
+
options.checkPerms === true ||
|
|
214
|
+
options.checkSecrets === true ||
|
|
215
|
+
options.checkEncryption === true ||
|
|
216
|
+
options.checkSystemd === true ||
|
|
217
|
+
options.fixPerms === true;
|
|
218
|
+
if (!explicit) {
|
|
219
|
+
return { perms: true, secrets: false, encryption: false, systemd: false };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
perms: options.checkPerms === true || options.fixPerms === true,
|
|
223
|
+
secrets: options.checkSecrets === true,
|
|
224
|
+
encryption: options.checkEncryption === true,
|
|
225
|
+
systemd: options.checkSystemd === true,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function emitHumanReport(report: DoctorReport, options: DoctorCommandOptions): void {
|
|
230
|
+
const print = options.print ?? defaultPrintSink;
|
|
231
|
+
print(
|
|
232
|
+
brand(
|
|
233
|
+
`graphorin doctor v${report.version} (host: ${report.home}, platform: ${report.platform})`,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
if (report.fixedPerms !== undefined) {
|
|
237
|
+
print(brand(`--fix-perms repaired:`));
|
|
238
|
+
for (const path of report.fixedPerms) print(` - ${path}`);
|
|
239
|
+
}
|
|
240
|
+
if (report.checks.length === 0) {
|
|
241
|
+
print(brand('no checks were enabled. Pass --all or one of --check-*.'));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const c of report.checks) {
|
|
245
|
+
print(` ${statusMarker(c.status)} ${c.check}: ${c.message}`);
|
|
246
|
+
if (c.hint !== undefined) print(` -> ${c.hint}`);
|
|
247
|
+
}
|
|
248
|
+
print(
|
|
249
|
+
brand(
|
|
250
|
+
`summary: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip`,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
}
|