@dzhechkov/harness-core 0.8.28 → 0.8.30

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.
Files changed (62) hide show
  1. package/.dz-manifest.json +106 -46
  2. package/README.md +17 -2
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +10 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/cmd-usage.d.ts.map +1 -1
  7. package/dist/cmd-usage.js +22 -10
  8. package/dist/cmd-usage.js.map +1 -1
  9. package/dist/core-boundary.d.ts +1 -1
  10. package/dist/core-boundary.d.ts.map +1 -1
  11. package/dist/core-boundary.js +8 -1
  12. package/dist/core-boundary.js.map +1 -1
  13. package/dist/guard.d.ts +19 -0
  14. package/dist/guard.d.ts.map +1 -1
  15. package/dist/guard.js +110 -0
  16. package/dist/guard.js.map +1 -1
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/publish.d.ts +30 -0
  22. package/dist/publish.d.ts.map +1 -1
  23. package/dist/publish.js +82 -0
  24. package/dist/publish.js.map +1 -1
  25. package/dist/release-line.d.ts +10 -0
  26. package/dist/release-line.d.ts.map +1 -0
  27. package/dist/release-line.js +21 -0
  28. package/dist/release-line.js.map +1 -0
  29. package/dist/repo-boundary.d.ts +13 -0
  30. package/dist/repo-boundary.d.ts.map +1 -0
  31. package/dist/repo-boundary.js +16 -0
  32. package/dist/repo-boundary.js.map +1 -0
  33. package/dist/repository-origin.d.ts +2 -0
  34. package/dist/repository-origin.d.ts.map +1 -0
  35. package/dist/repository-origin.js +2 -0
  36. package/dist/repository-origin.js.map +1 -0
  37. package/dist/statusline.d.ts +2 -0
  38. package/dist/statusline.d.ts.map +1 -1
  39. package/dist/statusline.js +3 -0
  40. package/dist/statusline.js.map +1 -1
  41. package/dist/store-counts.d.ts +11 -3
  42. package/dist/store-counts.d.ts.map +1 -1
  43. package/dist/store-counts.js +120 -63
  44. package/dist/store-counts.js.map +1 -1
  45. package/dist/store-guard.d.ts +2 -2
  46. package/dist/store-guard.d.ts.map +1 -1
  47. package/dist/store-guard.js +6 -0
  48. package/dist/store-guard.js.map +1 -1
  49. package/package.json +8 -7
  50. package/sbom.json +195 -45
  51. package/src/amendment-trace.ts +10 -1
  52. package/src/cmd-usage.ts +13 -6
  53. package/src/core-boundary.ts +7 -1
  54. package/src/guard.ts +137 -1
  55. package/src/index.ts +6 -1
  56. package/src/publish.ts +107 -0
  57. package/src/release-line.ts +31 -0
  58. package/src/repo-boundary.ts +25 -0
  59. package/src/repository-origin.ts +1 -0
  60. package/src/statusline.ts +4 -0
  61. package/src/store-counts.ts +146 -60
  62. package/src/store-guard.ts +8 -2
@@ -9,8 +9,42 @@ interface ReadonlyCountDb {
9
9
  close: () => void;
10
10
  }
11
11
 
12
+ export type StoreRowCount = number | 'unreadable' | 'busy';
13
+
14
+ export interface StoreCountOptions {
15
+ readonly busyTimeoutMs?: number;
16
+ readonly attempts?: number;
17
+ }
18
+
19
+ type DatabaseRequire = (id: string) => unknown;
20
+
21
+ function isBusy(error: unknown): boolean {
22
+ return typeof error === 'object' && error !== null
23
+ && (error as { code?: unknown }).code === 'SQLITE_BUSY';
24
+ }
25
+
26
+ function isInitializing(error: unknown): boolean {
27
+ return typeof error === 'object' && error !== null
28
+ && (error as { code?: unknown }).code === 'SQLITE_ERROR'
29
+ && typeof (error as { message?: unknown }).message === 'string'
30
+ && (error as { message: string }).message.includes('no such table');
31
+ }
32
+
33
+ const INITIALIZATION_RETRY_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
34
+
35
+ function waitForInitializationRetry(busyTimeoutMs: number): void {
36
+ Atomics.wait(INITIALIZATION_RETRY_WAIT, 0, 0, busyTimeoutMs);
37
+ }
38
+
39
+ function normalizedOptions(options: StoreCountOptions): { busyTimeoutMs: number; attempts: number } {
40
+ return {
41
+ busyTimeoutMs: options.busyTimeoutMs ?? 100,
42
+ attempts: options.attempts ?? 1,
43
+ };
44
+ }
45
+
12
46
  export interface LearningStoreRowCounts {
13
- readonly lexicalRows: number | 'unreadable';
47
+ readonly lexicalRows: StoreRowCount;
14
48
  /** Exact active quarantine labels in the selected lexical SQLite tier; absent for fallback/error paths. */
15
49
  readonly lexicalQuarantinedRows?: number;
16
50
  /** Physical lexical population counted; jsonl and SQLite maxima are not comparable. */
@@ -26,7 +60,7 @@ export interface LearningStoreRowCounts {
26
60
  * до уроков уронило бы наблюдение с 1774 до 631 — страж прочитал бы это как обвал стора.
27
61
  * Для показателя зеркала в панели есть отдельное поле `vectorLessonRows`.
28
62
  */
29
- readonly vectorRows: number | 'unreadable';
63
+ readonly vectorRows: StoreRowCount;
30
64
  /**
31
65
  * Только зеркальные УРОКИ (task_type dz-teach/dz-learning) — величина, сравнимая с `lexicalRows`.
32
66
  * Отсутствует, когда зеркала нет или разложить его по родам не удалось: тогда показатель обязан
@@ -52,21 +86,48 @@ function countJsonlRowsReadonly(path: string): number | 'unreadable' {
52
86
  export function countSqliteRowsReadonly(
53
87
  sqlitePath: string,
54
88
  table: 'memory_records' | 'reasoning_patterns',
55
- ): number | undefined {
56
- try {
57
- const require = createRequire(import.meta.url);
58
- const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
59
- const db = new Database(sqlitePath, { readonly: true });
89
+ ): number | undefined;
90
+ export function countSqliteRowsReadonly(
91
+ sqlitePath: string,
92
+ table: 'memory_records' | 'reasoning_patterns',
93
+ options: StoreCountOptions,
94
+ requireModule?: DatabaseRequire,
95
+ ): StoreRowCount;
96
+ export function countSqliteRowsReadonly(
97
+ sqlitePath: string,
98
+ table: 'memory_records' | 'reasoning_patterns',
99
+ options?: StoreCountOptions,
100
+ requireModule: DatabaseRequire = createRequire(import.meta.url),
101
+ ): number | undefined | StoreRowCount {
102
+ const legacyUndefined = options === undefined;
103
+ const { busyTimeoutMs, attempts } = normalizedOptions(options ?? {});
104
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
60
105
  try {
61
- db.pragma('busy_timeout = 100');
62
- const row = db.prepare(`SELECT COUNT(*) as cnt FROM ${table}`).get() as { cnt?: unknown };
63
- return typeof row?.cnt === 'number' ? row.cnt : undefined;
64
- } finally {
65
- db.close();
106
+ const Database = requireModule('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
107
+ const db = new Database(sqlitePath, { readonly: true });
108
+ try {
109
+ db.pragma(`busy_timeout = ${busyTimeoutMs}`);
110
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM ${table}`).get() as { cnt?: unknown };
111
+ return typeof row?.cnt === 'number' ? row.cnt : (legacyUndefined ? undefined : 'unreadable');
112
+ } finally {
113
+ db.close();
114
+ }
115
+ } catch (error) {
116
+ if (isBusy(error)) {
117
+ if (attempt + 1 < attempts) continue;
118
+ return legacyUndefined ? undefined : 'busy';
119
+ }
120
+ if (isInitializing(error)) {
121
+ if (attempt + 1 < attempts) {
122
+ waitForInitializationRetry(busyTimeoutMs);
123
+ continue;
124
+ }
125
+ return legacyUndefined ? undefined : 'unreadable';
126
+ }
127
+ return legacyUndefined ? undefined : 'unreadable';
66
128
  }
67
- } catch {
68
- return undefined;
69
129
  }
130
+ return legacyUndefined ? undefined : 'busy';
70
131
  }
71
132
 
72
133
  interface SqliteRowsWithQuarantine {
@@ -80,61 +141,79 @@ interface SqliteRowsWithQuarantine {
80
141
  function countSqliteRowsWithQuarantineReadonly(
81
142
  sqlitePath: string,
82
143
  table: 'memory_records' | 'reasoning_patterns',
83
- ): SqliteRowsWithQuarantine | undefined {
84
- try {
85
- const require = createRequire(import.meta.url);
86
- const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
87
- const db = new Database(sqlitePath, { readonly: true });
144
+ options: StoreCountOptions,
145
+ requireModule: DatabaseRequire,
146
+ ): SqliteRowsWithQuarantine | 'unreadable' | 'busy' {
147
+ const { busyTimeoutMs, attempts } = normalizedOptions(options);
148
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
88
149
  try {
89
- db.pragma('busy_timeout = 100');
150
+ const Database = requireModule('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
151
+ const db = new Database(sqlitePath, { readonly: true });
90
152
  try {
91
- const sql = table === 'memory_records'
92
- ? `SELECT COALESCE(SUM(n), 0) AS cnt,
153
+ db.pragma(`busy_timeout = ${busyTimeoutMs}`);
154
+ try {
155
+ const sql = table === 'memory_records'
156
+ ? `SELECT COALESCE(SUM(n), 0) AS cnt,
93
157
  COALESCE(SUM(CASE WHEN q_status = 'quarantined' THEN n ELSE 0 END), 0) AS quarantined
94
158
  FROM (
95
159
  SELECT CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.qStatus') END AS q_status,
96
160
  COUNT(*) AS n
97
161
  FROM memory_records GROUP BY 1
98
162
  )`
99
- : `SELECT COUNT(*) AS cnt,
163
+ : `SELECT COUNT(*) AS cnt,
100
164
  COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning') THEN 1 ELSE 0 END), 0) AS lessons,
101
165
  COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning')
102
166
  AND json_valid(metadata)
103
167
  AND json_extract(metadata, '$.qStatus') = 'quarantined' THEN 1 ELSE 0 END), 0) AS quarantined
104
168
  FROM reasoning_patterns`;
105
- const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
106
- if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return undefined;
107
- return {
108
- rows: row.cnt,
109
- quarantinedRows: row.quarantined,
110
- ...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
111
- };
112
- } catch {
113
- // Запасной путь: общий объём берём всегда, а разложение по родам пробуем ОТДЕЛЬНО —
114
- // схема без `metadata`, но с `task_type` уроки различает, и терять это не за что.
115
- // Если не различает и её lessonRows остаётся неизвестным, и показатель обязан сказать
116
- // «не читается» вместо мнимого совпадения.
117
- const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
118
- if (typeof row?.cnt !== 'number') return undefined;
119
- if (table !== 'reasoning_patterns') return { rows: row.cnt };
120
- try {
121
- const lesson = db.prepare(
122
- `SELECT COUNT(*) AS cnt FROM reasoning_patterns
169
+ const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
170
+ if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return 'unreadable';
171
+ return {
172
+ rows: row.cnt,
173
+ quarantinedRows: row.quarantined,
174
+ ...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
175
+ };
176
+ } catch (error) {
177
+ if (isBusy(error) || isInitializing(error)) throw error;
178
+ // Запасной путь: общий объём берём всегда, а разложение по родам пробуем ОТДЕЛЬНО
179
+ // схема без `metadata`, но с `task_type` уроки различает, и терять это не за что.
180
+ // Если не различает и её — lessonRows остаётся неизвестным, и показатель обязан сказать
181
+ // «не читается» вместо мнимого совпадения.
182
+ const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
183
+ if (typeof row?.cnt !== 'number') return 'unreadable';
184
+ if (table !== 'reasoning_patterns') return { rows: row.cnt };
185
+ try {
186
+ const lesson = db.prepare(
187
+ `SELECT COUNT(*) AS cnt FROM reasoning_patterns
123
188
  WHERE task_type IN ('dz-teach', 'dz-learning')`,
124
- ).get() as { cnt?: unknown };
125
- return typeof lesson?.cnt === 'number'
126
- ? { rows: row.cnt, lessonRows: lesson.cnt }
127
- : { rows: row.cnt };
128
- } catch {
129
- return { rows: row.cnt };
189
+ ).get() as { cnt?: unknown };
190
+ return typeof lesson?.cnt === 'number'
191
+ ? { rows: row.cnt, lessonRows: lesson.cnt }
192
+ : { rows: row.cnt };
193
+ } catch (lessonError) {
194
+ if (isBusy(lessonError) || isInitializing(lessonError)) throw lessonError;
195
+ return { rows: row.cnt };
196
+ }
130
197
  }
198
+ } finally {
199
+ db.close();
131
200
  }
132
- } finally {
133
- db.close();
201
+ } catch (error) {
202
+ if (isBusy(error)) {
203
+ if (attempt + 1 < attempts) continue;
204
+ return 'busy';
205
+ }
206
+ if (isInitializing(error)) {
207
+ if (attempt + 1 < attempts) {
208
+ waitForInitializationRetry(busyTimeoutMs);
209
+ continue;
210
+ }
211
+ return 'unreadable';
212
+ }
213
+ return 'unreadable';
134
214
  }
135
- } catch {
136
- return undefined;
137
215
  }
216
+ return 'busy';
138
217
  }
139
218
 
140
219
  /**
@@ -142,7 +221,11 @@ function countSqliteRowsWithQuarantineReadonly(
142
221
  * Missing tiers count as zero; an existing tier that cannot be counted is
143
222
  * explicitly `unreadable`.
144
223
  */
145
- export function countLearningStoreRowsReadonly(projectRoot: string): LearningStoreRowCounts {
224
+ export function countLearningStoreRowsReadonly(
225
+ projectRoot: string,
226
+ options: StoreCountOptions = {},
227
+ requireModule: DatabaseRequire = createRequire(import.meta.url),
228
+ ): LearningStoreRowCounts {
146
229
  const root = resolve(projectRoot);
147
230
  const lexicalPath = join(root, '.dz', 'memory', 'patterns.sqlite');
148
231
  const jsonlPath = join(root, '.dz', 'patterns.jsonl');
@@ -150,23 +233,23 @@ export function countLearningStoreRowsReadonly(projectRoot: string): LearningSto
150
233
  const vectorExists = existsSync(vectorPath);
151
234
  const lexicalSource = existsSync(lexicalPath) ? 'sqlite' : 'jsonl';
152
235
  const lexicalSqlite = lexicalSource === 'sqlite'
153
- ? countSqliteRowsWithQuarantineReadonly(lexicalPath, 'memory_records')
236
+ ? countSqliteRowsWithQuarantineReadonly(lexicalPath, 'memory_records', options, requireModule)
154
237
  : undefined;
155
- let lexicalRows: number | 'unreadable';
238
+ let lexicalRows: StoreRowCount;
156
239
  if (lexicalSource === 'sqlite') {
157
- lexicalRows = lexicalSqlite?.rows ?? 'unreadable';
240
+ lexicalRows = typeof lexicalSqlite === 'object' ? lexicalSqlite.rows : lexicalSqlite ?? 'unreadable';
158
241
  } else {
159
242
  lexicalRows = countJsonlRowsReadonly(jsonlPath);
160
243
  }
161
244
  const vectorSqlite = vectorExists
162
- ? countSqliteRowsWithQuarantineReadonly(vectorPath, 'reasoning_patterns')
245
+ ? countSqliteRowsWithQuarantineReadonly(vectorPath, 'reasoning_patterns', options, requireModule)
163
246
  : undefined;
164
247
  const ignoredJsonl = lexicalSource === 'sqlite' && existsSync(jsonlPath)
165
248
  ? countJsonlRowsReadonly(jsonlPath)
166
249
  : undefined;
167
250
  return {
168
251
  lexicalRows,
169
- ...(lexicalSqlite?.quarantinedRows === undefined ? {} : {
252
+ ...(typeof lexicalSqlite !== 'object' || lexicalSqlite.quarantinedRows === undefined ? {} : {
170
253
  lexicalQuarantinedRows: lexicalSqlite.quarantinedRows,
171
254
  }),
172
255
  lexicalSource,
@@ -175,9 +258,12 @@ export function countLearningStoreRowsReadonly(projectRoot: string): LearningSto
175
258
  lexicalIgnoredRows: ignoredJsonl,
176
259
  lexicalIgnoredSourcePath: jsonlPath,
177
260
  }),
178
- vectorRows: vectorExists ? (vectorSqlite?.rows ?? 'unreadable') : 0,
179
- ...(vectorSqlite?.lessonRows === undefined ? {} : { vectorLessonRows: vectorSqlite.lessonRows }),
180
- ...(vectorSqlite?.quarantinedRows === undefined ? {} : {
261
+ vectorRows: vectorExists
262
+ ? (typeof vectorSqlite === 'object' ? vectorSqlite.rows : vectorSqlite ?? 'unreadable')
263
+ : 0,
264
+ ...(typeof vectorSqlite !== 'object' || vectorSqlite.lessonRows === undefined
265
+ ? {} : { vectorLessonRows: vectorSqlite.lessonRows }),
266
+ ...(typeof vectorSqlite !== 'object' || vectorSqlite.quarantinedRows === undefined ? {} : {
181
267
  vectorQuarantinedRows: vectorSqlite.quarantinedRows,
182
268
  }),
183
269
  ...(vectorExists ? { vectorSourcePath: vectorPath } : {}),
@@ -92,9 +92,9 @@ export interface StoreMarkWriteOptions {
92
92
  readonly expectedPreviousLexicalSource?: StoreCountSource | 'unknown';
93
93
  }
94
94
 
95
- export type StoreRowCount = number | 'unreadable';
95
+ export type StoreRowCount = number | 'unreadable' | 'busy';
96
96
 
97
- export type StoreHealthVerdict = 'ok' | 'collapsed' | 'cold-start-over-existing' | 'source-changed' | 'unreadable' | 'no-mark';
97
+ export type StoreHealthVerdict = 'ok' | 'collapsed' | 'cold-start-over-existing' | 'source-changed' | 'unreadable' | 'busy' | 'no-mark';
98
98
 
99
99
  export interface StoreHealth {
100
100
  readonly verdict: StoreHealthVerdict;
@@ -354,6 +354,12 @@ export function checkStoreHealth(input: StoreHealthInput): StoreHealth {
354
354
  reason: `${unreadable.join(' and ')} store${unreadable.length > 1 ? 's are' : ' is'} unreadable`,
355
355
  };
356
356
  }
357
+ if (lexicalRows === 'busy' || vectorRows === 'busy') {
358
+ return {
359
+ verdict: 'busy',
360
+ reason: 'lexical store busy — another writer holds it; health not measured this run',
361
+ };
362
+ }
357
363
  if (mark === undefined) return { verdict: 'no-mark', reason: 'no store mark exists for this project' };
358
364
 
359
365
  const lexicalCount = lexicalRows as number;