@aiwg/cli 2026.8.26 → 2026.8.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/aiwg.mjs +6 -0
- package/dist/src/a2a/client.js +4 -1
- package/dist/src/a2a/codecs.js +5 -2
- package/dist/src/a2a/protocol.js +12 -1
- package/dist/src/activity-log/cli.js +4 -1
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/backends/graphology-backend.js +11 -2
- package/dist/src/artifacts/backends/json-backend.js +12 -2
- package/dist/src/artifacts/backends/sqlite-backend.js +20 -7
- package/dist/src/artifacts/fortemi-core-sync.js +37 -0
- package/dist/src/artifacts/graph-backend.js +16 -0
- package/dist/src/audit/operator-decision.js +9 -25
- package/dist/src/features/catalog.js +3 -2
- package/dist/src/governance/boundary.js +354 -0
- package/dist/src/governance/classification.js +191 -0
- package/dist/src/governance/index.js +5 -0
- package/dist/src/governance/redaction.js +324 -0
- package/dist/src/governance/retention.js +274 -0
- package/dist/src/jobs/executor.js +2 -3
- package/dist/src/ops/cli.js +95 -0
- package/dist/src/serve/dispatch-router.js +1 -1
- package/dist/src/sessions/repository.js +8 -6
- package/dist/src/storage/backends/postgres.js +6 -1
- package/dist/src/storage/index.js +1 -1
- package/dist/src/storage/migration-protocol.js +228 -50
- package/dist/src/storage/qualification.js +39 -5
- package/package.json +1 -1
package/dist/src/ops/cli.js
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* @implements #544
|
|
12
12
|
*/
|
|
13
13
|
import { OpsRegistry } from './registry.js';
|
|
14
|
+
import { appendFile, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, resolve } from 'node:path';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { parse as parseYaml } from 'yaml';
|
|
18
|
+
import { prepareEvidenceForSink, } from '../governance/index.js';
|
|
14
19
|
/**
|
|
15
20
|
* Main CLI entry point for `aiwg ops <subcommand> [args]`
|
|
16
21
|
*/
|
|
@@ -53,6 +58,9 @@ export async function main(args) {
|
|
|
53
58
|
case 'adopt':
|
|
54
59
|
await handleAdopt(registry, subArgs);
|
|
55
60
|
break;
|
|
61
|
+
case 'evidence':
|
|
62
|
+
await handleEvidence(subArgs);
|
|
63
|
+
break;
|
|
56
64
|
default:
|
|
57
65
|
printUsage();
|
|
58
66
|
if (subcommand) {
|
|
@@ -61,6 +69,91 @@ export async function main(args) {
|
|
|
61
69
|
break;
|
|
62
70
|
}
|
|
63
71
|
}
|
|
72
|
+
function flagValue(args, name) {
|
|
73
|
+
const index = args.indexOf(name);
|
|
74
|
+
if (index === -1)
|
|
75
|
+
return undefined;
|
|
76
|
+
const value = args[index + 1];
|
|
77
|
+
if (!value || value.startsWith('--'))
|
|
78
|
+
throw new Error(`${name} requires a value`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function parseData(source, label) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(source);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
return parseYaml(source);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error(`${label} must contain valid JSON or YAML`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function readStdin(maxBytes = 16 * 1024 * 1024) {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
for await (const chunk of process.stdin) {
|
|
98
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
99
|
+
bytes += value.length;
|
|
100
|
+
if (bytes > maxBytes)
|
|
101
|
+
throw new Error('evidence input exceeds 16 MiB');
|
|
102
|
+
chunks.push(value);
|
|
103
|
+
}
|
|
104
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
105
|
+
}
|
|
106
|
+
async function readInput(args) {
|
|
107
|
+
const inputPath = flagValue(args, '--input');
|
|
108
|
+
return inputPath ? readFile(resolve(inputPath), 'utf8') : readStdin();
|
|
109
|
+
}
|
|
110
|
+
async function appendBoundaryAudit(cwd, value, configuredPath) {
|
|
111
|
+
const path = resolve(configuredPath ?? `${cwd}/.aiwg/ops/audit/governance-boundary.jsonl`);
|
|
112
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
113
|
+
await appendFile(path, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
114
|
+
await chmod(path, 0o600);
|
|
115
|
+
}
|
|
116
|
+
async function writePreparedOutput(pathValue, value) {
|
|
117
|
+
const path = resolve(pathValue);
|
|
118
|
+
const temporary = `${path}.tmp-${randomUUID()}`;
|
|
119
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
120
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
121
|
+
await rename(temporary, path);
|
|
122
|
+
await chmod(path, 0o600);
|
|
123
|
+
}
|
|
124
|
+
async function handleEvidence(args) {
|
|
125
|
+
const action = args[0];
|
|
126
|
+
if (action !== 'prepare') {
|
|
127
|
+
throw new Error('Usage: aiwg ops evidence prepare [--input <json-or-yaml>] [--policy <json-or-yaml>] [--output <path>] [--audit <path>]');
|
|
128
|
+
}
|
|
129
|
+
const envelope = parseData(await readInput(args), 'evidence input');
|
|
130
|
+
if (!envelope || typeof envelope !== 'object' || !envelope.artifact || typeof envelope.sinkId !== 'string') {
|
|
131
|
+
throw new Error('evidence input requires artifact and sinkId');
|
|
132
|
+
}
|
|
133
|
+
const policyPath = flagValue(args, '--policy');
|
|
134
|
+
const policy = policyPath
|
|
135
|
+
? parseData(await readFile(resolve(policyPath), 'utf8'), 'governance policy')
|
|
136
|
+
: undefined;
|
|
137
|
+
const result = prepareEvidenceForSink({
|
|
138
|
+
...envelope,
|
|
139
|
+
policy,
|
|
140
|
+
});
|
|
141
|
+
await appendBoundaryAudit(process.cwd(), result.audit, flagValue(args, '--audit'));
|
|
142
|
+
const response = result.allowed && result.prepared
|
|
143
|
+
? { schemaVersion: 'ops-prepared-evidence.aiwg.io/v1', prepared: result.prepared, audit: result.audit, auditRecorded: true }
|
|
144
|
+
: { schemaVersion: 'ops-prepared-evidence.aiwg.io/v1', denied: true, audit: result.audit, auditRecorded: true };
|
|
145
|
+
const outputPath = flagValue(args, '--output');
|
|
146
|
+
if (outputPath) {
|
|
147
|
+
if (!result.allowed)
|
|
148
|
+
throw new Error(`evidence publication denied: ${result.audit.reasonCodes.join(', ')}`);
|
|
149
|
+
await writePreparedOutput(outputPath, response);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
153
|
+
}
|
|
154
|
+
if (!result.allowed)
|
|
155
|
+
process.exitCode = 2;
|
|
156
|
+
}
|
|
64
157
|
async function handleInit(registry, args) {
|
|
65
158
|
// Parse flags
|
|
66
159
|
let silent = false;
|
|
@@ -225,6 +318,7 @@ Subcommands:
|
|
|
225
318
|
push [--workspace <n>] Push workspace repos to remote
|
|
226
319
|
discover [root...] Scan filesystem for orphaned ops-workspace clones
|
|
227
320
|
adopt <path> Register an existing local clone as a repo entry
|
|
321
|
+
evidence prepare Sanitize and policy-gate evidence before publication
|
|
228
322
|
|
|
229
323
|
Init options:
|
|
230
324
|
--silent Skip interactive prompts
|
|
@@ -251,6 +345,7 @@ Examples:
|
|
|
251
345
|
aiwg ops push --workspace personal
|
|
252
346
|
aiwg ops discover ~/projects ~/work --max-depth 4
|
|
253
347
|
aiwg ops discover --register --workspace home
|
|
348
|
+
aiwg ops evidence prepare --input request.yaml --policy .aiwg/ops/governance-policy.yaml
|
|
254
349
|
|
|
255
350
|
Discover options:
|
|
256
351
|
--max-depth <n> Max walk depth from each root (default: 3)
|
|
@@ -85,7 +85,7 @@ async function dispatchV2(executor, payload, opts) {
|
|
|
85
85
|
// discovery dependency to the compatibility path.
|
|
86
86
|
if (clientOpts.protocolPolicy === '0.3') {
|
|
87
87
|
clientOpts.selectedInterface = {
|
|
88
|
-
url: `${executor.transportEndpoints.rest.replace(/\/+$/, '')}/agents/${encodeURIComponent(a2aInstanceId)}`,
|
|
88
|
+
url: `${executor.transportEndpoints.rest.replace(/\/+$/, '')}/agents/${encodeURIComponent(a2aInstanceId)}/v1`,
|
|
89
89
|
protocolBinding: 'REST',
|
|
90
90
|
protocolVersion: '0.3',
|
|
91
91
|
preference: 0,
|
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireFeaturePackage } from '../features/runtime.js';
|
|
2
2
|
import { CandidateReviewReceiptSchema, DeletionReceiptSchema, IntelligenceCandidateSchema, PromotionDependencyDecisionSchema, PromotionReceiptSchema, SessionContractError, sha256, } from './contracts.js';
|
|
3
3
|
import { coverageFromBatchRun, } from './batch-contracts.js';
|
|
4
4
|
import { redactSessionText, sanitizeNativeExtensions } from './policy.js';
|
|
5
5
|
import { deriveSessionAnalytics, } from './analytics.js';
|
|
6
|
-
const require = createRequire(import.meta.url);
|
|
7
6
|
const POLICY_PROVIDER_MIGRATION = 'policy-provider-identity:v2';
|
|
8
7
|
const EVENT_ORIGIN_INTENT_MIGRATION = 'event-origin-intent:v1';
|
|
9
8
|
export class SessionRepository {
|
|
10
9
|
db;
|
|
11
10
|
constructor(path = ':memory:') {
|
|
11
|
+
let Database;
|
|
12
12
|
try {
|
|
13
|
-
|
|
14
|
-
this.db = new Database(path);
|
|
13
|
+
Database = requireFeaturePackage('better-sqlite3');
|
|
15
14
|
}
|
|
16
|
-
catch {
|
|
17
|
-
throw new Error('session SQLite
|
|
15
|
+
catch (cause) {
|
|
16
|
+
throw new Error('session SQLite catalog is unavailable because better-sqlite3 could not be loaded; ' +
|
|
17
|
+
'run `aiwg features install sqlite`, then retry. If the native build fails, install ' +
|
|
18
|
+
'Python 3, make, and a C/C++ compiler supported by node-gyp.', { cause });
|
|
18
19
|
}
|
|
20
|
+
this.db = new Database(path);
|
|
19
21
|
this.db.pragma('foreign_keys = ON');
|
|
20
22
|
this.db.pragma('journal_mode = WAL');
|
|
21
23
|
this.db.pragma('busy_timeout = 5000');
|
|
@@ -387,7 +387,7 @@ async function createPool(options) {
|
|
|
387
387
|
const Pool = (pg.Pool ?? defaultExport?.Pool);
|
|
388
388
|
if (!Pool)
|
|
389
389
|
throw new PostgresBackendError('AIWG_POSTGRES_DRIVER_INVALID', 'optional pg package does not export Pool');
|
|
390
|
-
|
|
390
|
+
const pool = new Pool({
|
|
391
391
|
connectionString,
|
|
392
392
|
max: bounded(options.maxConnections, DEFAULT_POOL_MAX, 1, 100, 'maxConnections'),
|
|
393
393
|
connectionTimeoutMillis: bounded(options.connectionTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'connectionTimeoutMs'),
|
|
@@ -395,6 +395,11 @@ async function createPool(options) {
|
|
|
395
395
|
application_name: options.applicationName ?? 'aiwg',
|
|
396
396
|
ssl: options.ssl === 'disable' ? false : { rejectUnauthorized: options.ssl === 'verify-full' },
|
|
397
397
|
});
|
|
398
|
+
// pg emits idle-client failures at pool scope. Registering a listener keeps
|
|
399
|
+
// server restarts from becoming uncaught process exceptions; failed clients
|
|
400
|
+
// are evicted by pg and the next backend operation reconnects normally.
|
|
401
|
+
pool.on?.('error', () => undefined);
|
|
402
|
+
return pool;
|
|
398
403
|
}
|
|
399
404
|
function isLoopbackPostgresUrl(connectionString) {
|
|
400
405
|
try {
|
|
@@ -22,7 +22,7 @@ export { ObsidianAdapter } from './backends/obsidian.js';
|
|
|
22
22
|
export { LogseqAdapter } from './backends/logseq.js';
|
|
23
23
|
export { FortemiAdapter } from './backends/fortemi.js';
|
|
24
24
|
export { STORAGE_BACKEND_CONTRACT, STORAGE_BACKEND_MATRIX, StorageCapabilityError, negotiateStorageCapabilities, } from './backend-contract.js';
|
|
25
|
-
export { STORAGE_MIGRATION_PROTOCOL, MigrationProtocolError, StorageMigrationCoordinator, approvalDigest, digestRecords, validateManifest, } from './migration-protocol.js';
|
|
25
|
+
export { STORAGE_MIGRATION_PROTOCOL, MigrationProtocolError, StorageMigrationCoordinator, approvalDigest, digestRecordChunks, digestRecords, validateManifest, } from './migration-protocol.js';
|
|
26
26
|
export { PostgresStorageBackend, PostgresBackendError, } from './backends/postgres.js';
|
|
27
27
|
export { POSTGRES_SCHEMA_VERSION, POSTGRES_SCHEMA_V1_SQL, inspectPostgresSchema, upgradePostgresSchemaV1, rollbackPostgresSchemaV1, postgresLeastPrivilegeSql, } from './backends/postgres-schema.js';
|
|
28
28
|
export { PostgrestStorageBackend, PostgrestBackendError, } from './backends/postgrest.js';
|
|
@@ -37,10 +37,28 @@ export class StorageMigrationCoordinator {
|
|
|
37
37
|
this.rollbackWindowMs = boundedInteger(options.rollbackWindowMs, 86_400_000, 1, 31_536_000_000, 'rollbackWindowMs');
|
|
38
38
|
this.now = options.now ?? (() => new Date());
|
|
39
39
|
this.random = options.random ?? Math.random;
|
|
40
|
+
if (options.mode === 'online' && !options.compareSourceRevisions) {
|
|
41
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_REVISION_ORDER_REQUIRED', 'online migration requires a source-revision comparator');
|
|
42
|
+
}
|
|
40
43
|
}
|
|
41
44
|
async preview() {
|
|
42
45
|
this.throwIfAborted();
|
|
43
|
-
const
|
|
46
|
+
const boundary = await this.options.safety.prepare(this.options.mode, this.source.identity, this.options.signal);
|
|
47
|
+
try {
|
|
48
|
+
assertBoundaryState(boundary, this.options.mode === 'offline' ? 'quiesced' : 'tracking', 'initial');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await this.options.safety.release(boundary, 'failed');
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
let snapshot;
|
|
55
|
+
try {
|
|
56
|
+
snapshot = await this.source.snapshot(this.options.signal);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
await this.options.safety.release(boundary, 'failed');
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
44
62
|
const createdAt = this.now().toISOString();
|
|
45
63
|
const sourceDigest = digestRecords(snapshot.records);
|
|
46
64
|
const manifest = {
|
|
@@ -67,57 +85,69 @@ export class StorageMigrationCoordinator {
|
|
|
67
85
|
},
|
|
68
86
|
digests: { source: sourceDigest },
|
|
69
87
|
records: [],
|
|
88
|
+
boundaries: { initial: boundary },
|
|
70
89
|
};
|
|
71
|
-
|
|
90
|
+
try {
|
|
91
|
+
await this.store.save(manifest);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
await this.options.safety.release(boundary, 'failed');
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
72
97
|
return manifest;
|
|
73
98
|
}
|
|
74
99
|
async apply(manifest) {
|
|
75
100
|
validateManifest(manifest, this.source.identity, this.destination.identity);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
manifest.state = 'copying';
|
|
81
|
-
await this.persist(manifest);
|
|
82
|
-
await this.copyRecords(snapshot.records, manifest);
|
|
83
|
-
if (manifest.mode === 'online') {
|
|
84
|
-
if (!this.source.changes) {
|
|
85
|
-
throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
|
|
101
|
+
try {
|
|
102
|
+
const snapshot = await this.source.snapshot(this.options.signal);
|
|
103
|
+
if (digestRecords(snapshot.records) !== manifest.digests.source) {
|
|
104
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SNAPSHOT_CHANGED', 'source snapshot no longer matches the preview; create a new preview');
|
|
86
105
|
}
|
|
87
|
-
manifest.state = '
|
|
106
|
+
manifest.state = 'copying';
|
|
88
107
|
await this.persist(manifest);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
this.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
manifest.checkpoint = {
|
|
96
|
-
...manifest.checkpoint,
|
|
97
|
-
...(cursor === undefined ? {} : { cursor }),
|
|
98
|
-
highWaterMark: page.highWaterMark,
|
|
99
|
-
drained: page.records.length === 0 || cursor === undefined,
|
|
100
|
-
};
|
|
108
|
+
await this.copyRecords(snapshot.records, manifest);
|
|
109
|
+
if (manifest.mode === 'online') {
|
|
110
|
+
if (!this.source.changes) {
|
|
111
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
|
|
112
|
+
}
|
|
113
|
+
manifest.state = 'replaying';
|
|
101
114
|
await this.persist(manifest);
|
|
102
|
-
|
|
103
|
-
break;
|
|
115
|
+
await this.replayChanges(manifest);
|
|
104
116
|
}
|
|
117
|
+
const finalBoundary = await this.options.safety.freeze(manifest, this.options.signal);
|
|
118
|
+
manifest.boundaries.final = finalBoundary;
|
|
119
|
+
assertBoundaryState(finalBoundary, 'quiesced', 'final');
|
|
120
|
+
await this.persist(manifest);
|
|
121
|
+
if (manifest.mode === 'online')
|
|
122
|
+
await this.replayChanges(manifest);
|
|
123
|
+
manifest.state = 'verifying';
|
|
124
|
+
await this.persist(manifest);
|
|
125
|
+
const verification = await this.verify(manifest);
|
|
126
|
+
if (!verification.valid) {
|
|
127
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', summarizeVerificationFailure(verification));
|
|
128
|
+
}
|
|
129
|
+
manifest.digests.destination = verification.destinationDigest;
|
|
130
|
+
manifest.verification = verification;
|
|
131
|
+
manifest.digests.verification = sha256(stableStringify(verification));
|
|
132
|
+
manifest.state = 'awaiting-approval';
|
|
133
|
+
manifest.updatedAt = this.now().toISOString();
|
|
134
|
+
manifest.digests.approval = approvalDigest(manifest);
|
|
135
|
+
await this.save(manifest);
|
|
136
|
+
return manifest;
|
|
105
137
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const verification = await this.verify(manifest);
|
|
109
|
-
if (!verification.valid) {
|
|
138
|
+
catch (error) {
|
|
139
|
+
const stage = manifest.state;
|
|
110
140
|
manifest.state = 'failed';
|
|
111
|
-
manifest.failure = { stage
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
manifest.failure = { stage, message: errorMessage(error) };
|
|
142
|
+
try {
|
|
143
|
+
await this.persist(manifest);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Preserve the triggering failure; the manifest store error is already observable at its source.
|
|
147
|
+
}
|
|
148
|
+
await this.options.safety.release(manifest.boundaries.final ?? manifest.boundaries.initial, 'failed');
|
|
149
|
+
throw error;
|
|
114
150
|
}
|
|
115
|
-
manifest.digests.destination = verification.destinationDigest;
|
|
116
|
-
manifest.state = 'awaiting-approval';
|
|
117
|
-
manifest.updatedAt = this.now().toISOString();
|
|
118
|
-
manifest.digests.approval = approvalDigest(manifest);
|
|
119
|
-
await this.save(manifest);
|
|
120
|
-
return manifest;
|
|
121
151
|
}
|
|
122
152
|
async verify(manifest) {
|
|
123
153
|
const [sourceRecords, destinationRecords] = await Promise.all([
|
|
@@ -131,16 +161,35 @@ export class StorageMigrationCoordinator {
|
|
|
131
161
|
const corrupt = [...sourceMap.entries()]
|
|
132
162
|
.filter(([key, record]) => {
|
|
133
163
|
const destination = destinationMap.get(key);
|
|
134
|
-
return destination !== undefined && (destination.
|
|
164
|
+
return destination !== undefined && (destination.sourceRevision !== record.sourceRevision ||
|
|
165
|
+
destination.digest !== record.digest ||
|
|
135
166
|
Boolean(destination.tombstone) !== Boolean(record.tombstone));
|
|
136
167
|
})
|
|
137
168
|
.map(([key]) => key)
|
|
138
169
|
.sort();
|
|
139
|
-
const
|
|
170
|
+
const boundaryReady = manifest.boundaries.final?.state === 'quiesced';
|
|
171
|
+
const lag = Number(!boundaryReady || (manifest.mode === 'online' && !manifest.checkpoint.drained));
|
|
140
172
|
const sourceDigest = digestRecords(sourceRecords);
|
|
141
173
|
const destinationDigest = digestRecords(destinationRecords);
|
|
174
|
+
const sourceChunks = digestRecordChunks(sourceRecords, this.batchSize);
|
|
175
|
+
const destinationChunks = digestRecordChunks(destinationRecords, this.batchSize);
|
|
176
|
+
const chunks = {
|
|
177
|
+
valid: stableStringify(sourceChunks) === stableStringify(destinationChunks),
|
|
178
|
+
source: sourceChunks,
|
|
179
|
+
destination: destinationChunks,
|
|
180
|
+
};
|
|
181
|
+
const semantic = await this.options.verifier.verify({
|
|
182
|
+
manifest,
|
|
183
|
+
source: this.source,
|
|
184
|
+
destination: this.destination,
|
|
185
|
+
sourceRecords,
|
|
186
|
+
destinationRecords,
|
|
187
|
+
signal: this.options.signal,
|
|
188
|
+
});
|
|
189
|
+
validateSemanticVerification(semantic);
|
|
142
190
|
return {
|
|
143
|
-
valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0 && lag === 0
|
|
191
|
+
valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0 && lag === 0 &&
|
|
192
|
+
sourceDigest === destinationDigest && chunks.valid && semanticIsValid(semantic),
|
|
144
193
|
sourceCount: sourceRecords.length,
|
|
145
194
|
destinationCount: destinationRecords.length,
|
|
146
195
|
sourceDigest,
|
|
@@ -149,6 +198,8 @@ export class StorageMigrationCoordinator {
|
|
|
149
198
|
unexpected,
|
|
150
199
|
corrupt,
|
|
151
200
|
lag,
|
|
201
|
+
chunks,
|
|
202
|
+
semantic,
|
|
152
203
|
};
|
|
153
204
|
}
|
|
154
205
|
async cutover(manifest, approval) {
|
|
@@ -164,14 +215,22 @@ export class StorageMigrationCoordinator {
|
|
|
164
215
|
if (!verification.valid) {
|
|
165
216
|
throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', summarizeVerificationFailure(verification));
|
|
166
217
|
}
|
|
218
|
+
if (!manifest.digests.verification || sha256(stableStringify(verification)) !== manifest.digests.verification) {
|
|
219
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_VERIFICATION_CHANGED', 'migration verification changed after approval; verify and approve a new manifest');
|
|
220
|
+
}
|
|
221
|
+
const finalBoundary = manifest.boundaries.final;
|
|
222
|
+
if (!finalBoundary) {
|
|
223
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_MISSING', 'migration has no final quiesced source boundary');
|
|
224
|
+
}
|
|
167
225
|
manifest.state = 'cutover';
|
|
168
226
|
await this.persist(manifest);
|
|
169
227
|
try {
|
|
170
|
-
await this.routing.
|
|
171
|
-
await this.
|
|
228
|
+
manifest.routingSwitch = await this.routing.switchAtomically(manifest);
|
|
229
|
+
await this.options.safety.release(finalBoundary, 'cutover');
|
|
172
230
|
}
|
|
173
231
|
catch (error) {
|
|
174
232
|
await this.routing.restoreSource(manifest);
|
|
233
|
+
await this.options.safety.release(finalBoundary, 'failed');
|
|
175
234
|
manifest.state = 'failed';
|
|
176
235
|
manifest.failure = { stage: 'cutover', message: errorMessage(error) };
|
|
177
236
|
await this.persist(manifest);
|
|
@@ -195,13 +254,47 @@ export class StorageMigrationCoordinator {
|
|
|
195
254
|
throw new MigrationProtocolError('AIWG_MIGRATION_NOT_ROLLBACKABLE', `cannot roll back state ${manifest.state}`);
|
|
196
255
|
}
|
|
197
256
|
await this.routing.restoreSource(manifest);
|
|
257
|
+
await this.options.safety.release(manifest.boundaries.final ?? manifest.boundaries.initial, 'rollback');
|
|
198
258
|
manifest.state = 'rolled-back';
|
|
199
259
|
await this.persist(manifest);
|
|
200
260
|
return manifest;
|
|
201
261
|
}
|
|
202
262
|
async copyRecords(records, manifest) {
|
|
203
263
|
const completed = new Set(manifest.records.map(receiptKey));
|
|
204
|
-
const
|
|
264
|
+
const latest = new Map();
|
|
265
|
+
for (const receipt of manifest.records)
|
|
266
|
+
latest.set(identityKey(receipt.identity), receipt);
|
|
267
|
+
const selected = new Map();
|
|
268
|
+
for (const record of records) {
|
|
269
|
+
if (completed.has(recordKey(record)))
|
|
270
|
+
continue;
|
|
271
|
+
const key = identityKey(record.identity);
|
|
272
|
+
const receipt = latest.get(key);
|
|
273
|
+
if (receipt && this.options.compareSourceRevisions) {
|
|
274
|
+
const order = compareRevisions(this.options.compareSourceRevisions, record.sourceRevision, receipt.sourceRevision);
|
|
275
|
+
if (order < 0)
|
|
276
|
+
continue;
|
|
277
|
+
if (order === 0) {
|
|
278
|
+
if (record.digest !== receipt.contentDigest)
|
|
279
|
+
throw revisionConflict(key, record.sourceRevision);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const prior = selected.get(key);
|
|
284
|
+
if (!prior) {
|
|
285
|
+
selected.set(key, record);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!this.options.compareSourceRevisions) {
|
|
289
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_DUPLICATE_IDENTITY', `snapshot contains multiple records for ${key}`);
|
|
290
|
+
}
|
|
291
|
+
const order = compareRevisions(this.options.compareSourceRevisions, record.sourceRevision, prior.sourceRevision);
|
|
292
|
+
if (order > 0)
|
|
293
|
+
selected.set(key, record);
|
|
294
|
+
else if (order === 0 && record.digest !== prior.digest)
|
|
295
|
+
throw revisionConflict(key, record.sourceRevision);
|
|
296
|
+
}
|
|
297
|
+
const pending = [...selected.values()].sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
|
|
205
298
|
const batches = chunk(pending, this.batchSize);
|
|
206
299
|
let next = 0;
|
|
207
300
|
const workers = Array.from({ length: Math.min(this.concurrency, batches.length) }, async () => {
|
|
@@ -210,7 +303,7 @@ export class StorageMigrationCoordinator {
|
|
|
210
303
|
if (index >= batches.length)
|
|
211
304
|
return;
|
|
212
305
|
const batch = batches[index];
|
|
213
|
-
const mutations = batch.map(record => mutationFor(manifest.migrationId, record));
|
|
306
|
+
const mutations = batch.map(record => mutationFor(manifest.migrationId, record, latest.get(identityKey(record.identity))?.destinationRevision));
|
|
214
307
|
const receipt = await this.retry(() => this.destination.commitBatch(mutations, this.options.signal), `batch ${index}`);
|
|
215
308
|
if (!receipt.committed || receipt.recordReceipts.length !== batch.length) {
|
|
216
309
|
throw new MigrationProtocolError('AIWG_MIGRATION_INVALID_RECEIPT', `destination returned an invalid receipt for batch ${index}`);
|
|
@@ -242,6 +335,27 @@ export class StorageMigrationCoordinator {
|
|
|
242
335
|
});
|
|
243
336
|
await Promise.all(workers);
|
|
244
337
|
}
|
|
338
|
+
async replayChanges(manifest) {
|
|
339
|
+
if (!this.source.changes) {
|
|
340
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
|
|
341
|
+
}
|
|
342
|
+
let cursor = manifest.checkpoint.cursor ?? manifest.snapshot.cursor;
|
|
343
|
+
for (;;) {
|
|
344
|
+
this.throwIfAborted();
|
|
345
|
+
const page = await this.source.changes(cursor, this.options.signal);
|
|
346
|
+
await this.copyRecords(page.records, manifest);
|
|
347
|
+
cursor = page.nextCursor ?? page.highWaterMark;
|
|
348
|
+
manifest.checkpoint = {
|
|
349
|
+
...manifest.checkpoint,
|
|
350
|
+
cursor,
|
|
351
|
+
highWaterMark: page.highWaterMark,
|
|
352
|
+
drained: page.records.length === 0 || page.nextCursor === undefined,
|
|
353
|
+
};
|
|
354
|
+
await this.persist(manifest);
|
|
355
|
+
if (manifest.checkpoint.drained)
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
245
359
|
async retry(operation, label) {
|
|
246
360
|
let attempt = 0;
|
|
247
361
|
for (;;) {
|
|
@@ -250,7 +364,7 @@ export class StorageMigrationCoordinator {
|
|
|
250
364
|
return await operation();
|
|
251
365
|
}
|
|
252
366
|
catch (error) {
|
|
253
|
-
const retryable = error
|
|
367
|
+
const retryable = isRetryableError(error);
|
|
254
368
|
if (!retryable)
|
|
255
369
|
throw error;
|
|
256
370
|
if (attempt >= this.maxRetries) {
|
|
@@ -290,6 +404,10 @@ export function digestRecords(records) {
|
|
|
290
404
|
.sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
|
|
291
405
|
return sha256(stableStringify(normalized));
|
|
292
406
|
}
|
|
407
|
+
export function digestRecordChunks(records, chunkSize) {
|
|
408
|
+
const sorted = [...records].sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
|
|
409
|
+
return chunk(sorted, chunkSize).map(recordsInChunk => digestRecords(recordsInChunk));
|
|
410
|
+
}
|
|
293
411
|
export function approvalDigest(manifest) {
|
|
294
412
|
const copy = structuredClone(manifest);
|
|
295
413
|
delete copy.digests.approval;
|
|
@@ -302,6 +420,10 @@ export function validateManifest(manifest, source, destination) {
|
|
|
302
420
|
if (!manifest.migrationId || !manifest.snapshot?.id || !manifest.digests?.source) {
|
|
303
421
|
throw new MigrationProtocolError('AIWG_MIGRATION_MANIFEST_CORRUPT', 'migration manifest is missing required identity or digest fields');
|
|
304
422
|
}
|
|
423
|
+
if (!manifest.boundaries?.initial) {
|
|
424
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_MISSING', 'migration manifest has no durable source boundary receipt');
|
|
425
|
+
}
|
|
426
|
+
assertBoundaryState(manifest.boundaries.initial, manifest.mode === 'offline' ? 'quiesced' : 'tracking', 'initial');
|
|
305
427
|
if (source && stableStringify(manifest.source) !== stableStringify(source)) {
|
|
306
428
|
throw new MigrationProtocolError('AIWG_MIGRATION_SOURCE_MISMATCH', 'manifest source does not match the active source');
|
|
307
429
|
}
|
|
@@ -309,13 +431,29 @@ export function validateManifest(manifest, source, destination) {
|
|
|
309
431
|
throw new MigrationProtocolError('AIWG_MIGRATION_DESTINATION_MISMATCH', 'manifest destination does not match the active destination');
|
|
310
432
|
}
|
|
311
433
|
}
|
|
312
|
-
function
|
|
434
|
+
function assertBoundaryState(boundary, expected, stage) {
|
|
435
|
+
if (!boundary.boundaryId || !boundary.establishedAt || boundary.state !== expected) {
|
|
436
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_INVALID', `${stage} source boundary must provide a durable ${expected} receipt`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function mutationFor(migrationId, record, expectedRevision) {
|
|
313
440
|
return {
|
|
314
441
|
operation: record.tombstone ? 'delete' : 'upsert',
|
|
315
442
|
record,
|
|
316
443
|
idempotencyKey: sha256(`${migrationId}\0${recordKey(record)}`),
|
|
444
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
317
445
|
};
|
|
318
446
|
}
|
|
447
|
+
function revisionConflict(identity, revision) {
|
|
448
|
+
return new MigrationProtocolError('AIWG_MIGRATION_REVISION_CONFLICT', `source revision ${revision} has conflicting content for ${identity}`);
|
|
449
|
+
}
|
|
450
|
+
function compareRevisions(comparator, left, right) {
|
|
451
|
+
const result = comparator(left, right);
|
|
452
|
+
if (!Number.isFinite(result)) {
|
|
453
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_REVISION_ORDER_INVALID', 'source-revision comparator returned a non-finite result');
|
|
454
|
+
}
|
|
455
|
+
return Math.sign(result);
|
|
456
|
+
}
|
|
319
457
|
function recordMap(records) {
|
|
320
458
|
return new Map(records.map(record => [identityKey(record.identity), record]));
|
|
321
459
|
}
|
|
@@ -357,11 +495,51 @@ function boundedInteger(raw, fallback, min, max, label) {
|
|
|
357
495
|
return value;
|
|
358
496
|
}
|
|
359
497
|
function summarizeVerificationFailure(verification) {
|
|
360
|
-
|
|
498
|
+
const semanticFailures = Object.values(verification.semantic).filter(check => !check.valid).length;
|
|
499
|
+
return `migration parity failed: missing=${verification.missing.length} unexpected=${verification.unexpected.length} corrupt=${verification.corrupt.length} chunks=${verification.chunks.valid ? 'valid' : 'invalid'} semantic=${semanticFailures} lag=${verification.lag}`;
|
|
500
|
+
}
|
|
501
|
+
function validateSemanticVerification(verification) {
|
|
502
|
+
for (const dimension of [
|
|
503
|
+
'schema', 'constraints', 'countsByType', 'edgeIntegrity', 'queryParity', 'traversalParity',
|
|
504
|
+
]) {
|
|
505
|
+
const check = verification?.[dimension];
|
|
506
|
+
if (!check || typeof check.valid !== 'boolean' || !Number.isInteger(check.declared) || check.declared < 0 ||
|
|
507
|
+
!Number.isInteger(check.checked) || check.checked < 0 || check.checked !== check.declared || !Array.isArray(check.failures)) {
|
|
508
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification is incomplete for ${dimension}`);
|
|
509
|
+
}
|
|
510
|
+
if (check.valid !== (check.failures.length === 0)) {
|
|
511
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification validity disagrees with failures for ${dimension}`);
|
|
512
|
+
}
|
|
513
|
+
if (check.evidenceDigest !== undefined && !/^[0-9A-Za-z._:-]+$/.test(check.evidenceDigest)) {
|
|
514
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification evidence digest is invalid for ${dimension}`);
|
|
515
|
+
}
|
|
516
|
+
if (['schema', 'constraints', 'queryParity'].includes(dimension) && check.declared < 1) {
|
|
517
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification must exercise at least one ${dimension} check`);
|
|
518
|
+
}
|
|
519
|
+
if (dimension === 'countsByType') {
|
|
520
|
+
if (!isCountMap(check.sourceCounts) || !isCountMap(check.destinationCounts)) {
|
|
521
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', 'countsByType must include non-negative integer source and destination maps');
|
|
522
|
+
}
|
|
523
|
+
const declaredTypes = new Set([...Object.keys(check.sourceCounts), ...Object.keys(check.destinationCounts)]).size;
|
|
524
|
+
if (check.declared !== declaredTypes || (check.valid && stableStringify(check.sourceCounts) !== stableStringify(check.destinationCounts))) {
|
|
525
|
+
throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', 'countsByType scope or validity does not match its declared maps');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function semanticIsValid(verification) {
|
|
531
|
+
return Object.values(verification).every(check => check.valid);
|
|
532
|
+
}
|
|
533
|
+
function isCountMap(value) {
|
|
534
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) &&
|
|
535
|
+
Object.values(value).every(count => Number.isInteger(count) && count >= 0);
|
|
361
536
|
}
|
|
362
537
|
function errorMessage(error) {
|
|
363
538
|
return error instanceof Error ? error.message : String(error);
|
|
364
539
|
}
|
|
540
|
+
function isRetryableError(error) {
|
|
541
|
+
return typeof error === 'object' && error !== null && 'retryable' in error && error.retryable === true;
|
|
542
|
+
}
|
|
365
543
|
async function abortableDelay(ms, signal) {
|
|
366
544
|
if (!signal) {
|
|
367
545
|
await new Promise(resolve => setTimeout(resolve, ms));
|