openclaw-hybrid-memory 2026.6.21 → 2026.6.40
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/README.md +11 -1
- package/backends/credentials-db.ts +230 -4
- package/backends/vector-db/runtime-locks.ts +27 -0
- package/backends/vector-db/vector-db-class.ts +18 -11
- package/backends/wal.ts +34 -27
- package/cli/active-tasks.ts +564 -109
- package/cli/cmd-credentials.ts +33 -2
- package/cli/cmd-extract-proposals.ts +81 -51
- package/cli/commands/manage/register-analyze-maintenance-logs.ts +34 -7
- package/cli/commands/manage/register-credentials-scope.ts +51 -3
- package/cli/commands/manage/register-procedure-lifecycle.ts +34 -0
- package/cli/commands/manage/register-reflection-pipeline.ts +275 -184
- package/cli/commands/manage/register-storage-and-stats.ts +1 -0
- package/cli/commands/manage/register-storage-graph-audit.ts +153 -77
- package/cli/commands/manage/storage-stats-helpers.ts +21 -0
- package/cli/context.ts +8 -1
- package/cli/distill.ts +3 -0
- package/cli/install/cron-jobs.ts +1 -1
- package/cli/install/workspace.ts +1 -4
- package/cli/register.ts +11 -0
- package/cli/types.ts +52 -2
- package/config/index.ts +1 -0
- package/dist/backends/credentials-db.d.ts +22 -0
- package/dist/backends/credentials-db.js +128 -3
- package/dist/backends/credentials-db.js.map +1 -1
- package/dist/backends/facts-db/fact-read-queries.js +1 -1
- package/dist/backends/facts-db/stats.js +1 -1
- package/dist/backends/vector-db/runtime-locks.js +22 -1
- package/dist/backends/vector-db/runtime-locks.js.map +1 -1
- package/dist/backends/vector-db/vector-db-class.d.ts +2 -0
- package/dist/backends/vector-db/vector-db-class.js +14 -8
- package/dist/backends/vector-db/vector-db-class.js.map +1 -1
- package/dist/backends/wal.js +27 -15
- package/dist/backends/wal.js.map +1 -1
- package/dist/cli/active-tasks.js +379 -88
- package/dist/cli/active-tasks.js.map +1 -1
- package/dist/cli/cmd-backfill.js +1 -1
- package/dist/cli/cmd-credentials.js +20 -2
- package/dist/cli/cmd-credentials.js.map +1 -1
- package/dist/cli/cmd-distill.js +1 -1
- package/dist/cli/cmd-extract-proposals.js +57 -35
- package/dist/cli/cmd-extract-proposals.js.map +1 -1
- package/dist/cli/commands/manage/register-agents-audit-runall.js +1 -1
- package/dist/cli/commands/manage/register-analyze-maintenance-logs.js +29 -7
- package/dist/cli/commands/manage/register-analyze-maintenance-logs.js.map +1 -1
- package/dist/cli/commands/manage/register-credentials-scope.js +32 -4
- package/dist/cli/commands/manage/register-credentials-scope.js.map +1 -1
- package/dist/cli/commands/manage/register-procedure-lifecycle.js +21 -0
- package/dist/cli/commands/manage/register-procedure-lifecycle.js.map +1 -1
- package/dist/cli/commands/manage/register-reflection-pipeline.js +69 -36
- package/dist/cli/commands/manage/register-reflection-pipeline.js.map +1 -1
- package/dist/cli/commands/manage/register-storage-and-stats.js +1 -0
- package/dist/cli/commands/manage/register-storage-and-stats.js.map +1 -1
- package/dist/cli/commands/manage/register-storage-graph-audit.js +102 -50
- package/dist/cli/commands/manage/register-storage-graph-audit.js.map +1 -1
- package/dist/cli/commands/manage/storage-stats-helpers.js +6 -0
- package/dist/cli/commands/manage/storage-stats-helpers.js.map +1 -1
- package/dist/cli/distill.js +1 -0
- package/dist/cli/distill.js.map +1 -1
- package/dist/cli/install/cron-jobs.js +1 -1
- package/dist/cli/install/cron-jobs.js.map +1 -1
- package/dist/cli/install/workspace.js +1 -3
- package/dist/cli/install/workspace.js.map +1 -1
- package/dist/cli/register.js.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/lifecycle/stage-capture/run-capture.js +1 -1
- package/dist/lifecycle/stage-injection.js +1 -1
- package/dist/lifecycle/stage-recall/run-recall.js +1 -1
- package/dist/services/active-task-checkpoint.js +1 -3
- package/dist/services/active-task-checkpoint.js.map +1 -1
- package/dist/services/active-task-reconcile-progress.js +142 -0
- package/dist/services/active-task-reconcile-progress.js.map +1 -0
- package/dist/services/active-task.js +146 -19
- package/dist/services/active-task.js.map +1 -1
- package/dist/services/adaptive-maintenance-llm.js +3 -2
- package/dist/services/adaptive-maintenance-llm.js.map +1 -1
- package/dist/services/audit-health-exit-info.js +31 -1
- package/dist/services/audit-health-exit-info.js.map +1 -1
- package/dist/services/chat.js +2 -1
- package/dist/services/chat.js.map +1 -1
- package/dist/services/context-audit.js +1 -1
- package/dist/services/context-engine.js +5 -1
- package/dist/services/context-engine.js.map +1 -1
- package/dist/services/contradiction-progress-summary.js +72 -0
- package/dist/services/contradiction-progress-summary.js.map +1 -0
- package/dist/services/cron-exit-validator.js +30 -11
- package/dist/services/cron-exit-validator.js.map +1 -1
- package/dist/services/cron-job-bash-harness.js +15 -7
- package/dist/services/cron-job-bash-harness.js.map +1 -1
- package/dist/services/crystallization-proposer.js +1 -4
- package/dist/services/crystallization-proposer.js.map +1 -1
- package/dist/services/directive-extract.js +1 -1
- package/dist/services/dream-cycle.js +110 -8
- package/dist/services/dream-cycle.js.map +1 -1
- package/dist/services/hybrid-mem-cron-default-job-steps.js +20 -2
- package/dist/services/hybrid-mem-cron-default-job-steps.js.map +1 -1
- package/dist/services/maintenance-auto-fix.js +1 -1
- package/dist/services/maintenance-benign-noise.js +81 -0
- package/dist/services/maintenance-benign-noise.js.map +1 -0
- package/dist/services/maintenance-failure-reporter.js.map +1 -1
- package/dist/services/maintenance-inventory.js +724 -0
- package/dist/services/maintenance-inventory.js.map +1 -0
- package/dist/services/maintenance-log-analyzer.js +193 -20
- package/dist/services/maintenance-log-analyzer.js.map +1 -1
- package/dist/services/passive-observer.js +1 -1
- package/dist/services/procedure-promotion-policy.js +1 -1
- package/dist/services/procedure-skill-generator.js +1 -4
- package/dist/services/procedure-skill-generator.js.map +1 -1
- package/dist/services/reflection/structured-output.js +368 -0
- package/dist/services/reflection/structured-output.js.map +1 -0
- package/dist/services/reflection.js +77 -116
- package/dist/services/reflection.js.map +1 -1
- package/dist/services/reinforcement-extract.js +1 -1
- package/dist/services/self-correction-extract.js +1 -1
- package/dist/services/skill-validator.js +1 -1
- package/dist/services/task-hygiene.js +4 -3
- package/dist/services/task-hygiene.js.map +1 -1
- package/dist/services/task-ledger-facts.js +158 -15
- package/dist/services/task-ledger-facts.js.map +1 -1
- package/dist/setup/cli-context/cli-services.js +11 -2
- package/dist/setup/cli-context/cli-services.js.map +1 -1
- package/dist/setup/cli-context/register-help.js +2 -1
- package/dist/setup/cli-context/register-help.js.map +1 -1
- package/dist/setup/tool-installers.js +1 -1
- package/dist/setup/tool-installers.js.map +1 -1
- package/dist/tools/memory/register-store-tools.js +1 -1
- package/dist/tools/public-api-routes.js +5 -4
- package/dist/tools/public-api-routes.js.map +1 -1
- package/dist/utils/llm-json-array.js +1 -1
- package/dist/utils/process-runner.js +6 -2
- package/dist/utils/process-runner.js.map +1 -1
- package/dist/utils/prompt-loader.js +1 -3
- package/dist/utils/prompt-loader.js.map +1 -1
- package/dist/utils/text.js +7 -1
- package/dist/utils/text.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +30 -150
- package/package.json +1 -1
- package/services/active-task-checkpoint.ts +1 -4
- package/services/active-task-reconcile-progress.ts +219 -0
- package/services/active-task.ts +169 -15
- package/services/adaptive-maintenance-llm.ts +3 -0
- package/services/audit-health-exit-info.ts +54 -0
- package/services/chat.ts +8 -0
- package/services/context-engine.ts +8 -1
- package/services/contradiction-progress-summary.ts +152 -0
- package/services/cron-exit-validator.ts +40 -13
- package/services/cron-job-bash-harness.ts +17 -9
- package/services/crystallization-proposer.ts +1 -5
- package/services/dream-cycle.ts +120 -12
- package/services/hybrid-mem-cron-default-job-steps.ts +11 -1
- package/services/maintenance-benign-noise.ts +125 -0
- package/services/maintenance-failure-reporter.ts +4 -7
- package/services/maintenance-inventory.ts +821 -0
- package/services/maintenance-log-analyzer.ts +266 -22
- package/services/procedure-skill-generator.ts +1 -5
- package/services/reflection/structured-output.ts +483 -0
- package/services/reflection.ts +140 -149
- package/services/task-hygiene.ts +4 -3
- package/services/task-ledger-facts.ts +184 -19
- package/services/vm-memory-incident-capture.ts +1 -2
- package/setup/cli-context/cli-services.ts +13 -1
- package/setup/cli-context/register-help.ts +1 -0
- package/setup/tool-installers.ts +1 -1
- package/tools/public-api-routes.ts +6 -3
- package/utils/prompt-loader.ts +1 -4
- package/utils/text.ts +7 -0
package/README.md
CHANGED
|
@@ -299,10 +299,20 @@ openclaw hybrid-mem analyze-maintenance-logs --since 7d --format json --out /tmp
|
|
|
299
299
|
openclaw hybrid-mem analyze-maintenance-logs --since 24h --auto-fix --glitchtip --strict
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
-
Rules are data-driven in [`services/maintenance-rules.json`](services/maintenance-rules.json), so operators can inspect or extend classifications without changing analyzer code. Findings are persisted in `maintenance-findings.db` table `maintenance_finding`, enabling week-over-week trend output for `--since 7d` / `--trend` runs.
|
|
302
|
+
Rules are data-driven in [`services/maintenance-rules.json`](services/maintenance-rules.json), so operators can inspect or extend classifications without changing analyzer code. Findings are persisted in `maintenance-findings.db` table `maintenance_finding`, enabling week-over-week trend output for `--since 7d` / `--trend` runs. The digest collapses repeated fingerprints into `New` vs `Still failing`, suppresses stale historical fingerprints outside the current window from the primary findings, and avoids re-reporting already GlitchTip-reported fingerprints on every analyzer run. Known benign compatibility notices (for example missing `registerContextEngine` on older SDKs and Codex project-local config warnings) are collected in `noiseWarnings[]` and excluded from primary failure counts by default (#1833).
|
|
303
303
|
|
|
304
304
|
The installer registers `hybrid-mem:maintenance-log-analyzer` to run after the nightly chain and announce the rendered digest to the operator.
|
|
305
305
|
|
|
306
|
+
### Unified maintenance inventory
|
|
307
|
+
|
|
308
|
+
Use the maintenance inventory report to see host crontab jobs and gateway `~/.openclaw/cron/jobs.json` jobs in one place, including scheduler ownership, timezone, guard/log paths, last-run state, and collision groups:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
openclaw hybrid-mem maintenance inventory
|
|
312
|
+
openclaw hybrid-mem maintenance inventory --format markdown
|
|
313
|
+
openclaw hybrid-mem maintenance inventory --json
|
|
314
|
+
```
|
|
315
|
+
|
|
306
316
|
### Auto-fix whitelist (#1199)
|
|
307
317
|
|
|
308
318
|
`--auto-fix` applies **only** safe, idempotent actions implemented in [`services/maintenance-auto-fix.ts`](services/maintenance-auto-fix.ts):
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "node:crypto";
|
|
8
|
-
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
10
|
import { DatabaseSync } from "node:sqlite";
|
|
11
11
|
import type { CredentialType } from "../config.js";
|
|
@@ -30,6 +30,9 @@ const CRED_KDF_PLAINTEXT = 0; // no encryption (user secures by other means)
|
|
|
30
30
|
/** Log once per vault path: legacy v1 KDF is weak; opening triggers migration to scrypt when possible. */
|
|
31
31
|
const _v1KdfWarnedPaths = new Set<string>();
|
|
32
32
|
|
|
33
|
+
/** Log once per vault path: key is configured but vault is plaintext (kdf_version=0). */
|
|
34
|
+
const _plaintextKeyIgnoredWarnedPaths = new Set<string>();
|
|
35
|
+
|
|
33
36
|
/** v1 only: legacy SHA-256 KDF (weak). Existing vaults cannot be decrypted with another KDF. */
|
|
34
37
|
function deriveKeyV1Legacy(password: string): Buffer {
|
|
35
38
|
// codeql[js/insufficient-password-hash]
|
|
@@ -170,11 +173,12 @@ export class CredentialsDB extends BaseSqliteStore {
|
|
|
170
173
|
this.salt = Buffer.alloc(0);
|
|
171
174
|
this.key = Buffer.alloc(0);
|
|
172
175
|
this.password = null;
|
|
173
|
-
// Optionally warn that key is being ignored
|
|
174
|
-
if (encryptionKey.length >= 16) {
|
|
176
|
+
// Optionally warn that key is being ignored (once per process per path)
|
|
177
|
+
if (encryptionKey.length >= 16 && !_plaintextKeyIgnoredWarnedPaths.has(dbPath)) {
|
|
178
|
+
_plaintextKeyIgnoredWarnedPaths.add(dbPath);
|
|
175
179
|
pluginLogger.warn(
|
|
176
180
|
"Credentials vault is in plaintext mode (kdf_version=0). The configured encryption key is being ignored. " +
|
|
177
|
-
"To encrypt the existing vault at rest, run: openclaw hybrid-mem credentials encrypt-vault --yes",
|
|
181
|
+
"To encrypt the existing vault at rest, run: openclaw hybrid-mem credentials encrypt-vault --backup --verify --yes",
|
|
178
182
|
);
|
|
179
183
|
}
|
|
180
184
|
return;
|
|
@@ -227,10 +231,13 @@ export class CredentialsDB extends BaseSqliteStore {
|
|
|
227
231
|
configuredKeyPresent: boolean;
|
|
228
232
|
keyIgnored: boolean;
|
|
229
233
|
migrationRequired: boolean;
|
|
234
|
+
entryCount: number;
|
|
230
235
|
} {
|
|
231
236
|
const encryptedAtRest = this.kdfVersion !== CRED_KDF_PLAINTEXT;
|
|
232
237
|
const keyIgnored = this.kdfVersion === CRED_KDF_PLAINTEXT && this.configuredKeyPresent;
|
|
233
238
|
const migrationRequired = keyIgnored;
|
|
239
|
+
const entryCount = (this.liveDb.prepare("SELECT COUNT(*) as count FROM credentials").get() as { count: number })
|
|
240
|
+
.count;
|
|
234
241
|
return {
|
|
235
242
|
dbPath: this.dbPath,
|
|
236
243
|
kdfVersion: this.kdfVersion,
|
|
@@ -238,6 +245,7 @@ export class CredentialsDB extends BaseSqliteStore {
|
|
|
238
245
|
configuredKeyPresent: this.configuredKeyPresent,
|
|
239
246
|
keyIgnored,
|
|
240
247
|
migrationRequired,
|
|
248
|
+
entryCount,
|
|
241
249
|
};
|
|
242
250
|
}
|
|
243
251
|
|
|
@@ -297,6 +305,224 @@ export class CredentialsDB extends BaseSqliteStore {
|
|
|
297
305
|
return { migrated: migratedCount, kdfVersion: this.kdfVersion };
|
|
298
306
|
}
|
|
299
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Safe vault encryption with optional backup and post-encryption verification.
|
|
310
|
+
*
|
|
311
|
+
* Steps:
|
|
312
|
+
* 1. If `backupPath` is set, create a consistent copy of the current plaintext DB via VACUUM INTO.
|
|
313
|
+
* 2. Encrypt the vault in-place via `enableEncryptionAtRest`.
|
|
314
|
+
* 3. If `verify` is true, attempt to decrypt every entry to confirm data integrity.
|
|
315
|
+
* On failure, throws with the backup path so the operator can restore manually.
|
|
316
|
+
*
|
|
317
|
+
* Safety: does NOT auto-restore on verify failure — the in-place operation may have
|
|
318
|
+
* already committed. The caller should inspect `backupPath` and restore if needed.
|
|
319
|
+
*/
|
|
320
|
+
encryptVaultSafe(
|
|
321
|
+
encryptionKey: string,
|
|
322
|
+
opts: { backupPath?: string; verify?: boolean } = {},
|
|
323
|
+
): { migrated: number; kdfVersion: number; backupPath?: string; verified?: boolean } {
|
|
324
|
+
const { backupPath, verify } = opts;
|
|
325
|
+
|
|
326
|
+
const keyLooksEncrypted = encryptionKey.length >= 16;
|
|
327
|
+
if (!keyLooksEncrypted) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
"Encryption key is missing or too short. Set credentials.encryptionKey (16+ chars) or OPENCLAW_CRED_KEY before encrypting the vault.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (this.kdfVersion !== CRED_KDF_PLAINTEXT) {
|
|
333
|
+
throw new Error(`Credentials vault is already encrypted (kdf_version=${this.kdfVersion}).`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const newSalt = randomBytes(32);
|
|
337
|
+
const newKdfVersion = CRED_KDF_VERSION;
|
|
338
|
+
const newKey = deriveKey(encryptionKey, newSalt, newKdfVersion);
|
|
339
|
+
|
|
340
|
+
let migratedCount = 0;
|
|
341
|
+
|
|
342
|
+
// Perform backup and encryption in a single IMMEDIATE transaction to prevent
|
|
343
|
+
// writes between backup snapshot and encryption. This fixes the race condition
|
|
344
|
+
// where VACUUM INTO would snapshot at T1, then new rows could be written at T2,
|
|
345
|
+
// then encryption would start at T3, resulting in encrypted rows missing from backup.
|
|
346
|
+
|
|
347
|
+
// Preserve existing backup during retry by renaming it temporarily
|
|
348
|
+
let oldBackupPath: string | undefined;
|
|
349
|
+
if (backupPath) {
|
|
350
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
351
|
+
try {
|
|
352
|
+
// Check if old backup exists and rename it temporarily
|
|
353
|
+
if (existsSync(backupPath)) {
|
|
354
|
+
oldBackupPath = `${backupPath}.old.${Date.now()}`;
|
|
355
|
+
renameSync(backupPath, oldBackupPath);
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
// If rename fails, proceed anyway (file might not exist)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const migrateWithBackup = createTransaction(
|
|
363
|
+
this.liveDb,
|
|
364
|
+
() => {
|
|
365
|
+
if (backupPath) {
|
|
366
|
+
// Create backup database and copy all data within this transaction
|
|
367
|
+
const backupDb = new DatabaseSync(backupPath);
|
|
368
|
+
try {
|
|
369
|
+
// Copy schema
|
|
370
|
+
backupDb.exec(`
|
|
371
|
+
CREATE TABLE vault_meta (
|
|
372
|
+
key TEXT PRIMARY KEY,
|
|
373
|
+
value BLOB NOT NULL
|
|
374
|
+
)
|
|
375
|
+
`);
|
|
376
|
+
backupDb.exec(`
|
|
377
|
+
CREATE TABLE credentials (
|
|
378
|
+
service TEXT NOT NULL,
|
|
379
|
+
type TEXT NOT NULL DEFAULT 'other',
|
|
380
|
+
value BLOB NOT NULL,
|
|
381
|
+
url TEXT,
|
|
382
|
+
notes TEXT,
|
|
383
|
+
created INTEGER NOT NULL,
|
|
384
|
+
updated INTEGER NOT NULL,
|
|
385
|
+
expires INTEGER,
|
|
386
|
+
PRIMARY KEY (service, type)
|
|
387
|
+
)
|
|
388
|
+
`);
|
|
389
|
+
backupDb.exec(`
|
|
390
|
+
CREATE INDEX idx_credentials_service ON credentials(service)
|
|
391
|
+
`);
|
|
392
|
+
|
|
393
|
+
// Copy vault_meta
|
|
394
|
+
const metaRows = this.liveDb.prepare("SELECT key, value FROM vault_meta").all() as Array<{
|
|
395
|
+
key: string;
|
|
396
|
+
value: Uint8Array | Buffer;
|
|
397
|
+
}>;
|
|
398
|
+
const insertMeta = backupDb.prepare("INSERT INTO vault_meta (key, value) VALUES (?, ?)");
|
|
399
|
+
for (const row of metaRows) {
|
|
400
|
+
insertMeta.run(row.key, row.value);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Copy credentials
|
|
404
|
+
const credRows = this.liveDb.prepare("SELECT * FROM credentials").all() as Array<Record<string, unknown>>;
|
|
405
|
+
migratedCount = credRows.length;
|
|
406
|
+
const insertCred = backupDb.prepare(
|
|
407
|
+
"INSERT INTO credentials (service, type, value, url, notes, created, updated, expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
408
|
+
);
|
|
409
|
+
for (const row of credRows) {
|
|
410
|
+
insertCred.run(
|
|
411
|
+
row.service,
|
|
412
|
+
row.type,
|
|
413
|
+
row.value,
|
|
414
|
+
row.url,
|
|
415
|
+
row.notes,
|
|
416
|
+
row.created,
|
|
417
|
+
row.updated,
|
|
418
|
+
row.expires,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
backupDb.close();
|
|
423
|
+
tryRestrictSqliteDbFileMode(backupPath);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
backupDb.close();
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
// No backup, just count rows
|
|
430
|
+
const rows = this.liveDb.prepare("SELECT service, type FROM credentials").all() as Array<{
|
|
431
|
+
service: string;
|
|
432
|
+
type: string;
|
|
433
|
+
}>;
|
|
434
|
+
migratedCount = rows.length;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Now encrypt within the same transaction
|
|
438
|
+
const rows = this.liveDb.prepare("SELECT service, type, value FROM credentials").all() as Array<{
|
|
439
|
+
service: string;
|
|
440
|
+
type: string;
|
|
441
|
+
value: Uint8Array | Buffer;
|
|
442
|
+
}>;
|
|
443
|
+
const updateStmt = this.liveDb.prepare("UPDATE credentials SET value = ? WHERE service = ? AND type = ?");
|
|
444
|
+
for (const r of rows) {
|
|
445
|
+
const plaintext = toBuffer(r.value).toString("utf8");
|
|
446
|
+
const encrypted = encryptValue(plaintext, newKey);
|
|
447
|
+
updateStmt.run(encrypted as unknown as Uint8Array, r.service, r.type);
|
|
448
|
+
}
|
|
449
|
+
this.liveDb
|
|
450
|
+
.prepare("INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('kdf_version', ?)")
|
|
451
|
+
.run(Buffer.from([newKdfVersion]));
|
|
452
|
+
this.liveDb.prepare("INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('salt', ?)").run(newSalt);
|
|
453
|
+
},
|
|
454
|
+
"IMMEDIATE",
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
let migrationSucceeded = false;
|
|
458
|
+
try {
|
|
459
|
+
migrateWithBackup();
|
|
460
|
+
migrationSucceeded = true;
|
|
461
|
+
|
|
462
|
+
this.kdfVersion = newKdfVersion;
|
|
463
|
+
this.salt = newSalt;
|
|
464
|
+
this.key = newKey;
|
|
465
|
+
this.password = null;
|
|
466
|
+
this.storesEncryptedValues = true;
|
|
467
|
+
|
|
468
|
+
const result = { migrated: migratedCount, kdfVersion: this.kdfVersion };
|
|
469
|
+
|
|
470
|
+
let verified: boolean | undefined;
|
|
471
|
+
if (verify) {
|
|
472
|
+
try {
|
|
473
|
+
const entries = this.listAll();
|
|
474
|
+
// Verify that all encrypted entries were successfully decrypted
|
|
475
|
+
if (entries.length !== result.migrated) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`Verification failed: only ${entries.length} of ${result.migrated} entries could be decrypted`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
for (const e of entries) {
|
|
481
|
+
const verifiedEntry = this.get(e.service, e.type as CredentialType);
|
|
482
|
+
if (!verifiedEntry) {
|
|
483
|
+
throw new Error(`Verification failed: missing credential ${e.service}/${e.type}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
verified = true;
|
|
487
|
+
} catch (err) {
|
|
488
|
+
const backupNote = backupPath ? ` Plaintext backup is at: ${backupPath}` : "";
|
|
489
|
+
throw new Error(
|
|
490
|
+
`Vault encrypted but post-encryption verification failed: ${err instanceof Error ? err.message : String(err)}.${backupNote}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Success: delete the old backup if it exists
|
|
496
|
+
if (oldBackupPath) {
|
|
497
|
+
try {
|
|
498
|
+
unlinkSync(oldBackupPath);
|
|
499
|
+
} catch {
|
|
500
|
+
// Best effort cleanup; don't fail on cleanup errors
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
...result,
|
|
506
|
+
...(backupPath !== undefined ? { backupPath } : {}),
|
|
507
|
+
...(verified !== undefined ? { verified } : {}),
|
|
508
|
+
};
|
|
509
|
+
} catch (err) {
|
|
510
|
+
// After successful migration, keep the new backup (at backupPath) that matches the encrypted state.
|
|
511
|
+
// Only restore the old backup if migration itself failed.
|
|
512
|
+
if (oldBackupPath && backupPath && !migrationSucceeded) {
|
|
513
|
+
try {
|
|
514
|
+
if (existsSync(backupPath)) {
|
|
515
|
+
unlinkSync(backupPath);
|
|
516
|
+
}
|
|
517
|
+
renameSync(oldBackupPath, backupPath);
|
|
518
|
+
} catch {
|
|
519
|
+
// If restore fails, leave both files (old backup is preserved with .old suffix)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
300
526
|
/**
|
|
301
527
|
* Insert or update a credential. On conflict (service, type), `updated` and value fields refresh;
|
|
302
528
|
* `created` is preserved from the original row — intentional for "same key, rotated secret" flows (#894).
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level cooperative locks for LanceDB readers, optimize exclusivity, and re-index
|
|
3
|
+
* exclusion. Refcount maps use `.get(path) ?? 0` intentionally: updates run in synchronous
|
|
4
|
+
* JS blocks (no await between read and write), which is safe on Node's single-threaded model.
|
|
5
|
+
* Async callers must re-check immediately before mutating LanceDB (#1841).
|
|
6
|
+
*/
|
|
1
7
|
export const activeReadersByPath = new Map<string, number>();
|
|
2
8
|
export const optimizeExclusiveLockByPath = new Map<string, boolean>();
|
|
3
9
|
export const reindexExclusiveLockByPath = new Map<string, number>();
|
|
@@ -38,3 +44,24 @@ export function decrementReindexLockCount(dbPath: string): void {
|
|
|
38
44
|
}
|
|
39
45
|
reindexExclusiveLockByPath.set(dbPath, next);
|
|
40
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Returns true when a re-index operation currently holds the cooperative re-index lock. */
|
|
49
|
+
export function isReindexLockHeld(dbPath: string): boolean {
|
|
50
|
+
return getReindexLockCount(dbPath) > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wait until no re-index lock is held. Re-validates synchronously after each drain so a lock
|
|
55
|
+
* acquired during an await does not leave callers proceeding into LanceDB mutations (#1841).
|
|
56
|
+
*/
|
|
57
|
+
export async function waitForReindexLockClear(dbPath: string, maxWaitMs = 30_000): Promise<void> {
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
while (isReindexLockHeld(dbPath)) {
|
|
60
|
+
if (Date.now() - started > maxWaitMs) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`VectorDB operation blocked by active re-index lock for >${maxWaitMs}ms. Retry after re-index completes.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -50,9 +50,11 @@ import {
|
|
|
50
50
|
incrementActiveReaderCount,
|
|
51
51
|
incrementReindexLockCount,
|
|
52
52
|
isOptimizeLocked,
|
|
53
|
+
isReindexLockHeld,
|
|
53
54
|
optimizeExclusiveLockByPath,
|
|
54
55
|
reindexExclusiveLockByPath,
|
|
55
56
|
setOptimizeLock,
|
|
57
|
+
waitForReindexLockClear,
|
|
56
58
|
} from "./runtime-locks.js";
|
|
57
59
|
|
|
58
60
|
export class VectorDB {
|
|
@@ -228,15 +230,7 @@ export class VectorDB {
|
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
private async waitForReindexLockRelease(maxWaitMs = 30_000): Promise<void> {
|
|
231
|
-
|
|
232
|
-
while (getReindexLockCount(this.dbPath) > 0) {
|
|
233
|
-
if (Date.now() - started > maxWaitMs) {
|
|
234
|
-
throw new Error(
|
|
235
|
-
`VectorDB operation blocked by active re-index lock for >${maxWaitMs}ms. Retry after re-index completes.`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
239
|
-
}
|
|
233
|
+
await waitForReindexLockClear(this.dbPath, maxWaitMs);
|
|
240
234
|
}
|
|
241
235
|
|
|
242
236
|
async runWithReindexLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
@@ -1342,6 +1336,11 @@ export class VectorDB {
|
|
|
1342
1336
|
return /retryable commit conflict/i.test(msg);
|
|
1343
1337
|
}
|
|
1344
1338
|
|
|
1339
|
+
/** TOCTOU guard after waitForReindexLockRelease; distinct from max-wait timeout errors. */
|
|
1340
|
+
private isRetryableReindexLockBlockError(err: unknown): boolean {
|
|
1341
|
+
return err instanceof Error && err.message === "VectorDB write blocked by active re-index lock";
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1345
1344
|
private async sleep(ms: number): Promise<void> {
|
|
1346
1345
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1347
1346
|
}
|
|
@@ -1359,13 +1358,21 @@ export class VectorDB {
|
|
|
1359
1358
|
let lastErr: unknown;
|
|
1360
1359
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1361
1360
|
try {
|
|
1361
|
+
await this.waitForReindexLockRelease();
|
|
1362
|
+
if (isReindexLockHeld(this.dbPath)) {
|
|
1363
|
+
throw new Error("VectorDB write blocked by active re-index lock");
|
|
1364
|
+
}
|
|
1362
1365
|
return await fn();
|
|
1363
1366
|
} catch (err) {
|
|
1364
1367
|
lastErr = err;
|
|
1365
|
-
|
|
1368
|
+
const isCommitConflict = this.isRetryableCommitConflictError(err);
|
|
1369
|
+
const isReindexBlock = this.isRetryableReindexLockBlockError(err);
|
|
1370
|
+
if (!isCommitConflict && !isReindexBlock) throw err;
|
|
1371
|
+
if (attempt >= maxAttempts) throw err;
|
|
1366
1372
|
const delayMs = this.getWriteConflictRetryDelayMs(attempt);
|
|
1373
|
+
const reason = isReindexBlock ? "re-index lock block" : "retryable commit conflict";
|
|
1367
1374
|
this.logWarn(
|
|
1368
|
-
`memory-hybrid: ${operation} hit
|
|
1375
|
+
`memory-hybrid: ${operation} hit ${reason} (attempt ${attempt}/${maxAttempts}) — retrying in ${delayMs}ms`,
|
|
1369
1376
|
);
|
|
1370
1377
|
await this.sleep(delayMs);
|
|
1371
1378
|
}
|
package/backends/wal.ts
CHANGED
|
@@ -132,39 +132,46 @@ export class WriteAheadLog {
|
|
|
132
132
|
try {
|
|
133
133
|
// "a+" read+append so fdatasync works on more filesystems than read-only or append-only edge cases (issue #854).
|
|
134
134
|
fh = await open(this.walPath, "a+");
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
135
|
+
try {
|
|
136
|
+
await fh.datasync();
|
|
137
|
+
return;
|
|
138
|
+
} catch (datasyncErr) {
|
|
139
|
+
const code = (datasyncErr as NodeJS.ErrnoException).code;
|
|
140
|
+
if (code !== "EPERM" && code !== "EINVAL") throw datasyncErr;
|
|
141
|
+
// Intentional fail-fast when neither datasync nor fsync can persist WAL writes (#7, #1846).
|
|
142
|
+
// Some filesystems (e.g. NTFS via WSL2) reject fdatasync; reopen and try fsync() as fallback.
|
|
143
|
+
await fh.close().catch(() => {});
|
|
144
|
+
fh = undefined;
|
|
142
145
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
fh = await open(this.walPath, "a+");
|
|
147
|
+
} catch (openErr) {
|
|
148
|
+
throw openErr;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await fh.sync();
|
|
152
|
+
if (!this.fsyncWarnEmitted) {
|
|
153
|
+
pluginLogger.warn(
|
|
154
|
+
`[WAL] datasync() unsupported (${code}), using fsync() fallback — durability available but may be slower`,
|
|
155
|
+
);
|
|
156
|
+
this.fsyncWarnEmitted = true;
|
|
145
157
|
}
|
|
146
|
-
|
|
147
|
-
} catch (
|
|
158
|
+
return;
|
|
159
|
+
} catch (syncErr) {
|
|
148
160
|
if (!this.fsyncWarnEmitted) {
|
|
149
161
|
pluginLogger.error(
|
|
150
162
|
`[WAL] CRITICAL: Both datasync() and fsync() failed (${code}) — WAL durability unavailable on this filesystem. Data loss may occur on crash. Consider moving the database to a POSIX-compliant filesystem.`,
|
|
151
163
|
);
|
|
152
164
|
this.fsyncWarnEmitted = true;
|
|
153
165
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`[WAL] datasync() unsupported (${code}), using fsync() fallback — durability available but may be slower`,
|
|
163
|
-
);
|
|
164
|
-
this.fsyncWarnEmitted = true;
|
|
166
|
+
const causes = [datasyncErr, syncErr].filter((e): e is Error => e instanceof Error);
|
|
167
|
+
const err = new Error(`WAL fsync unavailable (${code}): datasync and fsync fallback both failed`, {
|
|
168
|
+
cause: syncErr instanceof Error ? syncErr : datasyncErr,
|
|
169
|
+
});
|
|
170
|
+
if (causes.length > 1) {
|
|
171
|
+
(err as Error & { causes: Error[] }).causes = causes;
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
165
174
|
}
|
|
166
|
-
} else {
|
|
167
|
-
throw err;
|
|
168
175
|
}
|
|
169
176
|
} finally {
|
|
170
177
|
await fh?.close();
|
|
@@ -189,7 +196,7 @@ export class WriteAheadLog {
|
|
|
189
196
|
operation: "wal-write",
|
|
190
197
|
subsystem: "wal",
|
|
191
198
|
});
|
|
192
|
-
throw new Error(`WAL write failed: ${err}
|
|
199
|
+
throw new Error(`WAL write failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
193
200
|
} finally {
|
|
194
201
|
// biome-ignore lint/style/noNonNullAssertion: Synchronous
|
|
195
202
|
releaseLock!();
|
|
@@ -284,7 +291,7 @@ export class WriteAheadLog {
|
|
|
284
291
|
operation: "wal-remove",
|
|
285
292
|
subsystem: "wal",
|
|
286
293
|
});
|
|
287
|
-
throw new Error(`WAL remove failed: ${err}
|
|
294
|
+
throw new Error(`WAL remove failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
288
295
|
} finally {
|
|
289
296
|
// biome-ignore lint/style/noNonNullAssertion: Synchronous
|
|
290
297
|
releaseLock!();
|
|
@@ -300,7 +307,7 @@ export class WriteAheadLog {
|
|
|
300
307
|
operation: "wal-clear",
|
|
301
308
|
subsystem: "wal",
|
|
302
309
|
});
|
|
303
|
-
throw new Error(`WAL clear failed: ${err}
|
|
310
|
+
throw new Error(`WAL clear failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
304
311
|
}
|
|
305
312
|
}
|
|
306
313
|
|