twinclaw 1.0.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/README.md +66 -0
- package/bin/npm-twinclaw.js +17 -0
- package/bin/run-twinbot-cli.js +36 -0
- package/bin/twinbot.js +4 -0
- package/bin/twinclaw.js +4 -0
- package/dist/api/handlers/browser.js +160 -0
- package/dist/api/handlers/callback.js +80 -0
- package/dist/api/handlers/config-validate.js +19 -0
- package/dist/api/handlers/health.js +117 -0
- package/dist/api/handlers/local-state-backup.js +118 -0
- package/dist/api/handlers/persona-state.js +59 -0
- package/dist/api/handlers/skill-packages.js +94 -0
- package/dist/api/router.js +278 -0
- package/dist/api/runtime-event-producer.js +99 -0
- package/dist/api/shared.js +82 -0
- package/dist/api/websocket-hub.js +305 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/env-schema.js +202 -0
- package/dist/config/env-validator.js +223 -0
- package/dist/config/identity-bootstrap.js +115 -0
- package/dist/config/json-config.js +344 -0
- package/dist/config/workspace.js +186 -0
- package/dist/core/channels-cli.js +77 -0
- package/dist/core/cli.js +119 -0
- package/dist/core/context-assembly.js +33 -0
- package/dist/core/doctor.js +365 -0
- package/dist/core/gateway-cli.js +323 -0
- package/dist/core/gateway.js +416 -0
- package/dist/core/heartbeat.js +54 -0
- package/dist/core/install-cli.js +320 -0
- package/dist/core/lane-executor.js +134 -0
- package/dist/core/logs-cli.js +70 -0
- package/dist/core/onboarding.js +760 -0
- package/dist/core/pairing-cli.js +78 -0
- package/dist/core/secret-vault-cli.js +204 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +404 -0
- package/dist/interfaces/dispatcher.js +214 -0
- package/dist/interfaces/telegram_handler.js +82 -0
- package/dist/interfaces/tui-dashboard.js +53 -0
- package/dist/interfaces/whatsapp_handler.js +94 -0
- package/dist/release/cli.js +97 -0
- package/dist/release/mvp-gate-cli.js +118 -0
- package/dist/release/twinbot-config-schema.js +162 -0
- package/dist/release/twinclaw-config-schema.js +162 -0
- package/dist/services/block-chunker.js +174 -0
- package/dist/services/browser-service.js +334 -0
- package/dist/services/context-lifecycle.js +314 -0
- package/dist/services/db.js +1055 -0
- package/dist/services/delivery-tracker.js +110 -0
- package/dist/services/dm-pairing.js +245 -0
- package/dist/services/embedding-service.js +125 -0
- package/dist/services/file-watcher.js +125 -0
- package/dist/services/inbound-debounce.js +92 -0
- package/dist/services/incident-manager.js +516 -0
- package/dist/services/job-scheduler.js +176 -0
- package/dist/services/local-state-backup.js +682 -0
- package/dist/services/mcp-client-adapter.js +291 -0
- package/dist/services/mcp-server-manager.js +143 -0
- package/dist/services/model-router.js +927 -0
- package/dist/services/mvp-gate.js +845 -0
- package/dist/services/orchestration-service.js +422 -0
- package/dist/services/persona-state.js +256 -0
- package/dist/services/policy-engine.js +92 -0
- package/dist/services/proactive-notifier.js +94 -0
- package/dist/services/queue-service.js +146 -0
- package/dist/services/release-pipeline.js +652 -0
- package/dist/services/runtime-budget-governor.js +415 -0
- package/dist/services/secret-vault.js +704 -0
- package/dist/services/semantic-memory.js +249 -0
- package/dist/services/skill-package-manager.js +806 -0
- package/dist/services/skill-registry.js +122 -0
- package/dist/services/streaming-output.js +75 -0
- package/dist/services/stt-service.js +39 -0
- package/dist/services/tts-service.js +44 -0
- package/dist/skills/builtin.js +250 -0
- package/dist/skills/shell.js +87 -0
- package/dist/skills/types.js +1 -0
- package/dist/types/api.js +1 -0
- package/dist/types/context-budget.js +1 -0
- package/dist/types/doctor.js +1 -0
- package/dist/types/file-watcher.js +1 -0
- package/dist/types/incident.js +1 -0
- package/dist/types/local-state-backup.js +1 -0
- package/dist/types/mcp.js +1 -0
- package/dist/types/messaging.js +1 -0
- package/dist/types/model-routing.js +1 -0
- package/dist/types/mvp-gate.js +2 -0
- package/dist/types/orchestration.js +1 -0
- package/dist/types/persona-state.js +22 -0
- package/dist/types/policy.js +1 -0
- package/dist/types/reasoning-graph.js +1 -0
- package/dist/types/release.js +1 -0
- package/dist/types/reliability.js +1 -0
- package/dist/types/runtime-budget.js +1 -0
- package/dist/types/scheduler.js +1 -0
- package/dist/types/secret-vault.js +1 -0
- package/dist/types/skill-packages.js +1 -0
- package/dist/types/websocket.js +14 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/retry.js +61 -0
- package/dist/utils/secret-scan.js +208 -0
- package/mcp-servers.json +179 -0
- package/package.json +81 -0
- package/skill-packages.json +92 -0
- package/skill-packages.lock.json +5 -0
- package/src/skills/builtin.ts +275 -0
- package/src/skills/shell.ts +118 -0
- package/src/skills/types.ts +30 -0
- package/src/types/api.ts +252 -0
- package/src/types/blessed-contrib.d.ts +4 -0
- package/src/types/context-budget.ts +76 -0
- package/src/types/doctor.ts +29 -0
- package/src/types/file-watcher.ts +26 -0
- package/src/types/incident.ts +57 -0
- package/src/types/local-state-backup.ts +121 -0
- package/src/types/mcp.ts +106 -0
- package/src/types/messaging.ts +35 -0
- package/src/types/model-routing.ts +61 -0
- package/src/types/mvp-gate.ts +99 -0
- package/src/types/orchestration.ts +65 -0
- package/src/types/persona-state.ts +61 -0
- package/src/types/policy.ts +27 -0
- package/src/types/reasoning-graph.ts +58 -0
- package/src/types/release.ts +115 -0
- package/src/types/reliability.ts +43 -0
- package/src/types/runtime-budget.ts +85 -0
- package/src/types/scheduler.ts +47 -0
- package/src/types/secret-vault.ts +62 -0
- package/src/types/skill-packages.ts +81 -0
- package/src/types/sqlite-vec.d.ts +5 -0
- package/src/types/websocket.ts +122 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { copyFile, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { listLocalStateRestoreEvents, listLocalStateSnapshotRecords, removeLocalStateSnapshotRecords, saveLocalStateRestoreEvent, upsertLocalStateSnapshotRecord, } from './db.js';
|
|
5
|
+
import { logThought, scrubSensitiveText } from '../utils/logger.js';
|
|
6
|
+
import { getConfigValue } from '../config/config-loader.js';
|
|
7
|
+
const BACKUP_JOB_ID = 'local-state-snapshot';
|
|
8
|
+
const MANIFEST_VERSION = 1;
|
|
9
|
+
const DEFAULT_RETENTION_LIMIT = 7;
|
|
10
|
+
const DEFAULT_SNAPSHOT_CRON = '0 */6 * * *';
|
|
11
|
+
const SNAPSHOT_TARGETS = [
|
|
12
|
+
{ id: 'identity-dir', scope: 'identity', relativePath: 'identity', kind: 'directory' },
|
|
13
|
+
{ id: 'memory-dir', scope: 'memory', relativePath: 'memory', kind: 'directory' },
|
|
14
|
+
{ id: 'runtime-db', scope: 'runtime-db', relativePath: path.join('memory', 'twinbot.db'), kind: 'file' },
|
|
15
|
+
{ id: 'twinbot-config', scope: 'config', relativePath: 'twinbot.json', kind: 'file' },
|
|
16
|
+
{
|
|
17
|
+
id: 'policy-profiles',
|
|
18
|
+
scope: 'policy-profiles',
|
|
19
|
+
relativePath: path.join('memory', 'policy-profiles.json'),
|
|
20
|
+
kind: 'file',
|
|
21
|
+
},
|
|
22
|
+
{ id: 'mcp-config', scope: 'mcp-config', relativePath: 'mcp-servers.json', kind: 'file' },
|
|
23
|
+
{ id: 'skill-catalog', scope: 'skill-packages', relativePath: 'skill-packages.json', kind: 'file' },
|
|
24
|
+
{
|
|
25
|
+
id: 'skill-lock',
|
|
26
|
+
scope: 'skill-packages',
|
|
27
|
+
relativePath: 'skill-packages.lock.json',
|
|
28
|
+
kind: 'file',
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
const SNAPSHOT_TARGETS_BY_ID = new Map(SNAPSHOT_TARGETS.map((target) => [target.id, target]));
|
|
32
|
+
function nowIso(now) {
|
|
33
|
+
return now().toISOString();
|
|
34
|
+
}
|
|
35
|
+
function compactTimestamp(now) {
|
|
36
|
+
return now().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
37
|
+
}
|
|
38
|
+
function hashContent(value) {
|
|
39
|
+
return createHash('sha256').update(value).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
function safeParseJson(value, fallback) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(value);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return fallback;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function normalizeScopes(scopes) {
|
|
50
|
+
if (!scopes || scopes.length === 0) {
|
|
51
|
+
return [...new Set(SNAPSHOT_TARGETS.map((target) => target.scope))];
|
|
52
|
+
}
|
|
53
|
+
const allowed = new Set(SNAPSHOT_TARGETS.map((target) => target.scope));
|
|
54
|
+
return [...new Set(scopes)].filter((scope) => allowed.has(scope));
|
|
55
|
+
}
|
|
56
|
+
async function pathExists(targetPath) {
|
|
57
|
+
try {
|
|
58
|
+
await stat(targetPath);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function ensureParentDir(targetPath) {
|
|
66
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
async function movePath(sourcePath, destinationPath, kind) {
|
|
69
|
+
await ensureParentDir(destinationPath);
|
|
70
|
+
try {
|
|
71
|
+
await rename(sourcePath, destinationPath);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
if (kind === 'directory') {
|
|
76
|
+
await cp(sourcePath, destinationPath, { recursive: true });
|
|
77
|
+
await rm(sourcePath, { recursive: true, force: true });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
await copyFile(sourcePath, destinationPath);
|
|
81
|
+
await rm(sourcePath, { force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export class LocalStateBackupService {
|
|
85
|
+
#workspaceRoot;
|
|
86
|
+
#backupRootDir;
|
|
87
|
+
#snapshotsDir;
|
|
88
|
+
#operationsDir;
|
|
89
|
+
#retentionLimit;
|
|
90
|
+
#snapshotCronExpression;
|
|
91
|
+
#scheduler;
|
|
92
|
+
#now;
|
|
93
|
+
#beforeRestoreApplyForTest;
|
|
94
|
+
constructor(options = {}) {
|
|
95
|
+
this.#workspaceRoot = options.workspaceRoot ?? process.cwd();
|
|
96
|
+
this.#backupRootDir =
|
|
97
|
+
options.backupRootDir ??
|
|
98
|
+
path.join(this.#workspaceRoot, '.twinbot', 'state-backups');
|
|
99
|
+
this.#snapshotsDir = path.join(this.#backupRootDir, 'snapshots');
|
|
100
|
+
this.#operationsDir = path.join(this.#backupRootDir, 'operations');
|
|
101
|
+
this.#retentionLimit = Math.max(1, options.retentionLimit ?? DEFAULT_RETENTION_LIMIT);
|
|
102
|
+
this.#snapshotCronExpression =
|
|
103
|
+
options.snapshotCronExpression ?? getConfigValue('LOCAL_STATE_SNAPSHOT_CRON') ?? DEFAULT_SNAPSHOT_CRON;
|
|
104
|
+
this.#scheduler = options.scheduler;
|
|
105
|
+
this.#now = options.now ?? (() => new Date());
|
|
106
|
+
this.#beforeRestoreApplyForTest = options.beforeRestoreApplyForTest;
|
|
107
|
+
}
|
|
108
|
+
start() {
|
|
109
|
+
if (!this.#scheduler || this.#scheduler.getJob(BACKUP_JOB_ID)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.#scheduler.register({
|
|
113
|
+
id: BACKUP_JOB_ID,
|
|
114
|
+
cronExpression: this.#snapshotCronExpression,
|
|
115
|
+
description: 'Capture local-state snapshot with retention cleanup',
|
|
116
|
+
handler: async () => {
|
|
117
|
+
try {
|
|
118
|
+
await this.createSnapshot({ trigger: 'scheduled' });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = scrubSensitiveText(error instanceof Error ? error.message : String(error));
|
|
122
|
+
const failedId = `snapshot_failed_${compactTimestamp(this.#now)}`;
|
|
123
|
+
upsertLocalStateSnapshotRecord({
|
|
124
|
+
snapshotId: failedId,
|
|
125
|
+
triggerType: 'scheduled',
|
|
126
|
+
status: 'failed',
|
|
127
|
+
scopes: [],
|
|
128
|
+
entryCount: 0,
|
|
129
|
+
manifestPath: '',
|
|
130
|
+
checksum: null,
|
|
131
|
+
detail: message,
|
|
132
|
+
});
|
|
133
|
+
await logThought(`[LocalStateBackup] Scheduled snapshot failed: ${message}`);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
autoStart: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
stop() {
|
|
140
|
+
this.#scheduler?.unregister(BACKUP_JOB_ID);
|
|
141
|
+
}
|
|
142
|
+
async createSnapshot(options = {}) {
|
|
143
|
+
const trigger = options.trigger ?? 'manual';
|
|
144
|
+
const retentionLimit = Math.max(1, options.retentionLimit ?? this.#retentionLimit);
|
|
145
|
+
await this.#ensureDirectories();
|
|
146
|
+
const snapshotId = await this.#createSnapshotId();
|
|
147
|
+
const snapshotDir = this.#snapshotDir(snapshotId);
|
|
148
|
+
const stateRoot = this.#snapshotStateRoot(snapshotId);
|
|
149
|
+
await mkdir(stateRoot, { recursive: true });
|
|
150
|
+
const entries = [];
|
|
151
|
+
for (const target of SNAPSHOT_TARGETS) {
|
|
152
|
+
const sourcePath = this.#resolveWorkspacePath(target.relativePath);
|
|
153
|
+
const snapshotPath = this.#snapshotStatePath(snapshotId, target.relativePath);
|
|
154
|
+
const exists = await pathExists(sourcePath);
|
|
155
|
+
let checksum = null;
|
|
156
|
+
let byteSize = 0;
|
|
157
|
+
let fileCount = 0;
|
|
158
|
+
if (exists) {
|
|
159
|
+
await this.#copyPath(sourcePath, snapshotPath, target.kind, true);
|
|
160
|
+
const checksumResult = await this.#calculateChecksum(snapshotPath, target.kind, false);
|
|
161
|
+
checksum = checksumResult.checksum;
|
|
162
|
+
byteSize = checksumResult.byteSize;
|
|
163
|
+
fileCount = checksumResult.fileCount;
|
|
164
|
+
}
|
|
165
|
+
entries.push({
|
|
166
|
+
id: target.id,
|
|
167
|
+
scope: target.scope,
|
|
168
|
+
relativePath: target.relativePath,
|
|
169
|
+
kind: target.kind,
|
|
170
|
+
exists,
|
|
171
|
+
checksum,
|
|
172
|
+
byteSize,
|
|
173
|
+
fileCount,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const manifest = {
|
|
177
|
+
manifestVersion: MANIFEST_VERSION,
|
|
178
|
+
snapshotId,
|
|
179
|
+
trigger,
|
|
180
|
+
createdAt: nowIso(this.#now),
|
|
181
|
+
retentionLimit,
|
|
182
|
+
entries,
|
|
183
|
+
};
|
|
184
|
+
const manifestPath = path.join(snapshotDir, 'manifest.json');
|
|
185
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
186
|
+
const scopeSet = [...new Set(entries.map((entry) => entry.scope))];
|
|
187
|
+
const manifestChecksum = hashContent(JSON.stringify(manifest));
|
|
188
|
+
upsertLocalStateSnapshotRecord({
|
|
189
|
+
snapshotId,
|
|
190
|
+
triggerType: trigger,
|
|
191
|
+
status: 'ready',
|
|
192
|
+
scopes: scopeSet,
|
|
193
|
+
entryCount: entries.length,
|
|
194
|
+
manifestPath,
|
|
195
|
+
checksum: manifestChecksum,
|
|
196
|
+
detail: null,
|
|
197
|
+
createdAt: manifest.createdAt,
|
|
198
|
+
});
|
|
199
|
+
await this.#pruneSnapshots(retentionLimit);
|
|
200
|
+
await logThought(`[LocalStateBackup] Snapshot ${snapshotId} created (${entries.length} tracked state entries, trigger=${trigger}).`);
|
|
201
|
+
return manifest;
|
|
202
|
+
}
|
|
203
|
+
async validateSnapshot(snapshotId, scopes) {
|
|
204
|
+
const manifest = await this.#readManifest(snapshotId);
|
|
205
|
+
const selectedScopes = normalizeScopes(scopes);
|
|
206
|
+
const selectedEntries = manifest.entries.filter((entry) => selectedScopes.includes(entry.scope));
|
|
207
|
+
const issues = [];
|
|
208
|
+
for (const entry of selectedEntries) {
|
|
209
|
+
const snapshotPath = this.#snapshotStatePath(snapshotId, entry.relativePath);
|
|
210
|
+
const exists = await pathExists(snapshotPath);
|
|
211
|
+
if (!entry.exists) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!exists) {
|
|
215
|
+
issues.push({
|
|
216
|
+
entryId: entry.id,
|
|
217
|
+
message: `Snapshot path is missing for '${entry.relativePath}'.`,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const checksumResult = await this.#calculateChecksum(snapshotPath, entry.kind, false);
|
|
222
|
+
if (checksumResult.checksum !== entry.checksum) {
|
|
223
|
+
issues.push({
|
|
224
|
+
entryId: entry.id,
|
|
225
|
+
message: `Checksum mismatch for '${entry.relativePath}'.`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (checksumResult.fileCount !== entry.fileCount) {
|
|
229
|
+
issues.push({
|
|
230
|
+
entryId: entry.id,
|
|
231
|
+
message: `File count mismatch for '${entry.relativePath}'.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (checksumResult.byteSize !== entry.byteSize) {
|
|
235
|
+
issues.push({
|
|
236
|
+
entryId: entry.id,
|
|
237
|
+
message: `Byte-size mismatch for '${entry.relativePath}'.`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
snapshotId,
|
|
243
|
+
scopes: selectedScopes,
|
|
244
|
+
entries: selectedEntries,
|
|
245
|
+
issues,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async restoreSnapshot(options = {}) {
|
|
249
|
+
const startedAt = nowIso(this.#now);
|
|
250
|
+
const dryRun = options.dryRun ?? false;
|
|
251
|
+
const requestedScopes = normalizeScopes(options.scopes);
|
|
252
|
+
const snapshotId = options.snapshotId ?? (await this.#latestSnapshotId());
|
|
253
|
+
const operationId = randomUUID();
|
|
254
|
+
if (!snapshotId) {
|
|
255
|
+
return this.#persistRestoreResult({
|
|
256
|
+
id: operationId,
|
|
257
|
+
snapshotId: null,
|
|
258
|
+
outcome: 'failed',
|
|
259
|
+
dryRun,
|
|
260
|
+
scopes: requestedScopes,
|
|
261
|
+
restoredPaths: [],
|
|
262
|
+
skippedPaths: [],
|
|
263
|
+
validationErrors: ['No local-state snapshots are available to restore.'],
|
|
264
|
+
rollbackApplied: false,
|
|
265
|
+
detail: 'Restore aborted because no snapshots were found.',
|
|
266
|
+
startedAt,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
let validation;
|
|
270
|
+
try {
|
|
271
|
+
validation = await this.validateSnapshot(snapshotId, requestedScopes);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
const message = scrubSensitiveText(error instanceof Error ? error.message : String(error));
|
|
275
|
+
return this.#persistRestoreResult({
|
|
276
|
+
id: operationId,
|
|
277
|
+
snapshotId,
|
|
278
|
+
outcome: 'failed',
|
|
279
|
+
dryRun,
|
|
280
|
+
scopes: requestedScopes,
|
|
281
|
+
restoredPaths: [],
|
|
282
|
+
skippedPaths: [],
|
|
283
|
+
validationErrors: [message],
|
|
284
|
+
rollbackApplied: false,
|
|
285
|
+
detail: 'Restore validation failed before execution.',
|
|
286
|
+
startedAt,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (validation.entries.length === 0) {
|
|
290
|
+
return this.#persistRestoreResult({
|
|
291
|
+
id: operationId,
|
|
292
|
+
snapshotId,
|
|
293
|
+
outcome: 'failed',
|
|
294
|
+
dryRun,
|
|
295
|
+
scopes: requestedScopes,
|
|
296
|
+
restoredPaths: [],
|
|
297
|
+
skippedPaths: [],
|
|
298
|
+
validationErrors: ['No snapshot entries matched the selected restore scope.'],
|
|
299
|
+
rollbackApplied: false,
|
|
300
|
+
detail: 'Restore scope selection produced zero entries.',
|
|
301
|
+
startedAt,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const validationErrors = validation.issues.map((issue) => `${issue.entryId}: ${issue.message}`);
|
|
305
|
+
if (validationErrors.length > 0) {
|
|
306
|
+
return this.#persistRestoreResult({
|
|
307
|
+
id: operationId,
|
|
308
|
+
snapshotId,
|
|
309
|
+
outcome: 'failed',
|
|
310
|
+
dryRun,
|
|
311
|
+
scopes: validation.scopes,
|
|
312
|
+
restoredPaths: [],
|
|
313
|
+
skippedPaths: [],
|
|
314
|
+
validationErrors,
|
|
315
|
+
rollbackApplied: false,
|
|
316
|
+
detail: 'Restore blocked because snapshot integrity validation failed.',
|
|
317
|
+
startedAt,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (dryRun) {
|
|
321
|
+
const restoredPaths = validation.entries
|
|
322
|
+
.filter((entry) => entry.exists)
|
|
323
|
+
.map((entry) => entry.relativePath);
|
|
324
|
+
const skippedPaths = validation.entries
|
|
325
|
+
.filter((entry) => !entry.exists)
|
|
326
|
+
.map((entry) => entry.relativePath);
|
|
327
|
+
return this.#persistRestoreResult({
|
|
328
|
+
id: operationId,
|
|
329
|
+
snapshotId,
|
|
330
|
+
outcome: 'dry-run',
|
|
331
|
+
dryRun: true,
|
|
332
|
+
scopes: validation.scopes,
|
|
333
|
+
restoredPaths,
|
|
334
|
+
skippedPaths,
|
|
335
|
+
validationErrors: [],
|
|
336
|
+
rollbackApplied: false,
|
|
337
|
+
detail: 'Dry-run validation completed with no integrity issues.',
|
|
338
|
+
startedAt,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
await this.#ensureDirectories();
|
|
342
|
+
const operationRoot = path.join(this.#operationsDir, `${snapshotId}_${compactTimestamp(this.#now)}_${operationId}`);
|
|
343
|
+
const stagingRoot = path.join(operationRoot, 'staging');
|
|
344
|
+
const rollbackRoot = path.join(operationRoot, 'rollback');
|
|
345
|
+
await mkdir(stagingRoot, { recursive: true });
|
|
346
|
+
await mkdir(rollbackRoot, { recursive: true });
|
|
347
|
+
const restoredPaths = [];
|
|
348
|
+
const skippedPaths = [];
|
|
349
|
+
const backedUpEntries = new Map();
|
|
350
|
+
const appliedEntries = [];
|
|
351
|
+
let rollbackApplied = false;
|
|
352
|
+
try {
|
|
353
|
+
for (const entry of validation.entries) {
|
|
354
|
+
const targetPath = this.#resolveWorkspacePath(entry.relativePath);
|
|
355
|
+
const targetExists = await pathExists(targetPath);
|
|
356
|
+
const rollbackPath = path.join(rollbackRoot, entry.id);
|
|
357
|
+
backedUpEntries.set(entry.id, targetExists);
|
|
358
|
+
if (targetExists) {
|
|
359
|
+
const targetStat = await stat(targetPath);
|
|
360
|
+
const targetKind = targetStat.isDirectory() ? 'directory' : 'file';
|
|
361
|
+
await this.#copyPath(targetPath, rollbackPath, targetKind, false);
|
|
362
|
+
}
|
|
363
|
+
if (entry.exists) {
|
|
364
|
+
const snapshotPath = this.#snapshotStatePath(snapshotId, entry.relativePath);
|
|
365
|
+
const stagingPath = path.join(stagingRoot, entry.id);
|
|
366
|
+
await this.#copyPath(snapshotPath, stagingPath, entry.kind, false);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const entry of validation.entries) {
|
|
370
|
+
if (this.#beforeRestoreApplyForTest) {
|
|
371
|
+
this.#beforeRestoreApplyForTest(entry);
|
|
372
|
+
}
|
|
373
|
+
const targetPath = this.#resolveWorkspacePath(entry.relativePath);
|
|
374
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
375
|
+
if (entry.exists) {
|
|
376
|
+
const stagingPath = path.join(stagingRoot, entry.id);
|
|
377
|
+
await movePath(stagingPath, targetPath, entry.kind);
|
|
378
|
+
restoredPaths.push(entry.relativePath);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
skippedPaths.push(entry.relativePath);
|
|
382
|
+
}
|
|
383
|
+
appliedEntries.push(entry);
|
|
384
|
+
}
|
|
385
|
+
return await this.#persistRestoreResult({
|
|
386
|
+
id: operationId,
|
|
387
|
+
snapshotId,
|
|
388
|
+
outcome: 'restored',
|
|
389
|
+
dryRun: false,
|
|
390
|
+
scopes: validation.scopes,
|
|
391
|
+
restoredPaths,
|
|
392
|
+
skippedPaths,
|
|
393
|
+
validationErrors: [],
|
|
394
|
+
rollbackApplied: false,
|
|
395
|
+
detail: `Restore completed successfully for snapshot '${snapshotId}'.`,
|
|
396
|
+
startedAt,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
rollbackApplied = appliedEntries.length > 0;
|
|
401
|
+
const message = scrubSensitiveText(error instanceof Error ? error.message : String(error));
|
|
402
|
+
if (rollbackApplied) {
|
|
403
|
+
for (const entry of [...appliedEntries].reverse()) {
|
|
404
|
+
const targetPath = this.#resolveWorkspacePath(entry.relativePath);
|
|
405
|
+
const rollbackPath = path.join(rollbackRoot, entry.id);
|
|
406
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
407
|
+
if (backedUpEntries.get(entry.id) && (await pathExists(rollbackPath))) {
|
|
408
|
+
const rollbackKind = (await stat(rollbackPath)).isDirectory() ? 'directory' : 'file';
|
|
409
|
+
await movePath(rollbackPath, targetPath, rollbackKind);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return await this.#persistRestoreResult({
|
|
414
|
+
id: operationId,
|
|
415
|
+
snapshotId,
|
|
416
|
+
outcome: 'failed',
|
|
417
|
+
dryRun: false,
|
|
418
|
+
scopes: validation.scopes,
|
|
419
|
+
restoredPaths,
|
|
420
|
+
skippedPaths,
|
|
421
|
+
validationErrors: [message],
|
|
422
|
+
rollbackApplied,
|
|
423
|
+
detail: `Restore failed for snapshot '${snapshotId}'.`,
|
|
424
|
+
startedAt,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
await rm(operationRoot, { recursive: true, force: true });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async getDiagnostics(limit = 20) {
|
|
432
|
+
const snapshots = listLocalStateSnapshotRecords(limit).map((row) => this.#toSnapshotRecord(row));
|
|
433
|
+
const restoreEvents = listLocalStateRestoreEvents(limit).map((row) => this.#toRestoreEvent(row));
|
|
434
|
+
const validationFailureCount = restoreEvents.filter((event) => event.outcome === 'failed').length;
|
|
435
|
+
const lastSnapshotAt = snapshots.find((snapshot) => snapshot.status === 'ready')?.createdAt ?? null;
|
|
436
|
+
const lastRestoreAt = restoreEvents[0]?.createdAt ?? null;
|
|
437
|
+
const schedulerJob = this.#scheduler?.getJob(BACKUP_JOB_ID) ?? null;
|
|
438
|
+
const recommendations = [];
|
|
439
|
+
if (snapshots.length === 0) {
|
|
440
|
+
recommendations.push('Create a manual snapshot to establish a recovery baseline.');
|
|
441
|
+
}
|
|
442
|
+
if (validationFailureCount > 0) {
|
|
443
|
+
recommendations.push('Inspect recent restore validation errors before running another restore.');
|
|
444
|
+
}
|
|
445
|
+
if (schedulerJob?.lastError) {
|
|
446
|
+
recommendations.push('Resolve scheduler job errors to restore automated snapshot health.');
|
|
447
|
+
}
|
|
448
|
+
if (recommendations.length === 0) {
|
|
449
|
+
recommendations.push('Backup and restore system is healthy.');
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
status: snapshots.length === 0 || validationFailureCount > 0 || Boolean(schedulerJob?.lastError)
|
|
453
|
+
? 'degraded'
|
|
454
|
+
: 'ok',
|
|
455
|
+
scheduler: {
|
|
456
|
+
enabled: Boolean(this.#scheduler),
|
|
457
|
+
jobId: BACKUP_JOB_ID,
|
|
458
|
+
job: schedulerJob,
|
|
459
|
+
cronExpression: this.#snapshotCronExpression,
|
|
460
|
+
},
|
|
461
|
+
lastSnapshotAt,
|
|
462
|
+
lastRestoreAt,
|
|
463
|
+
validationFailureCount,
|
|
464
|
+
snapshots,
|
|
465
|
+
restoreEvents,
|
|
466
|
+
recommendations,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
async #persistRestoreResult(input) {
|
|
470
|
+
const completedAt = nowIso(this.#now);
|
|
471
|
+
saveLocalStateRestoreEvent({
|
|
472
|
+
id: input.id,
|
|
473
|
+
snapshotId: input.snapshotId,
|
|
474
|
+
outcome: input.outcome,
|
|
475
|
+
dryRun: input.dryRun,
|
|
476
|
+
scopes: input.scopes,
|
|
477
|
+
restoredPaths: input.restoredPaths,
|
|
478
|
+
skippedPaths: input.skippedPaths,
|
|
479
|
+
validationErrors: input.validationErrors,
|
|
480
|
+
rollbackApplied: input.rollbackApplied,
|
|
481
|
+
detail: input.detail,
|
|
482
|
+
createdAt: completedAt,
|
|
483
|
+
});
|
|
484
|
+
const message = input.detail
|
|
485
|
+
? `${input.detail} restored=${input.restoredPaths.length} skipped=${input.skippedPaths.length}`
|
|
486
|
+
: `Restore outcome=${input.outcome}.`;
|
|
487
|
+
await logThought(`[LocalStateBackup] ${message}`);
|
|
488
|
+
return {
|
|
489
|
+
status: input.outcome,
|
|
490
|
+
snapshotId: input.snapshotId ?? 'unresolved',
|
|
491
|
+
dryRun: input.dryRun,
|
|
492
|
+
scopes: input.scopes,
|
|
493
|
+
restoredPaths: input.restoredPaths,
|
|
494
|
+
skippedPaths: input.skippedPaths,
|
|
495
|
+
validationErrors: input.validationErrors,
|
|
496
|
+
rollbackApplied: input.rollbackApplied,
|
|
497
|
+
startedAt: input.startedAt,
|
|
498
|
+
completedAt,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
#toSnapshotRecord(row) {
|
|
502
|
+
return {
|
|
503
|
+
snapshotId: row.snapshot_id,
|
|
504
|
+
trigger: row.trigger_type,
|
|
505
|
+
status: row.status,
|
|
506
|
+
scopes: safeParseJson(row.scopes_json, []),
|
|
507
|
+
entryCount: row.entry_count,
|
|
508
|
+
manifestPath: row.manifest_path,
|
|
509
|
+
checksum: row.checksum,
|
|
510
|
+
detail: row.detail,
|
|
511
|
+
createdAt: row.created_at,
|
|
512
|
+
updatedAt: row.updated_at,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
#toRestoreEvent(row) {
|
|
516
|
+
return {
|
|
517
|
+
id: row.id,
|
|
518
|
+
snapshotId: row.snapshot_id,
|
|
519
|
+
outcome: row.outcome,
|
|
520
|
+
dryRun: row.dry_run === 1,
|
|
521
|
+
scopes: safeParseJson(row.scopes_json, []),
|
|
522
|
+
restoredPaths: safeParseJson(row.restored_paths_json, []),
|
|
523
|
+
skippedPaths: safeParseJson(row.skipped_paths_json, []),
|
|
524
|
+
validationErrors: safeParseJson(row.validation_errors_json, []),
|
|
525
|
+
rollbackApplied: row.rollback_applied === 1,
|
|
526
|
+
detail: row.detail,
|
|
527
|
+
createdAt: row.created_at,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async #latestSnapshotId() {
|
|
531
|
+
const records = listLocalStateSnapshotRecords(1).filter((row) => row.status === 'ready');
|
|
532
|
+
const fromRecords = records[0]?.snapshot_id;
|
|
533
|
+
if (fromRecords) {
|
|
534
|
+
return fromRecords;
|
|
535
|
+
}
|
|
536
|
+
if (!(await pathExists(this.#snapshotsDir))) {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
const entries = await readdir(this.#snapshotsDir, { withFileTypes: true });
|
|
540
|
+
return entries
|
|
541
|
+
.filter((entry) => entry.isDirectory())
|
|
542
|
+
.map((entry) => entry.name)
|
|
543
|
+
.sort()
|
|
544
|
+
.reverse()[0];
|
|
545
|
+
}
|
|
546
|
+
async #readManifest(snapshotId) {
|
|
547
|
+
const manifestPath = path.join(this.#snapshotDir(snapshotId), 'manifest.json');
|
|
548
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
549
|
+
const parsed = JSON.parse(raw);
|
|
550
|
+
if (typeof parsed !== 'object' ||
|
|
551
|
+
parsed === null ||
|
|
552
|
+
parsed.manifestVersion !== MANIFEST_VERSION ||
|
|
553
|
+
!Array.isArray(parsed.entries)) {
|
|
554
|
+
throw new Error(`Snapshot '${snapshotId}' has an invalid manifest.`);
|
|
555
|
+
}
|
|
556
|
+
for (const entry of parsed.entries) {
|
|
557
|
+
if (!entry ||
|
|
558
|
+
typeof entry.id !== 'string' ||
|
|
559
|
+
typeof entry.scope !== 'string' ||
|
|
560
|
+
typeof entry.relativePath !== 'string' ||
|
|
561
|
+
typeof entry.kind !== 'string') {
|
|
562
|
+
throw new Error(`Snapshot '${snapshotId}' contains invalid entry metadata.`);
|
|
563
|
+
}
|
|
564
|
+
const target = SNAPSHOT_TARGETS_BY_ID.get(entry.id);
|
|
565
|
+
if (!target) {
|
|
566
|
+
throw new Error(`Snapshot '${snapshotId}' contains unsupported entry '${entry.id}'.`);
|
|
567
|
+
}
|
|
568
|
+
if (entry.scope !== target.scope ||
|
|
569
|
+
entry.relativePath !== target.relativePath ||
|
|
570
|
+
entry.kind !== target.kind) {
|
|
571
|
+
throw new Error(`Snapshot '${snapshotId}' has mismatched metadata for entry '${entry.id}'.`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return parsed;
|
|
575
|
+
}
|
|
576
|
+
async #pruneSnapshots(retentionLimit) {
|
|
577
|
+
if (!(await pathExists(this.#snapshotsDir))) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const entries = await readdir(this.#snapshotsDir, { withFileTypes: true });
|
|
581
|
+
const snapshotIds = entries
|
|
582
|
+
.filter((entry) => entry.isDirectory())
|
|
583
|
+
.map((entry) => entry.name)
|
|
584
|
+
.sort()
|
|
585
|
+
.reverse();
|
|
586
|
+
const stale = snapshotIds.slice(retentionLimit);
|
|
587
|
+
for (const snapshotId of stale) {
|
|
588
|
+
await rm(this.#snapshotDir(snapshotId), { recursive: true, force: true });
|
|
589
|
+
}
|
|
590
|
+
removeLocalStateSnapshotRecords(stale);
|
|
591
|
+
}
|
|
592
|
+
async #createSnapshotId() {
|
|
593
|
+
const base = `snapshot_${compactTimestamp(this.#now)}`;
|
|
594
|
+
let candidate = base;
|
|
595
|
+
let suffix = 1;
|
|
596
|
+
while (await pathExists(this.#snapshotDir(candidate))) {
|
|
597
|
+
candidate = `${base}_${String(suffix).padStart(2, '0')}`;
|
|
598
|
+
suffix += 1;
|
|
599
|
+
}
|
|
600
|
+
return candidate;
|
|
601
|
+
}
|
|
602
|
+
async #copyPath(sourcePath, destinationPath, kind, applySourceFilter) {
|
|
603
|
+
await ensureParentDir(destinationPath);
|
|
604
|
+
if (kind === 'directory') {
|
|
605
|
+
await cp(sourcePath, destinationPath, {
|
|
606
|
+
recursive: true,
|
|
607
|
+
filter: (source) => (applySourceFilter ? this.#shouldIncludeSource(source) : true),
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
await copyFile(sourcePath, destinationPath);
|
|
612
|
+
}
|
|
613
|
+
async #calculateChecksum(targetPath, kind, applySourceFilter) {
|
|
614
|
+
if (kind === 'file') {
|
|
615
|
+
const buffer = await readFile(targetPath);
|
|
616
|
+
return {
|
|
617
|
+
checksum: createHash('sha256').update(buffer).digest('hex'),
|
|
618
|
+
byteSize: buffer.byteLength,
|
|
619
|
+
fileCount: 1,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const files = await this.#listDirectoryFiles(targetPath, targetPath, applySourceFilter);
|
|
623
|
+
let totalBytes = 0;
|
|
624
|
+
const digest = createHash('sha256');
|
|
625
|
+
for (const relativeFile of files) {
|
|
626
|
+
const absoluteFile = path.join(targetPath, relativeFile);
|
|
627
|
+
const buffer = await readFile(absoluteFile);
|
|
628
|
+
totalBytes += buffer.byteLength;
|
|
629
|
+
const fileHash = createHash('sha256').update(buffer).digest('hex');
|
|
630
|
+
digest.update(`${relativeFile}:${fileHash}:${buffer.byteLength}\n`);
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
checksum: digest.digest('hex'),
|
|
634
|
+
byteSize: totalBytes,
|
|
635
|
+
fileCount: files.length,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
async #listDirectoryFiles(directoryPath, rootPath, applySourceFilter) {
|
|
639
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
640
|
+
const files = [];
|
|
641
|
+
for (const entry of entries) {
|
|
642
|
+
const absolutePath = path.join(directoryPath, entry.name);
|
|
643
|
+
if (applySourceFilter && !this.#shouldIncludeSource(absolutePath)) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (entry.isDirectory()) {
|
|
647
|
+
const nested = await this.#listDirectoryFiles(absolutePath, rootPath, applySourceFilter);
|
|
648
|
+
files.push(...nested);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (!entry.isFile()) {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
files.push(path.relative(rootPath, absolutePath).split(path.sep).join('/'));
|
|
655
|
+
}
|
|
656
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
657
|
+
}
|
|
658
|
+
#shouldIncludeSource(sourcePath) {
|
|
659
|
+
const absolute = path.resolve(sourcePath);
|
|
660
|
+
if (absolute === this.#backupRootDir) {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
return !absolute.startsWith(`${this.#backupRootDir}${path.sep}`);
|
|
664
|
+
}
|
|
665
|
+
async #ensureDirectories() {
|
|
666
|
+
await mkdir(this.#snapshotsDir, { recursive: true });
|
|
667
|
+
await mkdir(this.#operationsDir, { recursive: true });
|
|
668
|
+
}
|
|
669
|
+
#snapshotDir(snapshotId) {
|
|
670
|
+
return path.join(this.#snapshotsDir, snapshotId);
|
|
671
|
+
}
|
|
672
|
+
#snapshotStateRoot(snapshotId) {
|
|
673
|
+
return path.join(this.#snapshotDir(snapshotId), 'state');
|
|
674
|
+
}
|
|
675
|
+
#snapshotStatePath(snapshotId, relativePath) {
|
|
676
|
+
return path.join(this.#snapshotStateRoot(snapshotId), relativePath);
|
|
677
|
+
}
|
|
678
|
+
#resolveWorkspacePath(relativePath) {
|
|
679
|
+
return path.join(this.#workspaceRoot, relativePath);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
export { BACKUP_JOB_ID };
|