@kodrunhq/opencode-autopilot 1.15.2 → 1.16.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/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- package/src/health/checks.ts +29 -4
- package/src/index.ts +103 -11
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/types.ts +66 -0
- package/src/memory/capture.ts +221 -25
- package/src/memory/database.ts +74 -12
- package/src/memory/index.ts +17 -1
- package/src/memory/project-key.ts +6 -0
- package/src/memory/repository.ts +833 -42
- package/src/memory/retrieval.ts +83 -169
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/event-handlers.ts +28 -17
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +159 -0
- package/src/observability/forensic-schemas.ts +69 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +142 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build.ts +55 -97
- package/src/orchestrator/handlers/retrospective.ts +2 -1
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +29 -9
- package/src/orchestrator/orchestration-logger.ts +37 -23
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +29 -9
- package/src/tools/doctor.ts +26 -2
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +6 -5
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +97 -81
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/review.ts +39 -6
- package/src/tools/session-stats.ts +3 -2
- package/src/utils/paths.ts +20 -1
package/src/memory/repository.ts
CHANGED
|
@@ -1,20 +1,126 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lessonMemorySchema } from "../orchestrator/lesson-schemas";
|
|
4
|
+
import type { Lesson } from "../orchestrator/lesson-types";
|
|
2
5
|
import { OBSERVATION_TYPES } from "./constants";
|
|
3
6
|
import { getMemoryDb } from "./database";
|
|
4
|
-
import {
|
|
5
|
-
|
|
7
|
+
import {
|
|
8
|
+
observationSchema,
|
|
9
|
+
preferenceEvidenceSchema,
|
|
10
|
+
preferenceRecordSchema,
|
|
11
|
+
preferenceSchema,
|
|
12
|
+
projectSchema,
|
|
13
|
+
} from "./schemas";
|
|
14
|
+
import type {
|
|
15
|
+
Observation,
|
|
16
|
+
ObservationType,
|
|
17
|
+
Preference,
|
|
18
|
+
PreferenceEvidence,
|
|
19
|
+
PreferenceRecord,
|
|
20
|
+
Project,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
interface PreferenceRecordRow {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly value: string;
|
|
27
|
+
readonly scope: string;
|
|
28
|
+
readonly project_id: string | null;
|
|
29
|
+
readonly status: string;
|
|
30
|
+
readonly confidence: number;
|
|
31
|
+
readonly source_session: string | null;
|
|
32
|
+
readonly created_at: string;
|
|
33
|
+
readonly last_updated: string;
|
|
34
|
+
readonly evidence_count?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface PreferenceEvidenceRow {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly preference_id: string;
|
|
40
|
+
readonly session_id: string | null;
|
|
41
|
+
readonly run_id: string | null;
|
|
42
|
+
readonly statement: string;
|
|
43
|
+
readonly statement_hash: string;
|
|
44
|
+
readonly confidence: number;
|
|
45
|
+
readonly confirmed: number;
|
|
46
|
+
readonly created_at: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ProjectLessonRow {
|
|
50
|
+
readonly content: string;
|
|
51
|
+
readonly domain: Lesson["domain"];
|
|
52
|
+
readonly extracted_at: string;
|
|
53
|
+
readonly source_phase: Lesson["sourcePhase"];
|
|
54
|
+
readonly last_updated_at: string | null;
|
|
55
|
+
}
|
|
6
56
|
|
|
7
57
|
/** Resolve optional db parameter to singleton fallback. */
|
|
8
58
|
function resolveDb(db?: Database): Database {
|
|
9
59
|
return db ?? getMemoryDb();
|
|
10
60
|
}
|
|
11
61
|
|
|
62
|
+
function withWriteTransaction<T>(db: Database, callback: () => T): T {
|
|
63
|
+
const row = db.query("PRAGMA transaction_state").get() as { transaction_state?: string } | null;
|
|
64
|
+
if (row?.transaction_state === "TRANSACTION") {
|
|
65
|
+
return callback();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
db.run("BEGIN IMMEDIATE");
|
|
69
|
+
try {
|
|
70
|
+
const result = callback();
|
|
71
|
+
db.run("COMMIT");
|
|
72
|
+
return result;
|
|
73
|
+
} catch (error: unknown) {
|
|
74
|
+
try {
|
|
75
|
+
db.run("ROLLBACK");
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore rollback failures so the original error wins.
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildPlaceholders(count: number): string {
|
|
84
|
+
return Array.from({ length: count }, () => "?").join(", ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizePreferenceProjectId(
|
|
88
|
+
scope: PreferenceRecord["scope"],
|
|
89
|
+
projectId: string | null,
|
|
90
|
+
): string | null {
|
|
91
|
+
return scope === "project" ? projectId : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makePreferenceId(
|
|
95
|
+
key: string,
|
|
96
|
+
scope: PreferenceRecord["scope"],
|
|
97
|
+
projectId: string | null,
|
|
98
|
+
): string {
|
|
99
|
+
const normalizedProjectId = normalizePreferenceProjectId(scope, projectId) ?? "global";
|
|
100
|
+
return `pref-${createHash("sha1").update(`${scope}:${normalizedProjectId}:${key}`).digest("hex")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeEvidenceId(preferenceId: string, statementHash: string): string {
|
|
104
|
+
return `evidence-${createHash("sha1").update(`${preferenceId}:${statementHash}`).digest("hex")}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeStatementHash(statement: string): string {
|
|
108
|
+
return createHash("sha1").update(statement).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function tableExists(db: Database, tableName: string): boolean {
|
|
112
|
+
const row = db
|
|
113
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
114
|
+
.get(tableName) as { name?: string } | null;
|
|
115
|
+
return row?.name === tableName;
|
|
116
|
+
}
|
|
117
|
+
|
|
12
118
|
/** Validate observation type at runtime. */
|
|
13
119
|
function parseObservationType(value: unknown): ObservationType {
|
|
14
120
|
if (typeof value === "string" && (OBSERVATION_TYPES as readonly string[]).includes(value)) {
|
|
15
121
|
return value as ObservationType;
|
|
16
122
|
}
|
|
17
|
-
return "context";
|
|
123
|
+
return "context";
|
|
18
124
|
}
|
|
19
125
|
|
|
20
126
|
/** Map a snake_case DB row to camelCase Observation. */
|
|
@@ -39,21 +145,132 @@ function rowToProject(row: Record<string, unknown>): Project {
|
|
|
39
145
|
id: row.id as string,
|
|
40
146
|
path: row.path as string,
|
|
41
147
|
name: row.name as string,
|
|
148
|
+
firstSeenAt: ((row.first_seen_at as string) ?? (row.last_updated as string)) as string,
|
|
42
149
|
lastUpdated: row.last_updated as string,
|
|
43
150
|
};
|
|
44
151
|
}
|
|
45
152
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
153
|
+
function rowToPreferenceRecord(row: PreferenceRecordRow): PreferenceRecord {
|
|
154
|
+
return preferenceRecordSchema.parse({
|
|
155
|
+
id: row.id,
|
|
156
|
+
key: row.key,
|
|
157
|
+
value: row.value,
|
|
158
|
+
scope: row.scope,
|
|
159
|
+
projectId: row.project_id,
|
|
160
|
+
status: row.status,
|
|
161
|
+
confidence: row.confidence,
|
|
162
|
+
sourceSession: row.source_session,
|
|
163
|
+
createdAt: row.created_at,
|
|
164
|
+
lastUpdated: row.last_updated,
|
|
165
|
+
evidenceCount: row.evidence_count ?? 0,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function rowToPreferenceEvidence(row: PreferenceEvidenceRow): PreferenceEvidence {
|
|
170
|
+
return preferenceEvidenceSchema.parse({
|
|
171
|
+
id: row.id,
|
|
172
|
+
preferenceId: row.preference_id,
|
|
173
|
+
sessionId: row.session_id,
|
|
174
|
+
runId: row.run_id,
|
|
175
|
+
statement: row.statement,
|
|
176
|
+
statementHash: row.statement_hash,
|
|
177
|
+
confidence: row.confidence,
|
|
178
|
+
confirmed: row.confirmed === 1,
|
|
179
|
+
createdAt: row.created_at,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Map a normalized preference record to the compatibility shape. */
|
|
184
|
+
function recordToPreference(record: PreferenceRecord): Preference {
|
|
185
|
+
return preferenceSchema.parse({
|
|
186
|
+
id: record.id,
|
|
187
|
+
key: record.key,
|
|
188
|
+
value: record.value,
|
|
189
|
+
confidence: record.confidence,
|
|
190
|
+
scope: record.scope,
|
|
191
|
+
projectId: record.projectId,
|
|
192
|
+
status: record.status,
|
|
193
|
+
evidenceCount: record.evidenceCount,
|
|
194
|
+
sourceSession: record.sourceSession,
|
|
195
|
+
createdAt: record.createdAt,
|
|
196
|
+
lastUpdated: record.lastUpdated,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function syncCompatibilityPreference(record: PreferenceRecord, db: Database): void {
|
|
201
|
+
if (record.scope !== "global" || record.status !== "confirmed") {
|
|
202
|
+
db.run("DELETE FROM preferences WHERE id = ?", [record.id]);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
db.run(
|
|
207
|
+
`INSERT INTO preferences (id, key, value, confidence, source_session, created_at, last_updated)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
209
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
210
|
+
id = excluded.id,
|
|
211
|
+
key = excluded.key,
|
|
212
|
+
value = excluded.value,
|
|
213
|
+
confidence = excluded.confidence,
|
|
214
|
+
source_session = excluded.source_session,
|
|
215
|
+
created_at = excluded.created_at,
|
|
216
|
+
last_updated = excluded.last_updated`,
|
|
217
|
+
[
|
|
218
|
+
record.id,
|
|
219
|
+
record.key,
|
|
220
|
+
record.value,
|
|
221
|
+
record.confidence,
|
|
222
|
+
record.sourceSession,
|
|
223
|
+
record.createdAt,
|
|
224
|
+
record.lastUpdated,
|
|
225
|
+
],
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function listPreferenceRecordsSql(baseWhere = ""): string {
|
|
230
|
+
return `SELECT
|
|
231
|
+
pr.*,
|
|
232
|
+
COALESCE(evidence_counts.evidence_count, 0) AS evidence_count
|
|
233
|
+
FROM preference_records pr
|
|
234
|
+
LEFT JOIN (
|
|
235
|
+
SELECT preference_id, COUNT(*) AS evidence_count
|
|
236
|
+
FROM preference_evidence
|
|
237
|
+
GROUP BY preference_id
|
|
238
|
+
) AS evidence_counts ON evidence_counts.preference_id = pr.id
|
|
239
|
+
${baseWhere}
|
|
240
|
+
ORDER BY pr.last_updated DESC, pr.key ASC, pr.id ASC`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function listLegacyLessons(projectId: string, db: Database): readonly Lesson[] {
|
|
244
|
+
if (!tableExists(db, "project_lesson_memory")) {
|
|
245
|
+
return Object.freeze([]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const row = db
|
|
249
|
+
.query("SELECT state_json FROM project_lesson_memory WHERE project_id = ?")
|
|
250
|
+
.get(projectId) as { state_json?: string } | null;
|
|
251
|
+
if (row?.state_json === undefined) {
|
|
252
|
+
return Object.freeze([]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const parsed = lessonMemorySchema.parse(JSON.parse(row.state_json));
|
|
257
|
+
return Object.freeze(parsed.lessons);
|
|
258
|
+
} catch {
|
|
259
|
+
return Object.freeze([]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildLessonsFromRows(rows: readonly ProjectLessonRow[]): readonly Lesson[] {
|
|
264
|
+
return Object.freeze(
|
|
265
|
+
rows.map((row) =>
|
|
266
|
+
Object.freeze({
|
|
267
|
+
content: row.content,
|
|
268
|
+
domain: row.domain,
|
|
269
|
+
extractedAt: row.extracted_at,
|
|
270
|
+
sourcePhase: row.source_phase,
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
57
274
|
}
|
|
58
275
|
|
|
59
276
|
/**
|
|
@@ -97,8 +314,6 @@ export function searchObservations(
|
|
|
97
314
|
const d = resolveDb(db);
|
|
98
315
|
|
|
99
316
|
const projectFilter = projectId === null ? "AND o.project_id IS NULL" : "AND o.project_id = ?";
|
|
100
|
-
|
|
101
|
-
// Sanitize FTS5 query — wrap in double-quotes to prevent operator injection
|
|
102
317
|
const safeFtsQuery = `"${query.replace(/"/g, '""')}"`;
|
|
103
318
|
const params: Array<string | number> =
|
|
104
319
|
projectId === null ? [safeFtsQuery, limit] : [safeFtsQuery, projectId, limit];
|
|
@@ -127,12 +342,29 @@ export function searchObservations(
|
|
|
127
342
|
export function upsertProject(project: Project, db?: Database): void {
|
|
128
343
|
const validated = projectSchema.parse(project);
|
|
129
344
|
const d = resolveDb(db);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
345
|
+
const firstSeenAt = validated.firstSeenAt ?? validated.lastUpdated;
|
|
346
|
+
d.run(
|
|
347
|
+
`INSERT INTO projects (id, path, name, first_seen_at, last_updated)
|
|
348
|
+
VALUES (?, ?, ?, ?, ?)
|
|
349
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
350
|
+
path = excluded.path,
|
|
351
|
+
name = excluded.name,
|
|
352
|
+
first_seen_at = COALESCE(projects.first_seen_at, excluded.first_seen_at),
|
|
353
|
+
last_updated = excluded.last_updated`,
|
|
354
|
+
[validated.id, validated.path, validated.name, firstSeenAt, validated.lastUpdated],
|
|
355
|
+
);
|
|
356
|
+
d.run("UPDATE project_paths SET is_current = 0, last_updated = ? WHERE project_id = ?", [
|
|
134
357
|
validated.lastUpdated,
|
|
358
|
+
validated.id,
|
|
135
359
|
]);
|
|
360
|
+
d.run(
|
|
361
|
+
`INSERT INTO project_paths (project_id, path, first_seen_at, last_updated, is_current)
|
|
362
|
+
VALUES (?, ?, ?, ?, 1)
|
|
363
|
+
ON CONFLICT(project_id, path) DO UPDATE SET
|
|
364
|
+
last_updated = excluded.last_updated,
|
|
365
|
+
is_current = 1`,
|
|
366
|
+
[validated.id, validated.path, firstSeenAt, validated.lastUpdated],
|
|
367
|
+
);
|
|
136
368
|
}
|
|
137
369
|
|
|
138
370
|
/**
|
|
@@ -140,10 +372,21 @@ export function upsertProject(project: Project, db?: Database): void {
|
|
|
140
372
|
*/
|
|
141
373
|
export function getProjectByPath(path: string, db?: Database): Project | null {
|
|
142
374
|
const d = resolveDb(db);
|
|
143
|
-
const row = d
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
375
|
+
const row = d
|
|
376
|
+
.query(
|
|
377
|
+
`SELECT p.*
|
|
378
|
+
FROM projects p
|
|
379
|
+
WHERE p.path = ?
|
|
380
|
+
UNION ALL
|
|
381
|
+
SELECT p.*
|
|
382
|
+
FROM project_paths pp
|
|
383
|
+
JOIN projects p ON p.id = pp.project_id
|
|
384
|
+
WHERE pp.path = ?
|
|
385
|
+
AND NOT EXISTS (SELECT 1 FROM projects p2 WHERE p2.path = ?)
|
|
386
|
+
ORDER BY last_updated DESC
|
|
387
|
+
LIMIT 1`,
|
|
388
|
+
)
|
|
389
|
+
.get(path, path, path) as Record<string, unknown> | null;
|
|
147
390
|
return row ? rowToProject(row) : null;
|
|
148
391
|
}
|
|
149
392
|
|
|
@@ -167,34 +410,582 @@ export function getObservationsByProject(
|
|
|
167
410
|
return rows.map(rowToObservation);
|
|
168
411
|
}
|
|
169
412
|
|
|
413
|
+
export interface UpsertPreferenceRecordInput {
|
|
414
|
+
readonly id?: string;
|
|
415
|
+
readonly key: string;
|
|
416
|
+
readonly value: string;
|
|
417
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
418
|
+
readonly projectId?: string | null;
|
|
419
|
+
readonly status?: PreferenceRecord["status"];
|
|
420
|
+
readonly confidence?: number;
|
|
421
|
+
readonly sourceSession?: string | null;
|
|
422
|
+
readonly createdAt: string;
|
|
423
|
+
readonly lastUpdated: string;
|
|
424
|
+
readonly evidence?: readonly {
|
|
425
|
+
readonly sessionId?: string | null;
|
|
426
|
+
readonly runId?: string | null;
|
|
427
|
+
readonly statement: string;
|
|
428
|
+
readonly confidence?: number;
|
|
429
|
+
readonly confirmed?: boolean;
|
|
430
|
+
readonly createdAt?: string;
|
|
431
|
+
}[];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export interface ListPreferenceRecordOptions {
|
|
435
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
436
|
+
readonly projectId?: string | null;
|
|
437
|
+
readonly status?: PreferenceRecord["status"];
|
|
438
|
+
readonly onlyConfirmed?: boolean;
|
|
439
|
+
readonly limit?: number;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export type PreferenceUpsertInput = Omit<
|
|
443
|
+
Preference,
|
|
444
|
+
"scope" | "projectId" | "status" | "evidenceCount"
|
|
445
|
+
> &
|
|
446
|
+
Partial<Pick<Preference, "scope" | "projectId" | "status" | "evidenceCount">>;
|
|
447
|
+
|
|
448
|
+
export type PreferencePruneStatus = PreferenceRecord["status"] | "unconfirmed" | "any";
|
|
449
|
+
|
|
450
|
+
export interface PreferenceMutationResult {
|
|
451
|
+
readonly deletedPreferences: number;
|
|
452
|
+
readonly deletedEvidence: number;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface PreferencePruneOptions {
|
|
456
|
+
readonly olderThanDays: number;
|
|
457
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
458
|
+
readonly projectId?: string | null;
|
|
459
|
+
readonly status?: PreferencePruneStatus;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export interface PreferenceEvidencePruneOptions {
|
|
463
|
+
readonly olderThanDays: number;
|
|
464
|
+
readonly keepLatestPerPreference?: number;
|
|
465
|
+
readonly scope?: PreferenceRecord["scope"];
|
|
466
|
+
readonly projectId?: string | null;
|
|
467
|
+
readonly status?: PreferencePruneStatus;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isPreferenceStatusMatch(record: PreferenceRecord, status: PreferencePruneStatus): boolean {
|
|
471
|
+
if (status === "any") {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
if (status === "unconfirmed") {
|
|
475
|
+
return record.status !== "confirmed";
|
|
476
|
+
}
|
|
477
|
+
return record.status === status;
|
|
478
|
+
}
|
|
479
|
+
|
|
170
480
|
/**
|
|
171
|
-
* Create or replace a preference
|
|
481
|
+
* Create or replace a structured preference record and its supporting evidence.
|
|
172
482
|
*/
|
|
173
|
-
export function
|
|
174
|
-
|
|
483
|
+
export function upsertPreferenceRecord(
|
|
484
|
+
input: UpsertPreferenceRecordInput,
|
|
485
|
+
db?: Database,
|
|
486
|
+
): PreferenceRecord {
|
|
175
487
|
const d = resolveDb(db);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
488
|
+
const scope = input.scope ?? "global";
|
|
489
|
+
const normalizedProjectId = normalizePreferenceProjectId(scope, input.projectId ?? null);
|
|
490
|
+
if (scope === "project" && normalizedProjectId === null) {
|
|
491
|
+
throw new Error("project-scoped preferences require a projectId");
|
|
492
|
+
}
|
|
493
|
+
const validated = preferenceRecordSchema.parse({
|
|
494
|
+
id: input.id ?? makePreferenceId(input.key, scope, normalizedProjectId),
|
|
495
|
+
key: input.key,
|
|
496
|
+
value: input.value,
|
|
497
|
+
scope,
|
|
498
|
+
projectId: normalizedProjectId,
|
|
499
|
+
status: input.status ?? "confirmed",
|
|
500
|
+
confidence: input.confidence ?? 0.5,
|
|
501
|
+
sourceSession: input.sourceSession ?? null,
|
|
502
|
+
createdAt: input.createdAt,
|
|
503
|
+
lastUpdated: input.lastUpdated,
|
|
504
|
+
evidenceCount: input.evidence?.length ?? 0,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
d.run("BEGIN IMMEDIATE");
|
|
508
|
+
try {
|
|
509
|
+
d.run(
|
|
510
|
+
`INSERT INTO preference_records (
|
|
511
|
+
id,
|
|
512
|
+
key,
|
|
513
|
+
value,
|
|
514
|
+
scope,
|
|
515
|
+
project_id,
|
|
516
|
+
status,
|
|
517
|
+
confidence,
|
|
518
|
+
source_session,
|
|
519
|
+
created_at,
|
|
520
|
+
last_updated
|
|
521
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
522
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
523
|
+
key = excluded.key,
|
|
524
|
+
value = excluded.value,
|
|
525
|
+
scope = excluded.scope,
|
|
526
|
+
project_id = excluded.project_id,
|
|
527
|
+
status = excluded.status,
|
|
528
|
+
confidence = excluded.confidence,
|
|
529
|
+
source_session = excluded.source_session,
|
|
530
|
+
created_at = excluded.created_at,
|
|
531
|
+
last_updated = excluded.last_updated`,
|
|
532
|
+
[
|
|
533
|
+
validated.id,
|
|
534
|
+
validated.key,
|
|
535
|
+
validated.value,
|
|
536
|
+
validated.scope,
|
|
537
|
+
validated.projectId,
|
|
538
|
+
validated.status,
|
|
539
|
+
validated.confidence,
|
|
540
|
+
validated.sourceSession,
|
|
541
|
+
validated.createdAt,
|
|
542
|
+
validated.lastUpdated,
|
|
543
|
+
],
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
for (const evidence of input.evidence ?? []) {
|
|
547
|
+
const statementHash = makeStatementHash(evidence.statement);
|
|
548
|
+
const validatedEvidence = preferenceEvidenceSchema.parse({
|
|
549
|
+
id: makeEvidenceId(validated.id, statementHash),
|
|
550
|
+
preferenceId: validated.id,
|
|
551
|
+
sessionId: evidence.sessionId ?? validated.sourceSession,
|
|
552
|
+
runId: evidence.runId ?? null,
|
|
553
|
+
statement: evidence.statement,
|
|
554
|
+
statementHash,
|
|
555
|
+
confidence: evidence.confidence ?? validated.confidence,
|
|
556
|
+
confirmed: evidence.confirmed ?? validated.status === "confirmed",
|
|
557
|
+
createdAt: evidence.createdAt ?? validated.lastUpdated,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
d.run(
|
|
561
|
+
`INSERT INTO preference_evidence (
|
|
562
|
+
id,
|
|
563
|
+
preference_id,
|
|
564
|
+
session_id,
|
|
565
|
+
run_id,
|
|
566
|
+
statement,
|
|
567
|
+
statement_hash,
|
|
568
|
+
confidence,
|
|
569
|
+
confirmed,
|
|
570
|
+
created_at
|
|
571
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
572
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
573
|
+
session_id = excluded.session_id,
|
|
574
|
+
run_id = excluded.run_id,
|
|
575
|
+
statement = excluded.statement,
|
|
576
|
+
confidence = excluded.confidence,
|
|
577
|
+
confirmed = excluded.confirmed,
|
|
578
|
+
created_at = excluded.created_at`,
|
|
579
|
+
[
|
|
580
|
+
validatedEvidence.id,
|
|
581
|
+
validatedEvidence.preferenceId,
|
|
582
|
+
validatedEvidence.sessionId,
|
|
583
|
+
validatedEvidence.runId,
|
|
584
|
+
validatedEvidence.statement,
|
|
585
|
+
validatedEvidence.statementHash,
|
|
586
|
+
validatedEvidence.confidence,
|
|
587
|
+
validatedEvidence.confirmed ? 1 : 0,
|
|
588
|
+
validatedEvidence.createdAt,
|
|
589
|
+
],
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
syncCompatibilityPreference(validated, d);
|
|
594
|
+
d.run("COMMIT");
|
|
595
|
+
} catch (error: unknown) {
|
|
596
|
+
try {
|
|
597
|
+
d.run("ROLLBACK");
|
|
598
|
+
} catch {
|
|
599
|
+
// Ignore rollback failures so the original error wins.
|
|
600
|
+
}
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return getPreferenceRecordById(validated.id, d) ?? validated;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function getPreferenceRecordById(id: string, db?: Database): PreferenceRecord | null {
|
|
608
|
+
const d = resolveDb(db);
|
|
609
|
+
if (!tableExists(d, "preference_records")) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const row = d
|
|
614
|
+
.query(`${listPreferenceRecordsSql("WHERE pr.id = ?")} LIMIT 1`)
|
|
615
|
+
.get(id) as PreferenceRecordRow | null;
|
|
616
|
+
return row ? rowToPreferenceRecord(row) : null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function listPreferenceRecords(
|
|
620
|
+
options: ListPreferenceRecordOptions = {},
|
|
621
|
+
db?: Database,
|
|
622
|
+
): readonly PreferenceRecord[] {
|
|
623
|
+
const d = resolveDb(db);
|
|
624
|
+
if (!tableExists(d, "preference_records")) {
|
|
625
|
+
return getAllPreferences(d).map((preference) =>
|
|
626
|
+
preferenceRecordSchema.parse({
|
|
627
|
+
...preference,
|
|
628
|
+
scope: preference.scope,
|
|
629
|
+
projectId: preference.projectId,
|
|
630
|
+
status: preference.status,
|
|
631
|
+
evidenceCount: preference.evidenceCount,
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const conditions: string[] = [];
|
|
637
|
+
const params: Array<string | number> = [];
|
|
638
|
+
|
|
639
|
+
if (options.scope) {
|
|
640
|
+
conditions.push("pr.scope = ?");
|
|
641
|
+
params.push(options.scope);
|
|
642
|
+
}
|
|
643
|
+
if (Object.hasOwn(options, "projectId")) {
|
|
644
|
+
if (options.projectId === null) {
|
|
645
|
+
conditions.push("pr.project_id IS NULL");
|
|
646
|
+
} else if (typeof options.projectId === "string") {
|
|
647
|
+
conditions.push("pr.project_id = ?");
|
|
648
|
+
params.push(options.projectId);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (options.onlyConfirmed === true) {
|
|
652
|
+
conditions.push("pr.status = 'confirmed'");
|
|
653
|
+
} else if (options.status) {
|
|
654
|
+
conditions.push("pr.status = ?");
|
|
655
|
+
params.push(options.status);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let sql = listPreferenceRecordsSql(
|
|
659
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
|
|
660
|
+
);
|
|
661
|
+
if (typeof options.limit === "number") {
|
|
662
|
+
sql = `${sql} LIMIT ?`;
|
|
663
|
+
params.push(Math.max(1, options.limit));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const rows = d.query(sql).all(...params) as PreferenceRecordRow[];
|
|
667
|
+
return Object.freeze(rows.map(rowToPreferenceRecord));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function listPreferenceEvidence(
|
|
671
|
+
preferenceId: string,
|
|
672
|
+
db?: Database,
|
|
673
|
+
): readonly PreferenceEvidence[] {
|
|
674
|
+
const d = resolveDb(db);
|
|
675
|
+
if (!tableExists(d, "preference_evidence")) {
|
|
676
|
+
return Object.freeze([]);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const rows = d
|
|
680
|
+
.query(
|
|
681
|
+
`SELECT *
|
|
682
|
+
FROM preference_evidence
|
|
683
|
+
WHERE preference_id = ?
|
|
684
|
+
ORDER BY created_at DESC, id DESC`,
|
|
685
|
+
)
|
|
686
|
+
.all(preferenceId) as PreferenceEvidenceRow[];
|
|
687
|
+
return Object.freeze(rows.map(rowToPreferenceEvidence));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function selectPrunablePreferenceRecords(
|
|
691
|
+
options: PreferencePruneOptions,
|
|
692
|
+
db: Database,
|
|
693
|
+
): readonly PreferenceRecord[] {
|
|
694
|
+
const cutoff = new Date(
|
|
695
|
+
Date.now() - Math.max(1, options.olderThanDays) * 24 * 60 * 60 * 1000,
|
|
696
|
+
).toISOString();
|
|
697
|
+
const status = options.status ?? "unconfirmed";
|
|
698
|
+
const records = listPreferenceRecords(
|
|
699
|
+
{
|
|
700
|
+
scope: options.scope,
|
|
701
|
+
projectId: options.projectId,
|
|
702
|
+
status:
|
|
703
|
+
status === "candidate" || status === "confirmed" || status === "rejected"
|
|
704
|
+
? status
|
|
705
|
+
: undefined,
|
|
706
|
+
},
|
|
707
|
+
db,
|
|
708
|
+
);
|
|
709
|
+
return Object.freeze(
|
|
710
|
+
records.filter(
|
|
711
|
+
(record) => isPreferenceStatusMatch(record, status) && record.lastUpdated < cutoff,
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function deletePreferenceRecordsByIds(
|
|
717
|
+
ids: readonly string[],
|
|
718
|
+
db: Database,
|
|
719
|
+
): PreferenceMutationResult {
|
|
720
|
+
if (ids.length === 0) {
|
|
721
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!tableExists(db, "preference_records")) {
|
|
725
|
+
const placeholders = buildPlaceholders(ids.length);
|
|
726
|
+
const deletedPreferences =
|
|
727
|
+
(
|
|
728
|
+
db
|
|
729
|
+
.query(`SELECT COUNT(*) AS cnt FROM preferences WHERE id IN (${placeholders})`)
|
|
730
|
+
.get(...ids) as { cnt?: number } | null
|
|
731
|
+
)?.cnt ?? 0;
|
|
732
|
+
if (deletedPreferences > 0) {
|
|
733
|
+
db.run(`DELETE FROM preferences WHERE id IN (${placeholders})`, [...ids]);
|
|
734
|
+
}
|
|
735
|
+
return Object.freeze({ deletedPreferences, deletedEvidence: 0 });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const placeholders = buildPlaceholders(ids.length);
|
|
739
|
+
const records = db
|
|
740
|
+
.query(`SELECT * FROM preference_records WHERE id IN (${placeholders})`)
|
|
741
|
+
.all(...ids) as PreferenceRecordRow[];
|
|
742
|
+
if (records.length === 0) {
|
|
743
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const deletedEvidence = tableExists(db, "preference_evidence")
|
|
747
|
+
? ((
|
|
748
|
+
db
|
|
749
|
+
.query(
|
|
750
|
+
`SELECT COUNT(*) AS cnt FROM preference_evidence WHERE preference_id IN (${placeholders})`,
|
|
751
|
+
)
|
|
752
|
+
.get(...ids) as { cnt?: number } | null
|
|
753
|
+
)?.cnt ?? 0)
|
|
754
|
+
: 0;
|
|
755
|
+
|
|
756
|
+
withWriteTransaction(db, () => {
|
|
757
|
+
for (const record of records) {
|
|
758
|
+
if (record.scope === "global") {
|
|
759
|
+
db.run("DELETE FROM preferences WHERE id = ? OR key = ?", [record.id, record.key]);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
db.run(`DELETE FROM preference_records WHERE id IN (${placeholders})`, [...ids]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
return Object.freeze({
|
|
766
|
+
deletedPreferences: records.length,
|
|
767
|
+
deletedEvidence,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function deletePreferenceRecord(id: string, db?: Database): PreferenceMutationResult {
|
|
772
|
+
return deletePreferenceRecordsByIds([id], resolveDb(db));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export function deletePreferencesByKey(
|
|
776
|
+
key: string,
|
|
777
|
+
options: { readonly scope?: PreferenceRecord["scope"]; readonly projectId?: string | null } = {},
|
|
778
|
+
db?: Database,
|
|
779
|
+
): PreferenceMutationResult {
|
|
780
|
+
const d = resolveDb(db);
|
|
781
|
+
const records = listPreferenceRecords(
|
|
782
|
+
{
|
|
783
|
+
scope: options.scope,
|
|
784
|
+
projectId: options.projectId,
|
|
785
|
+
},
|
|
786
|
+
d,
|
|
787
|
+
).filter((record) => record.key === key);
|
|
788
|
+
return deletePreferenceRecordsByIds(
|
|
789
|
+
records.map((record) => record.id),
|
|
790
|
+
d,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function prunePreferences(
|
|
795
|
+
options: PreferencePruneOptions,
|
|
796
|
+
db?: Database,
|
|
797
|
+
): PreferenceMutationResult {
|
|
798
|
+
const d = resolveDb(db);
|
|
799
|
+
const records = selectPrunablePreferenceRecords(options, d);
|
|
800
|
+
return deletePreferenceRecordsByIds(
|
|
801
|
+
records.map((record) => record.id),
|
|
802
|
+
d,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function prunePreferenceEvidence(
|
|
807
|
+
options: PreferenceEvidencePruneOptions,
|
|
808
|
+
db?: Database,
|
|
809
|
+
): PreferenceMutationResult {
|
|
810
|
+
const d = resolveDb(db);
|
|
811
|
+
if (!tableExists(d, "preference_evidence") || !tableExists(d, "preference_records")) {
|
|
812
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence: 0 });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const cutoff = new Date(
|
|
816
|
+
Date.now() - Math.max(1, options.olderThanDays) * 24 * 60 * 60 * 1000,
|
|
817
|
+
).toISOString();
|
|
818
|
+
const keepLatestPerPreference = Math.max(0, options.keepLatestPerPreference ?? 1);
|
|
819
|
+
const status = options.status ?? "any";
|
|
820
|
+
const records = listPreferenceRecords(
|
|
821
|
+
{
|
|
822
|
+
scope: options.scope,
|
|
823
|
+
projectId: options.projectId,
|
|
824
|
+
status:
|
|
825
|
+
status === "candidate" || status === "confirmed" || status === "rejected"
|
|
826
|
+
? status
|
|
827
|
+
: undefined,
|
|
828
|
+
},
|
|
829
|
+
d,
|
|
830
|
+
).filter((record) => isPreferenceStatusMatch(record, status));
|
|
831
|
+
|
|
832
|
+
let deletedEvidence = 0;
|
|
833
|
+
withWriteTransaction(d, () => {
|
|
834
|
+
for (const record of records) {
|
|
835
|
+
const evidence = listPreferenceEvidence(record.id, d);
|
|
836
|
+
const removable = evidence
|
|
837
|
+
.slice(keepLatestPerPreference)
|
|
838
|
+
.filter((entry) => entry.createdAt < cutoff);
|
|
839
|
+
if (removable.length === 0) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const placeholders = buildPlaceholders(removable.length);
|
|
844
|
+
d.run(
|
|
845
|
+
`DELETE FROM preference_evidence WHERE id IN (${placeholders})`,
|
|
846
|
+
removable.map((entry) => entry.id),
|
|
847
|
+
);
|
|
848
|
+
deletedEvidence += removable.length;
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
return Object.freeze({ deletedPreferences: 0, deletedEvidence });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function listRelevantLessons(
|
|
856
|
+
projectId: string,
|
|
857
|
+
limit = 5,
|
|
858
|
+
db?: Database,
|
|
859
|
+
): readonly Lesson[] {
|
|
860
|
+
const d = resolveDb(db);
|
|
861
|
+
if (tableExists(d, "project_lessons")) {
|
|
862
|
+
const rows = d
|
|
863
|
+
.query(
|
|
864
|
+
`SELECT content, domain, extracted_at, source_phase, last_updated_at
|
|
865
|
+
FROM project_lessons
|
|
866
|
+
WHERE project_id = ?
|
|
867
|
+
ORDER BY extracted_at DESC, lesson_id DESC
|
|
868
|
+
LIMIT ?`,
|
|
869
|
+
)
|
|
870
|
+
.all(projectId, limit) as ProjectLessonRow[];
|
|
871
|
+
if (rows.length > 0) {
|
|
872
|
+
return buildLessonsFromRows(rows);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return listLegacyLessons(projectId, d).slice(0, limit);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Create or replace a preference by its id.
|
|
881
|
+
* Compatibility wrapper that stores a confirmed global preference record.
|
|
882
|
+
*/
|
|
883
|
+
export function upsertPreference(pref: PreferenceUpsertInput, db?: Database): void {
|
|
884
|
+
const validated = preferenceSchema.parse({
|
|
885
|
+
scope: "global",
|
|
886
|
+
projectId: null,
|
|
887
|
+
status: "confirmed",
|
|
888
|
+
evidenceCount: 0,
|
|
889
|
+
...pref,
|
|
890
|
+
});
|
|
891
|
+
upsertPreferenceRecord(
|
|
892
|
+
{
|
|
893
|
+
id: validated.id,
|
|
894
|
+
key: validated.key,
|
|
895
|
+
value: validated.value,
|
|
896
|
+
scope: validated.scope,
|
|
897
|
+
projectId: validated.projectId,
|
|
898
|
+
status: validated.status,
|
|
899
|
+
confidence: validated.confidence,
|
|
900
|
+
sourceSession: validated.sourceSession,
|
|
901
|
+
createdAt: validated.createdAt,
|
|
902
|
+
lastUpdated: validated.lastUpdated,
|
|
903
|
+
evidence:
|
|
904
|
+
validated.sourceSession === null
|
|
905
|
+
? []
|
|
906
|
+
: [
|
|
907
|
+
{
|
|
908
|
+
sessionId: validated.sourceSession,
|
|
909
|
+
statement: `${validated.key}: ${validated.value}`,
|
|
910
|
+
confidence: validated.confidence,
|
|
911
|
+
confirmed: validated.status === "confirmed",
|
|
912
|
+
createdAt: validated.lastUpdated,
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
},
|
|
916
|
+
db,
|
|
188
917
|
);
|
|
189
918
|
}
|
|
190
919
|
|
|
191
920
|
/**
|
|
192
|
-
* Get all preferences.
|
|
921
|
+
* Get all compatibility preferences.
|
|
193
922
|
*/
|
|
194
923
|
export function getAllPreferences(db?: Database): readonly Preference[] {
|
|
195
924
|
const d = resolveDb(db);
|
|
196
|
-
|
|
197
|
-
|
|
925
|
+
if (!tableExists(d, "preference_records")) {
|
|
926
|
+
const rows = d
|
|
927
|
+
.query("SELECT * FROM preferences ORDER BY last_updated DESC, key ASC")
|
|
928
|
+
.all() as Array<Record<string, unknown>>;
|
|
929
|
+
return rows.map((row) =>
|
|
930
|
+
preferenceSchema.parse({
|
|
931
|
+
id: row.id as string,
|
|
932
|
+
key: row.key as string,
|
|
933
|
+
value: row.value as string,
|
|
934
|
+
confidence: row.confidence as number,
|
|
935
|
+
scope: "global",
|
|
936
|
+
projectId: null,
|
|
937
|
+
status: "confirmed",
|
|
938
|
+
evidenceCount: 0,
|
|
939
|
+
sourceSession: (row.source_session as string) ?? null,
|
|
940
|
+
createdAt: row.created_at as string,
|
|
941
|
+
lastUpdated: row.last_updated as string,
|
|
942
|
+
}),
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const projected: Preference[] = [];
|
|
947
|
+
const seen = new Set<string>();
|
|
948
|
+
for (const record of listPreferenceRecords({ onlyConfirmed: true }, d)) {
|
|
949
|
+
const uniquenessKey = `${record.scope}:${record.projectId ?? "global"}:${record.key}`;
|
|
950
|
+
if (seen.has(uniquenessKey)) {
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
seen.add(uniquenessKey);
|
|
954
|
+
projected.push(recordToPreference(record));
|
|
955
|
+
}
|
|
956
|
+
return Object.freeze(projected);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function getConfirmedPreferencesForProject(
|
|
960
|
+
projectId: string,
|
|
961
|
+
db?: Database,
|
|
962
|
+
): readonly Preference[] {
|
|
963
|
+
const d = resolveDb(db);
|
|
964
|
+
const globalPrefs = listPreferenceRecords({ scope: "global", onlyConfirmed: true, limit: 5 }, d);
|
|
965
|
+
const projectPrefs = listPreferenceRecords(
|
|
966
|
+
{ scope: "project", projectId, onlyConfirmed: true, limit: 5 },
|
|
967
|
+
d,
|
|
968
|
+
);
|
|
969
|
+
return Object.freeze([...projectPrefs, ...globalPrefs].map(recordToPreference));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export function getRecentFailureObservations(
|
|
973
|
+
projectId: string,
|
|
974
|
+
limit = 5,
|
|
975
|
+
db?: Database,
|
|
976
|
+
): readonly Observation[] {
|
|
977
|
+
const d = resolveDb(db);
|
|
978
|
+
const rows = d
|
|
979
|
+
.query(
|
|
980
|
+
`SELECT *
|
|
981
|
+
FROM observations
|
|
982
|
+
WHERE project_id = ?
|
|
983
|
+
AND type = 'error'
|
|
984
|
+
ORDER BY created_at DESC, id DESC
|
|
985
|
+
LIMIT ?`,
|
|
986
|
+
)
|
|
987
|
+
.all(projectId, limit) as Array<Record<string, unknown>>;
|
|
988
|
+
return Object.freeze(rows.map(rowToObservation));
|
|
198
989
|
}
|
|
199
990
|
|
|
200
991
|
/**
|