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,652 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { appendFile, copyFile, cp, mkdir, readdir, readFile, rm, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { logThought } from '../utils/logger.js';
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
const DEFAULT_RETENTION_LIMIT = 5;
|
|
8
|
+
const DEFAULT_HEALTH_URL = 'http://127.0.0.1:3100/health';
|
|
9
|
+
const CRITICAL_ASSETS = [
|
|
10
|
+
{ key: 'runtime-db', relativePath: path.join('memory', 'twinbot.db'), kind: 'file' },
|
|
11
|
+
{ key: 'identity', relativePath: 'identity', kind: 'directory' },
|
|
12
|
+
{ key: 'mcp-config', relativePath: 'mcp-servers.json', kind: 'file' },
|
|
13
|
+
{ key: 'package-config', relativePath: 'package.json', kind: 'file' },
|
|
14
|
+
{ key: 'twinbot-config', relativePath: 'twinbot.json', kind: 'file' },
|
|
15
|
+
{ key: 'skill-catalog', relativePath: 'skill-packages.json', kind: 'file' },
|
|
16
|
+
{ key: 'skill-lock', relativePath: 'skill-packages.lock.json', kind: 'file' },
|
|
17
|
+
{ key: 'policy-profiles', relativePath: path.join('memory', 'policy-profiles.json'), kind: 'file' },
|
|
18
|
+
];
|
|
19
|
+
function nowIso(now) {
|
|
20
|
+
return now().toISOString();
|
|
21
|
+
}
|
|
22
|
+
function compactTimestamp(now) {
|
|
23
|
+
return now().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
24
|
+
}
|
|
25
|
+
function summarizeOutput(output) {
|
|
26
|
+
const trimmed = output.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
return 'No command output captured.';
|
|
29
|
+
}
|
|
30
|
+
const lines = trimmed.split('\n').slice(-5);
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
33
|
+
function isObjectRecord(value) {
|
|
34
|
+
return typeof value === 'object' && value !== null;
|
|
35
|
+
}
|
|
36
|
+
function isExecError(error) {
|
|
37
|
+
return error instanceof Error;
|
|
38
|
+
}
|
|
39
|
+
async function pathExists(targetPath) {
|
|
40
|
+
try {
|
|
41
|
+
await stat(targetPath);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function defaultCommandRunner(command, cwd) {
|
|
49
|
+
const startedAt = Date.now();
|
|
50
|
+
try {
|
|
51
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
52
|
+
cwd,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
exitCode: 0,
|
|
60
|
+
output,
|
|
61
|
+
durationMs: Date.now() - startedAt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!isExecError(error)) {
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
const output = [error.stdout, error.stderr, error.message]
|
|
69
|
+
.filter((part) => typeof part === 'string' && part.length > 0)
|
|
70
|
+
.join('\n')
|
|
71
|
+
.trim();
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
exitCode: typeof error.code === 'number' ? error.code : 1,
|
|
75
|
+
output,
|
|
76
|
+
durationMs: Date.now() - startedAt,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function defaultHealthProbe(url) {
|
|
81
|
+
let responseStatus;
|
|
82
|
+
let payloadStatus;
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(url, {
|
|
85
|
+
method: 'GET',
|
|
86
|
+
headers: {
|
|
87
|
+
accept: 'application/json',
|
|
88
|
+
},
|
|
89
|
+
signal: AbortSignal.timeout(5_000),
|
|
90
|
+
});
|
|
91
|
+
responseStatus = response.status;
|
|
92
|
+
const raw = await response.text();
|
|
93
|
+
if (raw.trim()) {
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (isObjectRecord(parsed) && isObjectRecord(parsed.data) && typeof parsed.data.status === 'string') {
|
|
96
|
+
payloadStatus = parsed.data.status;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
detail: `Health endpoint returned HTTP ${response.status}.`,
|
|
103
|
+
statusCode: responseStatus,
|
|
104
|
+
payloadStatus,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (payloadStatus !== 'ok' && payloadStatus !== 'degraded') {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
detail: 'Health endpoint responded without a valid system status.',
|
|
111
|
+
statusCode: responseStatus,
|
|
112
|
+
payloadStatus,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
detail: `Health endpoint reachable with status '${payloadStatus}'.`,
|
|
118
|
+
statusCode: responseStatus,
|
|
119
|
+
payloadStatus,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
detail: `Health endpoint probe failed: ${detail}`,
|
|
127
|
+
statusCode: responseStatus,
|
|
128
|
+
payloadStatus,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export class ReleasePipelineService {
|
|
133
|
+
#workspaceRoot;
|
|
134
|
+
#releaseRootDir;
|
|
135
|
+
#snapshotsDir;
|
|
136
|
+
#manifestsDir;
|
|
137
|
+
#retentionLimit;
|
|
138
|
+
#commandRunner;
|
|
139
|
+
#healthProbe;
|
|
140
|
+
#now;
|
|
141
|
+
constructor(options = {}) {
|
|
142
|
+
this.#workspaceRoot = options.workspaceRoot ?? process.cwd();
|
|
143
|
+
this.#releaseRootDir = options.releaseRootDir ?? path.join(this.#workspaceRoot, 'memory', 'release-pipeline');
|
|
144
|
+
this.#snapshotsDir = path.join(this.#releaseRootDir, 'snapshots');
|
|
145
|
+
this.#manifestsDir = path.join(this.#releaseRootDir, 'manifests');
|
|
146
|
+
this.#retentionLimit = Math.max(1, options.retentionLimit ?? DEFAULT_RETENTION_LIMIT);
|
|
147
|
+
this.#commandRunner = options.commandRunner ?? defaultCommandRunner;
|
|
148
|
+
this.#healthProbe = options.healthProbe ?? defaultHealthProbe;
|
|
149
|
+
this.#now = options.now ?? (() => new Date());
|
|
150
|
+
}
|
|
151
|
+
async runPreflight(options = {}) {
|
|
152
|
+
const healthUrl = options.healthUrl ?? DEFAULT_HEALTH_URL;
|
|
153
|
+
const checks = [];
|
|
154
|
+
checks.push(await this.#runCommandCheck('build', 'npm run build', 'Build compilation'));
|
|
155
|
+
checks.push(await this.#runCommandCheck('tests', 'npm run test', 'Test suite'));
|
|
156
|
+
checks.push(await this.#runHealthCheck(healthUrl, 'api-health'));
|
|
157
|
+
checks.push(await this.#runInterfaceReadinessCheck());
|
|
158
|
+
const failedChecks = checks.filter((check) => check.status === 'failed');
|
|
159
|
+
return {
|
|
160
|
+
passed: failedChecks.length === 0,
|
|
161
|
+
checks,
|
|
162
|
+
failedChecks,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async prepareRelease(options = {}) {
|
|
166
|
+
await this.#ensureReleaseDirectories();
|
|
167
|
+
const releaseId = options.releaseId ?? `release_${compactTimestamp(this.#now)}`;
|
|
168
|
+
const preflight = await this.runPreflight({ healthUrl: options.healthUrl });
|
|
169
|
+
const appVersion = await this.#resolveAppVersion();
|
|
170
|
+
const gitCommit = await this.#resolveGitCommit();
|
|
171
|
+
const diagnostics = preflight.failedChecks.map((check) => `${check.id}: ${check.detail}`);
|
|
172
|
+
let snapshot;
|
|
173
|
+
if (preflight.passed) {
|
|
174
|
+
snapshot = await this.createSnapshot({
|
|
175
|
+
releaseId,
|
|
176
|
+
retentionLimit: options.retentionLimit ?? this.#retentionLimit,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const manifestPath = path.join(this.#manifestsDir, `${releaseId}.json`);
|
|
180
|
+
const manifest = {
|
|
181
|
+
manifestVersion: 1,
|
|
182
|
+
releaseId,
|
|
183
|
+
generatedAt: nowIso(this.#now),
|
|
184
|
+
appVersion,
|
|
185
|
+
gitCommit,
|
|
186
|
+
status: preflight.passed ? 'ready' : 'blocked',
|
|
187
|
+
preflight,
|
|
188
|
+
snapshot: snapshot
|
|
189
|
+
? {
|
|
190
|
+
snapshotId: snapshot.snapshotId,
|
|
191
|
+
metadataPath: snapshot.metadataPath,
|
|
192
|
+
}
|
|
193
|
+
: undefined,
|
|
194
|
+
artifacts: await this.#collectArtifacts(snapshot),
|
|
195
|
+
diagnostics,
|
|
196
|
+
manifestPath,
|
|
197
|
+
};
|
|
198
|
+
await this.#writeJson(manifestPath, manifest);
|
|
199
|
+
await this.#writeJson(path.join(this.#manifestsDir, 'latest.json'), {
|
|
200
|
+
releaseId: manifest.releaseId,
|
|
201
|
+
manifestPath,
|
|
202
|
+
generatedAt: manifest.generatedAt,
|
|
203
|
+
status: manifest.status,
|
|
204
|
+
});
|
|
205
|
+
await logThought(`[ReleasePipeline] Manifest ${manifest.releaseId} generated with status ${manifest.status}.`);
|
|
206
|
+
return manifest;
|
|
207
|
+
}
|
|
208
|
+
async createSnapshot(input) {
|
|
209
|
+
await this.#ensureReleaseDirectories();
|
|
210
|
+
const retentionLimit = Math.max(1, input.retentionLimit ?? this.#retentionLimit);
|
|
211
|
+
const snapshotId = `${input.releaseId}_snapshot_${compactTimestamp(this.#now)}`;
|
|
212
|
+
const snapshotDir = path.join(this.#snapshotsDir, snapshotId);
|
|
213
|
+
const snapshotAssetsRoot = path.join(snapshotDir, 'assets');
|
|
214
|
+
await mkdir(snapshotAssetsRoot, { recursive: true });
|
|
215
|
+
const assets = [];
|
|
216
|
+
for (const asset of CRITICAL_ASSETS) {
|
|
217
|
+
const sourcePath = path.join(this.#workspaceRoot, asset.relativePath);
|
|
218
|
+
const snapshotPath = path.join(snapshotAssetsRoot, asset.relativePath);
|
|
219
|
+
const exists = await pathExists(sourcePath);
|
|
220
|
+
if (exists) {
|
|
221
|
+
await mkdir(path.dirname(snapshotPath), { recursive: true });
|
|
222
|
+
if (asset.kind === 'directory') {
|
|
223
|
+
await cp(sourcePath, snapshotPath, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
await copyFile(sourcePath, snapshotPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
assets.push({
|
|
230
|
+
key: asset.key,
|
|
231
|
+
relativePath: asset.relativePath,
|
|
232
|
+
kind: asset.kind,
|
|
233
|
+
exists,
|
|
234
|
+
sourcePath,
|
|
235
|
+
snapshotPath,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const metadataPath = path.join(snapshotDir, 'metadata.json');
|
|
239
|
+
const metadata = {
|
|
240
|
+
snapshotId,
|
|
241
|
+
releaseId: input.releaseId,
|
|
242
|
+
createdAt: nowIso(this.#now),
|
|
243
|
+
retentionLimit,
|
|
244
|
+
assets,
|
|
245
|
+
metadataPath,
|
|
246
|
+
};
|
|
247
|
+
await this.#writeJson(metadataPath, metadata);
|
|
248
|
+
await this.#pruneSnapshots(retentionLimit);
|
|
249
|
+
await logThought(`[ReleasePipeline] Snapshot ${snapshotId} captured with ${assets.length} critical asset(s).`);
|
|
250
|
+
return metadata;
|
|
251
|
+
}
|
|
252
|
+
async rollback(options = {}) {
|
|
253
|
+
await this.#ensureReleaseDirectories();
|
|
254
|
+
const startedAt = nowIso(this.#now);
|
|
255
|
+
const snapshot = await this.#resolveSnapshotForRollback(options.snapshotId);
|
|
256
|
+
const statePath = path.join(this.#releaseRootDir, 'rollback-state.json');
|
|
257
|
+
const previousState = await this.#readJson(statePath);
|
|
258
|
+
if (previousState &&
|
|
259
|
+
previousState.snapshotId === snapshot.snapshotId &&
|
|
260
|
+
previousState.status === 'success') {
|
|
261
|
+
const healthCheck = await this.#runHealthCheck(options.healthUrl ?? DEFAULT_HEALTH_URL, 'api-health');
|
|
262
|
+
const noopResult = {
|
|
263
|
+
status: healthCheck.status === 'passed' ? 'noop' : 'failed',
|
|
264
|
+
snapshotId: snapshot.snapshotId,
|
|
265
|
+
startedAt,
|
|
266
|
+
completedAt: nowIso(this.#now),
|
|
267
|
+
restoredAssets: [],
|
|
268
|
+
skippedAssets: [],
|
|
269
|
+
healthCheck,
|
|
270
|
+
diagnostics: healthCheck.status === 'passed'
|
|
271
|
+
? [`Snapshot ${snapshot.snapshotId} was already restored; no changes applied.`]
|
|
272
|
+
: [
|
|
273
|
+
`Snapshot ${snapshot.snapshotId} was previously restored, but post-rollback health verification failed.`,
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
await this.#appendRollbackAudit(noopResult);
|
|
277
|
+
return noopResult;
|
|
278
|
+
}
|
|
279
|
+
await this.#writeJson(statePath, {
|
|
280
|
+
snapshotId: snapshot.snapshotId,
|
|
281
|
+
status: 'in_progress',
|
|
282
|
+
updatedAt: nowIso(this.#now),
|
|
283
|
+
detail: 'Rollback started.',
|
|
284
|
+
});
|
|
285
|
+
const restoredAssets = [];
|
|
286
|
+
const skippedAssets = [];
|
|
287
|
+
try {
|
|
288
|
+
for (const asset of snapshot.assets) {
|
|
289
|
+
if (!asset.exists) {
|
|
290
|
+
skippedAssets.push(asset.relativePath);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const sourceExists = await pathExists(asset.snapshotPath);
|
|
294
|
+
if (!sourceExists) {
|
|
295
|
+
throw new Error(`Snapshot asset is missing: ${asset.snapshotPath}`);
|
|
296
|
+
}
|
|
297
|
+
await mkdir(path.dirname(asset.sourcePath), { recursive: true });
|
|
298
|
+
if (asset.kind === 'directory') {
|
|
299
|
+
await rm(asset.sourcePath, { recursive: true, force: true });
|
|
300
|
+
await cp(asset.snapshotPath, asset.sourcePath, { recursive: true });
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
await copyFile(asset.snapshotPath, asset.sourcePath);
|
|
304
|
+
}
|
|
305
|
+
restoredAssets.push(asset.relativePath);
|
|
306
|
+
}
|
|
307
|
+
if (options.restartCommand) {
|
|
308
|
+
const restartResult = await this.#commandRunner(options.restartCommand, this.#workspaceRoot);
|
|
309
|
+
if (!restartResult.ok) {
|
|
310
|
+
throw new Error(`Restart command failed with exit code ${restartResult.exitCode}: ${summarizeOutput(restartResult.output)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const healthCheck = await this.#runHealthCheck(options.healthUrl ?? DEFAULT_HEALTH_URL, 'api-health');
|
|
314
|
+
if (healthCheck.status === 'failed') {
|
|
315
|
+
throw new Error(`Rollback restored snapshot but health verification failed: ${healthCheck.detail}`);
|
|
316
|
+
}
|
|
317
|
+
const result = {
|
|
318
|
+
status: 'restored',
|
|
319
|
+
snapshotId: snapshot.snapshotId,
|
|
320
|
+
startedAt,
|
|
321
|
+
completedAt: nowIso(this.#now),
|
|
322
|
+
restoredAssets,
|
|
323
|
+
skippedAssets,
|
|
324
|
+
healthCheck,
|
|
325
|
+
diagnostics: [`Rollback restored snapshot ${snapshot.snapshotId} successfully.`],
|
|
326
|
+
};
|
|
327
|
+
await this.#writeJson(statePath, {
|
|
328
|
+
snapshotId: snapshot.snapshotId,
|
|
329
|
+
status: 'success',
|
|
330
|
+
updatedAt: nowIso(this.#now),
|
|
331
|
+
detail: 'Rollback completed successfully.',
|
|
332
|
+
});
|
|
333
|
+
await this.#appendRollbackAudit(result);
|
|
334
|
+
await logThought(`[ReleasePipeline] Rollback completed for snapshot ${snapshot.snapshotId}.`);
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
339
|
+
const failedHealthCheck = {
|
|
340
|
+
id: 'api-health',
|
|
341
|
+
status: 'failed',
|
|
342
|
+
detail: `Rollback aborted before healthy confirmation: ${detail}`,
|
|
343
|
+
startedAt: nowIso(this.#now),
|
|
344
|
+
completedAt: nowIso(this.#now),
|
|
345
|
+
durationMs: 0,
|
|
346
|
+
};
|
|
347
|
+
const failedResult = {
|
|
348
|
+
status: 'failed',
|
|
349
|
+
snapshotId: snapshot.snapshotId,
|
|
350
|
+
startedAt,
|
|
351
|
+
completedAt: nowIso(this.#now),
|
|
352
|
+
restoredAssets,
|
|
353
|
+
skippedAssets,
|
|
354
|
+
healthCheck: failedHealthCheck,
|
|
355
|
+
diagnostics: [detail],
|
|
356
|
+
};
|
|
357
|
+
await this.#writeJson(statePath, {
|
|
358
|
+
snapshotId: snapshot.snapshotId,
|
|
359
|
+
status: 'failed',
|
|
360
|
+
updatedAt: nowIso(this.#now),
|
|
361
|
+
detail,
|
|
362
|
+
});
|
|
363
|
+
await this.#appendRollbackAudit(failedResult);
|
|
364
|
+
await logThought(`[ReleasePipeline] Rollback failed for snapshot ${snapshot.snapshotId}: ${detail}`);
|
|
365
|
+
return failedResult;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async runDrill(options = {}) {
|
|
369
|
+
const startedAt = nowIso(this.#now);
|
|
370
|
+
const drillId = `drill_${compactTimestamp(this.#now)}`;
|
|
371
|
+
const diagnostics = [];
|
|
372
|
+
diagnostics.push(`[Drill ${drillId}] Starting release rollback drill...`);
|
|
373
|
+
let preflightResult = null;
|
|
374
|
+
let rollbackResult = null;
|
|
375
|
+
const integrityCheckIssues = [];
|
|
376
|
+
try {
|
|
377
|
+
diagnostics.push('[Drill] Step 1: Running preflight checks...');
|
|
378
|
+
preflightResult = await this.runPreflight({ healthUrl: options.healthUrl });
|
|
379
|
+
if (!preflightResult.passed) {
|
|
380
|
+
diagnostics.push('[Drill] Preflight failed - this simulates a failed deploy scenario.');
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
diagnostics.push('[Drill] Preflight passed.');
|
|
384
|
+
}
|
|
385
|
+
diagnostics.push('[Drill] Step 2: Preparing a release candidate to capture snapshot...');
|
|
386
|
+
let manifest;
|
|
387
|
+
try {
|
|
388
|
+
manifest = await this.prepareRelease({
|
|
389
|
+
healthUrl: options.healthUrl,
|
|
390
|
+
releaseId: `${drillId}_rc`,
|
|
391
|
+
retentionLimit: 10,
|
|
392
|
+
});
|
|
393
|
+
diagnostics.push(`[Drill] Release prepared with snapshot: ${manifest.snapshot?.snapshotId ?? 'none'}`);
|
|
394
|
+
}
|
|
395
|
+
catch (prepError) {
|
|
396
|
+
const prepMessage = prepError instanceof Error ? prepError.message : String(prepError);
|
|
397
|
+
diagnostics.push(`[Drill] Release preparation skipped/failed: ${prepMessage}`);
|
|
398
|
+
}
|
|
399
|
+
let targetSnapshotId = options.snapshotId ?? manifest?.snapshot?.snapshotId;
|
|
400
|
+
if (!targetSnapshotId) {
|
|
401
|
+
const snapshot = await this.#resolveSnapshotForRollback(undefined);
|
|
402
|
+
targetSnapshotId = snapshot.snapshotId;
|
|
403
|
+
diagnostics.push(`[Drill] Using latest available snapshot: ${targetSnapshotId}`);
|
|
404
|
+
}
|
|
405
|
+
diagnostics.push('[Drill] Step 3: Running rollback to restore snapshot...');
|
|
406
|
+
try {
|
|
407
|
+
rollbackResult = await this.rollback({
|
|
408
|
+
snapshotId: targetSnapshotId,
|
|
409
|
+
healthUrl: options.healthUrl,
|
|
410
|
+
});
|
|
411
|
+
diagnostics.push(`[Drill] Rollback status: ${rollbackResult.status}`);
|
|
412
|
+
}
|
|
413
|
+
catch (rbError) {
|
|
414
|
+
const rbMessage = rbError instanceof Error ? rbError.message : String(rbError);
|
|
415
|
+
diagnostics.push(`[Drill] Rollback error: ${rbMessage}`);
|
|
416
|
+
}
|
|
417
|
+
diagnostics.push('[Drill] Step 4: Verifying snapshot integrity...');
|
|
418
|
+
const snapshot = await this.#resolveSnapshotForRollback(targetSnapshotId);
|
|
419
|
+
for (const asset of snapshot.assets) {
|
|
420
|
+
if (asset.exists) {
|
|
421
|
+
const sourcePath = asset.sourcePath;
|
|
422
|
+
const snapshotPath = asset.snapshotPath;
|
|
423
|
+
const sourceExists = await pathExists(sourcePath);
|
|
424
|
+
const snapshotExists = await pathExists(snapshotPath);
|
|
425
|
+
if (!sourceExists) {
|
|
426
|
+
integrityCheckIssues.push(`Asset '${asset.relativePath}' missing in workspace after rollback.`);
|
|
427
|
+
}
|
|
428
|
+
else if (!snapshotExists) {
|
|
429
|
+
integrityCheckIssues.push(`Asset '${asset.relativePath}' missing in snapshot.`);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
diagnostics.push(`[Drill] Integrity check passed for: ${asset.relativePath}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const integrityPassed = integrityCheckIssues.length === 0;
|
|
437
|
+
diagnostics.push(`[Drill] Integrity check result: ${integrityPassed ? 'PASSED' : 'FAILED'}`);
|
|
438
|
+
const drillPassed = rollbackResult !== null &&
|
|
439
|
+
(rollbackResult.status === 'restored' || rollbackResult.status === 'noop') &&
|
|
440
|
+
integrityPassed;
|
|
441
|
+
const result = {
|
|
442
|
+
status: drillPassed ? 'passed' : 'failed',
|
|
443
|
+
drillId,
|
|
444
|
+
startedAt,
|
|
445
|
+
completedAt: nowIso(this.#now),
|
|
446
|
+
simulatedFailure: !preflightResult?.passed,
|
|
447
|
+
snapshotRestored: rollbackResult?.status === 'restored',
|
|
448
|
+
preflightResult,
|
|
449
|
+
rollbackResult,
|
|
450
|
+
integrityCheck: {
|
|
451
|
+
passed: integrityPassed,
|
|
452
|
+
issues: integrityCheckIssues,
|
|
453
|
+
},
|
|
454
|
+
diagnostics,
|
|
455
|
+
};
|
|
456
|
+
await this.#appendDrillAudit(result);
|
|
457
|
+
await logThought(`[ReleasePipeline] Drill ${drillId} completed with status: ${result.status}`);
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
462
|
+
diagnostics.push(`[Drill] Drill failed with error: ${errorMessage}`);
|
|
463
|
+
const result = {
|
|
464
|
+
status: 'failed',
|
|
465
|
+
drillId,
|
|
466
|
+
startedAt,
|
|
467
|
+
completedAt: nowIso(this.#now),
|
|
468
|
+
simulatedFailure: preflightResult !== null && !preflightResult.passed,
|
|
469
|
+
snapshotRestored: rollbackResult?.status === 'restored',
|
|
470
|
+
preflightResult,
|
|
471
|
+
rollbackResult,
|
|
472
|
+
integrityCheck: {
|
|
473
|
+
passed: false,
|
|
474
|
+
issues: [...integrityCheckIssues, errorMessage],
|
|
475
|
+
},
|
|
476
|
+
diagnostics,
|
|
477
|
+
};
|
|
478
|
+
await this.#appendDrillAudit(result);
|
|
479
|
+
await logThought(`[ReleasePipeline] Drill ${drillId} failed: ${errorMessage}`);
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async #runCommandCheck(id, command, label) {
|
|
484
|
+
const started = Date.now();
|
|
485
|
+
const startedAt = new Date(started).toISOString();
|
|
486
|
+
const run = await this.#commandRunner(command, this.#workspaceRoot);
|
|
487
|
+
const completedAt = nowIso(this.#now);
|
|
488
|
+
return {
|
|
489
|
+
id,
|
|
490
|
+
status: run.ok ? 'passed' : 'failed',
|
|
491
|
+
detail: run.ok
|
|
492
|
+
? `${label} passed.`
|
|
493
|
+
: `${label} failed (exit ${run.exitCode}). ${summarizeOutput(run.output)}`,
|
|
494
|
+
command,
|
|
495
|
+
startedAt,
|
|
496
|
+
completedAt,
|
|
497
|
+
durationMs: run.durationMs,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
async #runHealthCheck(healthUrl, id) {
|
|
501
|
+
const started = Date.now();
|
|
502
|
+
const startedAt = new Date(started).toISOString();
|
|
503
|
+
const probe = await this.#healthProbe(healthUrl);
|
|
504
|
+
const completedAt = nowIso(this.#now);
|
|
505
|
+
return {
|
|
506
|
+
id,
|
|
507
|
+
status: probe.ok ? 'passed' : 'failed',
|
|
508
|
+
detail: probe.detail,
|
|
509
|
+
command: `GET ${healthUrl}`,
|
|
510
|
+
startedAt,
|
|
511
|
+
completedAt,
|
|
512
|
+
durationMs: Date.now() - started,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
async #runInterfaceReadinessCheck() {
|
|
516
|
+
const started = Date.now();
|
|
517
|
+
const startedAt = new Date(started).toISOString();
|
|
518
|
+
const required = [
|
|
519
|
+
path.join(this.#workspaceRoot, 'gui', 'package.json'),
|
|
520
|
+
path.join(this.#workspaceRoot, 'src', 'interfaces', 'dispatcher.ts'),
|
|
521
|
+
path.join(this.#workspaceRoot, 'mcp-servers.json'),
|
|
522
|
+
];
|
|
523
|
+
const missing = [];
|
|
524
|
+
for (const target of required) {
|
|
525
|
+
if (!(await pathExists(target))) {
|
|
526
|
+
missing.push(path.relative(this.#workspaceRoot, target));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
id: 'interface-readiness',
|
|
531
|
+
status: missing.length === 0 ? 'passed' : 'failed',
|
|
532
|
+
detail: missing.length === 0
|
|
533
|
+
? 'Critical interface assets are present.'
|
|
534
|
+
: `Missing critical interface assets: ${missing.join(', ')}`,
|
|
535
|
+
startedAt,
|
|
536
|
+
completedAt: nowIso(this.#now),
|
|
537
|
+
durationMs: Date.now() - started,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
async #resolveAppVersion() {
|
|
541
|
+
const packagePath = path.join(this.#workspaceRoot, 'package.json');
|
|
542
|
+
const raw = await readFile(packagePath, 'utf8');
|
|
543
|
+
const parsed = JSON.parse(raw);
|
|
544
|
+
if (!isObjectRecord(parsed) || typeof parsed.version !== 'string') {
|
|
545
|
+
throw new Error('Unable to resolve package.json version for release manifest.');
|
|
546
|
+
}
|
|
547
|
+
return parsed.version;
|
|
548
|
+
}
|
|
549
|
+
async #resolveGitCommit() {
|
|
550
|
+
const result = await this.#commandRunner('git --no-pager rev-parse HEAD', this.#workspaceRoot);
|
|
551
|
+
if (!result.ok) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
const commit = result.output.trim().split('\n').at(-1);
|
|
555
|
+
return commit && commit.length > 0 ? commit : null;
|
|
556
|
+
}
|
|
557
|
+
async #collectArtifacts(snapshot) {
|
|
558
|
+
const pointers = [];
|
|
559
|
+
const distPath = path.join(this.#workspaceRoot, 'dist');
|
|
560
|
+
pointers.push({
|
|
561
|
+
id: 'dist',
|
|
562
|
+
path: distPath,
|
|
563
|
+
exists: await pathExists(distPath),
|
|
564
|
+
});
|
|
565
|
+
const runtimeDbPath = path.join(this.#workspaceRoot, 'memory', 'twinbot.db');
|
|
566
|
+
pointers.push({
|
|
567
|
+
id: 'runtime-db',
|
|
568
|
+
path: runtimeDbPath,
|
|
569
|
+
exists: await pathExists(runtimeDbPath),
|
|
570
|
+
});
|
|
571
|
+
if (snapshot) {
|
|
572
|
+
pointers.push({
|
|
573
|
+
id: 'snapshot-metadata',
|
|
574
|
+
path: snapshot.metadataPath,
|
|
575
|
+
exists: await pathExists(snapshot.metadataPath),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return pointers;
|
|
579
|
+
}
|
|
580
|
+
async #resolveSnapshotForRollback(snapshotId) {
|
|
581
|
+
const targetSnapshotId = snapshotId ?? (await this.#latestSnapshotId());
|
|
582
|
+
if (!targetSnapshotId) {
|
|
583
|
+
throw new Error('No snapshots are available for rollback.');
|
|
584
|
+
}
|
|
585
|
+
const metadataPath = path.join(this.#snapshotsDir, targetSnapshotId, 'metadata.json');
|
|
586
|
+
const metadata = await this.#readJson(metadataPath);
|
|
587
|
+
if (!metadata) {
|
|
588
|
+
throw new Error(`Snapshot metadata not found for '${targetSnapshotId}'.`);
|
|
589
|
+
}
|
|
590
|
+
if (metadata.snapshotId !== targetSnapshotId) {
|
|
591
|
+
throw new Error(`Snapshot metadata mismatch for '${targetSnapshotId}'.`);
|
|
592
|
+
}
|
|
593
|
+
return metadata;
|
|
594
|
+
}
|
|
595
|
+
async #latestSnapshotId() {
|
|
596
|
+
if (!(await pathExists(this.#snapshotsDir))) {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
const entries = await readdir(this.#snapshotsDir, { withFileTypes: true });
|
|
600
|
+
const candidates = entries
|
|
601
|
+
.filter((entry) => entry.isDirectory())
|
|
602
|
+
.map((entry) => entry.name)
|
|
603
|
+
.sort()
|
|
604
|
+
.reverse();
|
|
605
|
+
return candidates[0];
|
|
606
|
+
}
|
|
607
|
+
async #pruneSnapshots(retentionLimit) {
|
|
608
|
+
if (!(await pathExists(this.#snapshotsDir))) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const entries = await readdir(this.#snapshotsDir, { withFileTypes: true });
|
|
612
|
+
const snapshots = entries
|
|
613
|
+
.filter((entry) => entry.isDirectory())
|
|
614
|
+
.map((entry) => entry.name)
|
|
615
|
+
.sort()
|
|
616
|
+
.reverse();
|
|
617
|
+
const stale = snapshots.slice(retentionLimit);
|
|
618
|
+
for (const snapshotId of stale) {
|
|
619
|
+
await rm(path.join(this.#snapshotsDir, snapshotId), { recursive: true, force: true });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async #appendRollbackAudit(result) {
|
|
623
|
+
const auditPath = path.join(this.#releaseRootDir, 'rollback-audit.log');
|
|
624
|
+
await mkdir(path.dirname(auditPath), { recursive: true });
|
|
625
|
+
await appendFile(auditPath, `${JSON.stringify(result)}\n`, 'utf8');
|
|
626
|
+
}
|
|
627
|
+
async #appendDrillAudit(result) {
|
|
628
|
+
const auditPath = path.join(this.#releaseRootDir, 'drill-audit.log');
|
|
629
|
+
await mkdir(path.dirname(auditPath), { recursive: true });
|
|
630
|
+
await appendFile(auditPath, `${JSON.stringify(result)}\n`, 'utf8');
|
|
631
|
+
}
|
|
632
|
+
async #ensureReleaseDirectories() {
|
|
633
|
+
await mkdir(this.#snapshotsDir, { recursive: true });
|
|
634
|
+
await mkdir(this.#manifestsDir, { recursive: true });
|
|
635
|
+
}
|
|
636
|
+
async #writeJson(filePath, value) {
|
|
637
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
638
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
639
|
+
}
|
|
640
|
+
async #readJson(filePath) {
|
|
641
|
+
try {
|
|
642
|
+
const raw = await readFile(filePath, 'utf8');
|
|
643
|
+
return JSON.parse(raw);
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
647
|
+
return undefined;
|
|
648
|
+
}
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|