@silo-ai/silo 0.1.1 → 0.1.2
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 +6 -0
- package/dist/bin.mjs +354 -67
- package/package.json +1 -1
- package/skills/silo/tasks/synchronize.md +20 -1
package/README.md
CHANGED
|
@@ -47,6 +47,12 @@ The first schema mutation creates the database. Inspect the resulting logical sc
|
|
|
47
47
|
|
|
48
48
|
Run `silo --help` and `silo <group> <command> --help` for the authoritative command syntax and examples. The self-contained [`skills/silo/`](skills/silo/) package includes both agent operating practices and the exact JSON request contracts it references.
|
|
49
49
|
|
|
50
|
+
To make the packaged guidance discoverable without installing a separate agent skill, add this rule to your global `AGENTS.md`:
|
|
51
|
+
|
|
52
|
+
> - When told to “use Silo” or do something with Silo, run `silo skill` and follow its instructions. Read any referenced task guide or JSON Schema with `silo skill <relative-path>`.
|
|
53
|
+
|
|
54
|
+
`silo skill` prints the main skill. Its relative links can be read from any directory, for example with `silo skill tasks/create-table.md` or `silo skill schemas/row-write.schema.json`.
|
|
55
|
+
|
|
50
56
|
## Import a schema template
|
|
51
57
|
|
|
52
58
|
Import the bundled agent-first task schema into the current repository:
|
package/dist/bin.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { binary, command, number, option, optional, positional, runSafely, string, subcommands } from "cmd-ts";
|
|
2
|
+
import { binary, command, extendType, flag, number, oneOf, option, optional, positional, runSafely, string, subcommands } from "cmd-ts";
|
|
3
3
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { File } from "cmd-ts/dist/cjs/batteries/fs.js";
|
|
5
6
|
import { DatabaseSync, backup, constants } from "node:sqlite";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
9
9
|
import { isIP } from "node:net";
|
|
10
10
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
@@ -15,7 +15,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|
|
15
15
|
import ReactMarkdown from "react-markdown";
|
|
16
16
|
import remarkGfm from "remark-gfm";
|
|
17
17
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
|
-
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
18
|
+
import { DeleteObjectsCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
20
|
//#region src/model.ts
|
|
21
21
|
var SiloError = class extends Error {
|
|
@@ -1306,6 +1306,10 @@ var SiloDatabase = class SiloDatabase {
|
|
|
1306
1306
|
canonical.close();
|
|
1307
1307
|
}
|
|
1308
1308
|
}
|
|
1309
|
+
async backupRecovery(path) {
|
|
1310
|
+
this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1311
|
+
await backup(this.database, path);
|
|
1312
|
+
}
|
|
1309
1313
|
rebasePending(pending, generation, etag, discardTransactionId) {
|
|
1310
1314
|
if (!this.getSyncState()) throw new SiloError(exits.integrity, "sync_metadata_missing", "Restored sync metadata is missing.");
|
|
1311
1315
|
try {
|
|
@@ -2231,6 +2235,63 @@ var S3SyncRemote = class {
|
|
|
2231
2235
|
generationUrl(generation) {
|
|
2232
2236
|
return `s3://${this.bucket}/${this.prefix}/generations/${generation}`;
|
|
2233
2237
|
}
|
|
2238
|
+
async listGenerations() {
|
|
2239
|
+
const prefix = `${this.prefix}/generations/`;
|
|
2240
|
+
const generations = /* @__PURE__ */ new Map();
|
|
2241
|
+
let continuationToken;
|
|
2242
|
+
try {
|
|
2243
|
+
do {
|
|
2244
|
+
const response = await this.client.send(new ListObjectsV2Command({
|
|
2245
|
+
Bucket: this.bucket,
|
|
2246
|
+
Prefix: prefix,
|
|
2247
|
+
ContinuationToken: continuationToken
|
|
2248
|
+
}));
|
|
2249
|
+
for (const object of response.Contents ?? []) {
|
|
2250
|
+
const generation = (object.Key?.slice(prefix.length))?.split("/", 1)[0];
|
|
2251
|
+
if (!generation || !object.LastModified) continue;
|
|
2252
|
+
const previous = generations.get(generation);
|
|
2253
|
+
if (!previous || object.LastModified > previous) generations.set(generation, object.LastModified);
|
|
2254
|
+
}
|
|
2255
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
2256
|
+
} while (continuationToken);
|
|
2257
|
+
return [...generations].map(([generation, last_modified]) => ({
|
|
2258
|
+
generation,
|
|
2259
|
+
last_modified
|
|
2260
|
+
}));
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
throw new SiloError(exits.io, "sync_generations_list_failed", error instanceof Error ? error.message : String(error));
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
async deleteGeneration(generation) {
|
|
2266
|
+
const prefix = `${this.prefix}/generations/${generation}/`;
|
|
2267
|
+
const objects = [];
|
|
2268
|
+
let continuationToken;
|
|
2269
|
+
try {
|
|
2270
|
+
do {
|
|
2271
|
+
const response = await this.client.send(new ListObjectsV2Command({
|
|
2272
|
+
Bucket: this.bucket,
|
|
2273
|
+
Prefix: prefix,
|
|
2274
|
+
ContinuationToken: continuationToken
|
|
2275
|
+
}));
|
|
2276
|
+
objects.push(...(response.Contents ?? []).flatMap((object) => object.Key ? [{ Key: object.Key }] : []));
|
|
2277
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
2278
|
+
} while (continuationToken);
|
|
2279
|
+
for (let offset = 0; offset < objects.length; offset += 1e3) {
|
|
2280
|
+
const batch = objects.slice(offset, offset + 1e3);
|
|
2281
|
+
const result = await this.client.send(new DeleteObjectsCommand({
|
|
2282
|
+
Bucket: this.bucket,
|
|
2283
|
+
Delete: {
|
|
2284
|
+
Objects: batch,
|
|
2285
|
+
Quiet: true
|
|
2286
|
+
}
|
|
2287
|
+
}));
|
|
2288
|
+
if (result.Errors?.length) throw new Error(result.Errors.map((item) => `${item.Key ?? "unknown key"}: ${item.Message ?? item.Code ?? "delete failed"}`).join("; "));
|
|
2289
|
+
}
|
|
2290
|
+
return objects.length;
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
throw new SiloError(exits.io, "sync_generation_delete_failed", `Generation ${generation}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2234
2295
|
};
|
|
2235
2296
|
var LitestreamCheckpoint = class {
|
|
2236
2297
|
executable;
|
|
@@ -2384,7 +2445,7 @@ var SiloSync = class {
|
|
|
2384
2445
|
const database = SiloDatabase.open(this.workspace, true, true);
|
|
2385
2446
|
try {
|
|
2386
2447
|
const state = database.getSyncState();
|
|
2387
|
-
if (head && !state) throw new SiloError(exits.revision, "sync_local_diverged",
|
|
2448
|
+
if (head && !state) throw new SiloError(exits.revision, "sync_local_diverged", `A local database and remote generation ${head.manifest.generation} both exist. Choose an explicit sync recovery workflow.`);
|
|
2388
2449
|
const configured = database.configureSync(remoteUrl, head?.manifest.database_id);
|
|
2389
2450
|
if (head) validateHead(this.workspace, head, configured.database_id);
|
|
2390
2451
|
} finally {
|
|
@@ -2394,6 +2455,88 @@ var SiloSync = class {
|
|
|
2394
2455
|
return this.status();
|
|
2395
2456
|
});
|
|
2396
2457
|
}
|
|
2458
|
+
async adoptRemote(remoteUrl, confirmedGeneration) {
|
|
2459
|
+
return withSyncLock(this.workspace, async () => {
|
|
2460
|
+
await this.services.checkpoint.check();
|
|
2461
|
+
if (!existsSync(this.workspace.databasePath)) throw new SiloError(exits.absent, "sync_local_absent", "A local database is required to use the adopt-remote recovery workflow.");
|
|
2462
|
+
const local = SiloDatabase.open(this.workspace, true, true);
|
|
2463
|
+
try {
|
|
2464
|
+
if (local.getSyncState()) throw new SiloError(exits.workspace, "sync_already_configured", "The local database is already configured for synchronization.");
|
|
2465
|
+
} finally {
|
|
2466
|
+
local.close();
|
|
2467
|
+
}
|
|
2468
|
+
const remote = this.services.remote(remoteUrl);
|
|
2469
|
+
const head = await remote.readHead();
|
|
2470
|
+
if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
|
|
2471
|
+
validateHead(this.workspace, head);
|
|
2472
|
+
this.confirmRecovery(head, confirmedGeneration);
|
|
2473
|
+
const restored = temporaryPath(this.workspace, "adopt");
|
|
2474
|
+
const preserved = `${this.workspace.databasePath}.recovery-local-${randomUUID()}.sqlite`;
|
|
2475
|
+
try {
|
|
2476
|
+
await this.restoreAndVerify(remote, head, restored);
|
|
2477
|
+
const original = SiloDatabase.open(this.workspace, true, true);
|
|
2478
|
+
try {
|
|
2479
|
+
await original.backupRecovery(preserved);
|
|
2480
|
+
} finally {
|
|
2481
|
+
original.close();
|
|
2482
|
+
}
|
|
2483
|
+
installDatabase(restored, this.workspace.databasePath);
|
|
2484
|
+
const adopted = SiloDatabase.open(this.workspace, true, true);
|
|
2485
|
+
try {
|
|
2486
|
+
adopted.markSynchronized(head.manifest.generation, head.etag);
|
|
2487
|
+
} finally {
|
|
2488
|
+
adopted.close();
|
|
2489
|
+
}
|
|
2490
|
+
} finally {
|
|
2491
|
+
removeDatabase(restored);
|
|
2492
|
+
}
|
|
2493
|
+
return {
|
|
2494
|
+
status: await this.status(),
|
|
2495
|
+
preserved
|
|
2496
|
+
};
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
async replaceRemote(remoteUrl, confirmedGeneration) {
|
|
2500
|
+
return withSyncLock(this.workspace, async () => {
|
|
2501
|
+
await this.services.checkpoint.check();
|
|
2502
|
+
if (!existsSync(this.workspace.databasePath)) throw new SiloError(exits.absent, "sync_local_absent", "A local database is required to use the replace-remote recovery workflow.");
|
|
2503
|
+
const remote = this.services.remote(remoteUrl);
|
|
2504
|
+
const head = await remote.readHead();
|
|
2505
|
+
if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
|
|
2506
|
+
validateHead(this.workspace, head);
|
|
2507
|
+
this.confirmRecovery(head, confirmedGeneration);
|
|
2508
|
+
const candidate = temporaryPath(this.workspace, "replace");
|
|
2509
|
+
const local = SiloDatabase.open(this.workspace, true, true);
|
|
2510
|
+
try {
|
|
2511
|
+
if (local.getSyncState()) throw new SiloError(exits.workspace, "sync_already_configured", "The local database is already configured for synchronization.");
|
|
2512
|
+
await local.backupRecovery(candidate);
|
|
2513
|
+
} finally {
|
|
2514
|
+
local.close();
|
|
2515
|
+
}
|
|
2516
|
+
try {
|
|
2517
|
+
const configured = SiloDatabase.open({
|
|
2518
|
+
...this.workspace,
|
|
2519
|
+
databasePath: candidate
|
|
2520
|
+
}, true, true);
|
|
2521
|
+
try {
|
|
2522
|
+
configured.configureSync(remoteUrl, head.manifest.database_id);
|
|
2523
|
+
} finally {
|
|
2524
|
+
configured.close();
|
|
2525
|
+
}
|
|
2526
|
+
await this.publishDatabase(candidate, remote, head);
|
|
2527
|
+
installDatabase(candidate, this.workspace.databasePath);
|
|
2528
|
+
} finally {
|
|
2529
|
+
removeDatabase(candidate);
|
|
2530
|
+
}
|
|
2531
|
+
return {
|
|
2532
|
+
status: await this.status(),
|
|
2533
|
+
preserved: remote.generationUrl(head.manifest.generation)
|
|
2534
|
+
};
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
confirmRecovery(head, confirmedGeneration) {
|
|
2538
|
+
if (confirmedGeneration !== head.manifest.generation) throw new SiloError(exits.revision, "sync_recovery_confirmation_mismatch", `Confirmation must equal current remote generation ${head.manifest.generation}.`);
|
|
2539
|
+
}
|
|
2397
2540
|
async status() {
|
|
2398
2541
|
if (!existsSync(this.workspace.databasePath)) return {
|
|
2399
2542
|
state: "unconfigured",
|
|
@@ -2501,7 +2644,7 @@ var SiloSync = class {
|
|
|
2501
2644
|
async push() {
|
|
2502
2645
|
return withSyncLock(this.workspace, async () => {
|
|
2503
2646
|
await this.services.checkpoint.check();
|
|
2504
|
-
|
|
2647
|
+
const database = SiloDatabase.open(this.workspace, true, true);
|
|
2505
2648
|
let state;
|
|
2506
2649
|
let pendingCount;
|
|
2507
2650
|
try {
|
|
@@ -2527,64 +2670,111 @@ var SiloSync = class {
|
|
|
2527
2670
|
head = await remote.readHead();
|
|
2528
2671
|
if (head) validateHead(this.workspace, head, state.database_id);
|
|
2529
2672
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2673
|
+
await this.publishDatabase(this.workspace.databasePath, remote, head);
|
|
2674
|
+
return this.status();
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
async prune(olderThanDays = 7, apply = false, now = /* @__PURE__ */ new Date()) {
|
|
2678
|
+
return withSyncLock(this.workspace, async () => {
|
|
2679
|
+
const database = SiloDatabase.open(this.workspace);
|
|
2680
|
+
let state;
|
|
2535
2681
|
try {
|
|
2536
|
-
|
|
2537
|
-
const manifest = {
|
|
2538
|
-
format_version: 1,
|
|
2539
|
-
database_id: state.database_id,
|
|
2540
|
-
identity: this.workspace.identity,
|
|
2541
|
-
generation,
|
|
2542
|
-
publication_id: publicationId,
|
|
2543
|
-
parent_generation: head?.manifest.generation ?? null,
|
|
2544
|
-
schema_revision: database.getSchema().revision,
|
|
2545
|
-
database_sha256: hashDatabase(candidate),
|
|
2546
|
-
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2547
|
-
};
|
|
2548
|
-
database.close();
|
|
2549
|
-
await this.services.checkpoint.publish(candidate, remote.generationUrl(generation));
|
|
2550
|
-
await this.services.checkpoint.restore(remote.generationUrl(generation), verified);
|
|
2551
|
-
if (hashDatabase(verified) !== manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match the published database.");
|
|
2552
|
-
const verification = SiloDatabase.open({
|
|
2553
|
-
...this.workspace,
|
|
2554
|
-
databasePath: verified
|
|
2555
|
-
}, false, true);
|
|
2556
|
-
try {
|
|
2557
|
-
if (verification.getSchema().revision !== manifest.schema_revision) throw new SiloError(exits.integrity, "sync_checkpoint_schema_mismatch", "The published checkpoint schema does not match its manifest.");
|
|
2558
|
-
const integrity = verification.query("PRAGMA integrity_check");
|
|
2559
|
-
if (integrity.rows.length !== 1 || integrity.rows[0]?.[0] !== "ok") throw new SiloError(exits.integrity, "integrity_check_failed", "The published checkpoint failed SQLite integrity checking.");
|
|
2560
|
-
} finally {
|
|
2561
|
-
verification.close();
|
|
2562
|
-
}
|
|
2563
|
-
let etag;
|
|
2564
|
-
try {
|
|
2565
|
-
etag = await remote.publishHead(manifest, head?.etag ?? null);
|
|
2566
|
-
} catch (error) {
|
|
2567
|
-
if (!(error instanceof SiloError) || error.code !== "sync_head_changed") throw error;
|
|
2568
|
-
const current = await remote.readHead();
|
|
2569
|
-
if (!current || current.manifest.publication_id !== publicationId) throw error;
|
|
2570
|
-
etag = current.etag;
|
|
2571
|
-
}
|
|
2572
|
-
const updated = SiloDatabase.open(this.workspace, true, true);
|
|
2573
|
-
try {
|
|
2574
|
-
updated.markSynchronized(generation, etag);
|
|
2575
|
-
} finally {
|
|
2576
|
-
updated.close();
|
|
2577
|
-
}
|
|
2682
|
+
state = database.getSyncState();
|
|
2578
2683
|
} finally {
|
|
2579
|
-
|
|
2580
|
-
database.close();
|
|
2581
|
-
} catch {}
|
|
2582
|
-
removeDatabase(candidate);
|
|
2583
|
-
removeDatabase(verified);
|
|
2684
|
+
database.close();
|
|
2584
2685
|
}
|
|
2585
|
-
|
|
2686
|
+
if (!state) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
|
|
2687
|
+
const remote = this.services.remote(state.remote_url);
|
|
2688
|
+
const head = await remote.readHead();
|
|
2689
|
+
if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
|
|
2690
|
+
validateHead(this.workspace, head, state.database_id);
|
|
2691
|
+
const cutoff = /* @__PURE__ */ new Date(now.getTime() - olderThanDays * 864e5);
|
|
2692
|
+
const generations = await remote.listGenerations();
|
|
2693
|
+
const eligible = generations.filter((item) => item.generation !== head.manifest.generation && item.last_modified.getTime() <= cutoff.getTime()).map((item) => item.generation).sort();
|
|
2694
|
+
const deleted = [];
|
|
2695
|
+
if (apply && eligible.length) for (const generation of eligible) {
|
|
2696
|
+
const current = await remote.readHead();
|
|
2697
|
+
if (!current || current.etag !== head.etag) throw new SiloError(exits.revision, "sync_head_changed", deleted.length ? `Remote HEAD changed during cleanup after ${deleted.length} generation(s) were deleted; remaining generations were preserved.` : "Remote HEAD changed during cleanup; no generations were deleted.");
|
|
2698
|
+
validateHead(this.workspace, current, state.database_id);
|
|
2699
|
+
if (generation === current.manifest.generation) continue;
|
|
2700
|
+
await remote.deleteGeneration(generation);
|
|
2701
|
+
deleted.push(generation);
|
|
2702
|
+
}
|
|
2703
|
+
return {
|
|
2704
|
+
remote_url: state.remote_url,
|
|
2705
|
+
current_generation: head.manifest.generation,
|
|
2706
|
+
cutoff: cutoff.toISOString(),
|
|
2707
|
+
scanned_generations: generations.length,
|
|
2708
|
+
eligible_generations: eligible,
|
|
2709
|
+
deleted_generations: deleted,
|
|
2710
|
+
dry_run: !apply
|
|
2711
|
+
};
|
|
2586
2712
|
});
|
|
2587
2713
|
}
|
|
2714
|
+
async publishDatabase(databasePath, remote, head) {
|
|
2715
|
+
let database = SiloDatabase.open({
|
|
2716
|
+
...this.workspace,
|
|
2717
|
+
databasePath
|
|
2718
|
+
}, true, true);
|
|
2719
|
+
const state = database.getSyncState();
|
|
2720
|
+
const publicationId = randomUUID();
|
|
2721
|
+
const generation = randomUUID();
|
|
2722
|
+
const candidate = temporaryPath(this.workspace, "publish");
|
|
2723
|
+
const verified = temporaryPath(this.workspace, "verify");
|
|
2724
|
+
try {
|
|
2725
|
+
await database.backupCanonical(candidate, generation);
|
|
2726
|
+
const manifest = {
|
|
2727
|
+
format_version: 1,
|
|
2728
|
+
database_id: state.database_id,
|
|
2729
|
+
identity: this.workspace.identity,
|
|
2730
|
+
generation,
|
|
2731
|
+
publication_id: publicationId,
|
|
2732
|
+
parent_generation: head?.manifest.generation ?? null,
|
|
2733
|
+
schema_revision: database.getSchema().revision,
|
|
2734
|
+
database_sha256: hashDatabase(candidate),
|
|
2735
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2736
|
+
};
|
|
2737
|
+
database.close();
|
|
2738
|
+
await this.services.checkpoint.publish(candidate, remote.generationUrl(generation));
|
|
2739
|
+
await this.services.checkpoint.restore(remote.generationUrl(generation), verified);
|
|
2740
|
+
if (hashDatabase(verified) !== manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match the published database.");
|
|
2741
|
+
const verification = SiloDatabase.open({
|
|
2742
|
+
...this.workspace,
|
|
2743
|
+
databasePath: verified
|
|
2744
|
+
}, false, true);
|
|
2745
|
+
try {
|
|
2746
|
+
if (verification.getSchema().revision !== manifest.schema_revision) throw new SiloError(exits.integrity, "sync_checkpoint_schema_mismatch", "The published checkpoint schema does not match its manifest.");
|
|
2747
|
+
const integrity = verification.query("PRAGMA integrity_check");
|
|
2748
|
+
if (integrity.rows.length !== 1 || integrity.rows[0]?.[0] !== "ok") throw new SiloError(exits.integrity, "integrity_check_failed", "The published checkpoint failed SQLite integrity checking.");
|
|
2749
|
+
} finally {
|
|
2750
|
+
verification.close();
|
|
2751
|
+
}
|
|
2752
|
+
let etag;
|
|
2753
|
+
try {
|
|
2754
|
+
etag = await remote.publishHead(manifest, head?.etag ?? null);
|
|
2755
|
+
} catch (error) {
|
|
2756
|
+
if (!(error instanceof SiloError) || error.code !== "sync_head_changed") throw error;
|
|
2757
|
+
const current = await remote.readHead();
|
|
2758
|
+
if (!current || current.manifest.publication_id !== publicationId) throw error;
|
|
2759
|
+
etag = current.etag;
|
|
2760
|
+
}
|
|
2761
|
+
const updated = SiloDatabase.open({
|
|
2762
|
+
...this.workspace,
|
|
2763
|
+
databasePath
|
|
2764
|
+
}, true, true);
|
|
2765
|
+
try {
|
|
2766
|
+
updated.markSynchronized(generation, etag);
|
|
2767
|
+
} finally {
|
|
2768
|
+
updated.close();
|
|
2769
|
+
}
|
|
2770
|
+
} finally {
|
|
2771
|
+
try {
|
|
2772
|
+
database.close();
|
|
2773
|
+
} catch {}
|
|
2774
|
+
removeDatabase(candidate);
|
|
2775
|
+
removeDatabase(verified);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2588
2778
|
async restoreAndVerify(remote, head, outputPath) {
|
|
2589
2779
|
await this.services.checkpoint.restore(remote.generationUrl(head.manifest.generation), outputPath);
|
|
2590
2780
|
if (hashDatabase(outputPath) !== head.manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match remote HEAD.");
|
|
@@ -2605,6 +2795,23 @@ var SiloSync = class {
|
|
|
2605
2795
|
};
|
|
2606
2796
|
//#endregion
|
|
2607
2797
|
//#region src/cli.ts
|
|
2798
|
+
const skillResources = [
|
|
2799
|
+
"SKILL.md",
|
|
2800
|
+
"tasks/alter-table.md",
|
|
2801
|
+
"tasks/create-report.md",
|
|
2802
|
+
"tasks/create-table.md",
|
|
2803
|
+
"tasks/query-with-sql.md",
|
|
2804
|
+
"tasks/synchronize.md",
|
|
2805
|
+
"tasks/update-with-revision.md",
|
|
2806
|
+
"tasks/upsert-rows.md",
|
|
2807
|
+
"schemas/report-put.schema.json",
|
|
2808
|
+
"schemas/row-write.schema.json",
|
|
2809
|
+
"schemas/table-alter.schema.json",
|
|
2810
|
+
"schemas/table-create.schema.json"
|
|
2811
|
+
];
|
|
2812
|
+
function readSkillResource(resource = "SKILL.md") {
|
|
2813
|
+
return readFileSync(fileURLToPath(new URL(`../skills/silo/${resource}`, import.meta.url)), "utf8");
|
|
2814
|
+
}
|
|
2608
2815
|
const inputFile = option({
|
|
2609
2816
|
type: optional(File),
|
|
2610
2817
|
long: "file",
|
|
@@ -2753,6 +2960,16 @@ const status = command({
|
|
|
2753
2960
|
])));
|
|
2754
2961
|
})
|
|
2755
2962
|
});
|
|
2963
|
+
const skill = command({
|
|
2964
|
+
name: "skill",
|
|
2965
|
+
description: "Print the packaged Silo agent skill or one of its referenced resources.",
|
|
2966
|
+
args: { resource: positional({
|
|
2967
|
+
type: optional(oneOf(skillResources)),
|
|
2968
|
+
displayName: "relative-path",
|
|
2969
|
+
description: "A task or schema path referenced by SKILL.md."
|
|
2970
|
+
}) },
|
|
2971
|
+
handler: ({ resource }) => output(readSkillResource(resource))
|
|
2972
|
+
});
|
|
2756
2973
|
const databaseList = command({
|
|
2757
2974
|
name: "list",
|
|
2758
2975
|
description: "Discover Silo databases in the application-data catalog.",
|
|
@@ -3223,6 +3440,36 @@ const syncInit = command({
|
|
|
3223
3440
|
output(renderSyncStatus(await new SiloSync(resolveWorkspace()).initialize(remote)));
|
|
3224
3441
|
})
|
|
3225
3442
|
});
|
|
3443
|
+
function renderSyncRecovery(result) {
|
|
3444
|
+
return `${renderSyncStatus(result.status)}\n\n${heading("Preserved losing copy", `\`${result.preserved}\``)}`;
|
|
3445
|
+
}
|
|
3446
|
+
const recoveryArgs = {
|
|
3447
|
+
remote: positional({
|
|
3448
|
+
type: string,
|
|
3449
|
+
displayName: "s3-url"
|
|
3450
|
+
}),
|
|
3451
|
+
confirm: option({
|
|
3452
|
+
type: string,
|
|
3453
|
+
long: "confirm",
|
|
3454
|
+
description: "Confirm the exact current remote generation that this recovery will resolve."
|
|
3455
|
+
})
|
|
3456
|
+
};
|
|
3457
|
+
const syncAdoptRemote = command({
|
|
3458
|
+
name: "adopt-remote",
|
|
3459
|
+
description: "Replace an unconfigured local database after preserving it as a recovery snapshot.",
|
|
3460
|
+
args: recoveryArgs,
|
|
3461
|
+
handler: withErrors(async ({ remote, confirm }) => {
|
|
3462
|
+
output(renderSyncRecovery(await new SiloSync(resolveWorkspace()).adoptRemote(remote, confirm)));
|
|
3463
|
+
})
|
|
3464
|
+
});
|
|
3465
|
+
const syncReplaceRemote = command({
|
|
3466
|
+
name: "replace-remote",
|
|
3467
|
+
description: "Publish an unconfigured local database over a confirmed remote generation.",
|
|
3468
|
+
args: recoveryArgs,
|
|
3469
|
+
handler: withErrors(async ({ remote, confirm }) => {
|
|
3470
|
+
output(renderSyncRecovery(await new SiloSync(resolveWorkspace()).replaceRemote(remote, confirm)));
|
|
3471
|
+
})
|
|
3472
|
+
});
|
|
3226
3473
|
const syncStatus = command({
|
|
3227
3474
|
name: "status",
|
|
3228
3475
|
description: "Compare local synchronization state with remote HEAD.",
|
|
@@ -3250,6 +3497,24 @@ const pull = command({
|
|
|
3250
3497
|
output(renderSyncStatus(await new SiloSync(resolveWorkspace()).pull()));
|
|
3251
3498
|
})
|
|
3252
3499
|
});
|
|
3500
|
+
const push = command({
|
|
3501
|
+
name: "push",
|
|
3502
|
+
description: "Publish a merged immutable checkpoint and conditionally advance remote HEAD.",
|
|
3503
|
+
args: {},
|
|
3504
|
+
handler: withErrors(async () => {
|
|
3505
|
+
output(renderSyncStatus(await new SiloSync(resolveWorkspace()).push()));
|
|
3506
|
+
})
|
|
3507
|
+
});
|
|
3508
|
+
function renderPruneResult(result) {
|
|
3509
|
+
return heading(result.dry_run ? "Synchronization Cleanup Preview" : "Synchronization Cleanup", table(["Property", "Value"], [
|
|
3510
|
+
["Remote", result.remote_url],
|
|
3511
|
+
["Current generation", result.current_generation],
|
|
3512
|
+
["Cutoff", result.cutoff],
|
|
3513
|
+
["Scanned generations", result.scanned_generations],
|
|
3514
|
+
["Eligible generations", result.eligible_generations.length],
|
|
3515
|
+
["Deleted generations", result.deleted_generations.length]
|
|
3516
|
+
]) + (result.eligible_generations.length ? `\n\n${heading("Eligible Generation IDs", result.eligible_generations.map((id) => `- \`${id}\``).join("\n"))}` : ""));
|
|
3517
|
+
}
|
|
3253
3518
|
//#endregion
|
|
3254
3519
|
//#region src/bin.ts
|
|
3255
3520
|
const result = await runSafely(binary(subcommands({
|
|
@@ -3257,21 +3522,43 @@ const result = await runSafely(binary(subcommands({
|
|
|
3257
3522
|
version: "0.1.0",
|
|
3258
3523
|
cmds: {
|
|
3259
3524
|
status,
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
description: "Publish a merged immutable checkpoint and conditionally advance remote HEAD.",
|
|
3263
|
-
args: {},
|
|
3264
|
-
handler: withErrors(async () => {
|
|
3265
|
-
output(renderSyncStatus(await new SiloSync(resolveWorkspace()).push()));
|
|
3266
|
-
})
|
|
3267
|
-
}),
|
|
3525
|
+
skill,
|
|
3526
|
+
push,
|
|
3268
3527
|
pull,
|
|
3269
3528
|
sync: subcommands({
|
|
3270
3529
|
name: "sync",
|
|
3271
3530
|
cmds: {
|
|
3272
3531
|
init: syncInit,
|
|
3532
|
+
"adopt-remote": syncAdoptRemote,
|
|
3533
|
+
"replace-remote": syncReplaceRemote,
|
|
3273
3534
|
status: syncStatus,
|
|
3274
|
-
discard: syncDiscard
|
|
3535
|
+
discard: syncDiscard,
|
|
3536
|
+
prune: command({
|
|
3537
|
+
name: "prune",
|
|
3538
|
+
description: "Preview or delete remote generations older than a grace period.",
|
|
3539
|
+
args: {
|
|
3540
|
+
olderThan: option({
|
|
3541
|
+
type: extendType(number, {
|
|
3542
|
+
displayName: "days",
|
|
3543
|
+
async from(value) {
|
|
3544
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error("Expected a positive number of days.");
|
|
3545
|
+
return value;
|
|
3546
|
+
}
|
|
3547
|
+
}),
|
|
3548
|
+
long: "older-than",
|
|
3549
|
+
description: "Only consider generations at least this many days old.",
|
|
3550
|
+
defaultValue: () => 7
|
|
3551
|
+
}),
|
|
3552
|
+
apply: flag({
|
|
3553
|
+
long: "apply",
|
|
3554
|
+
description: "Delete eligible generations after revalidating remote HEAD.",
|
|
3555
|
+
defaultValue: () => false
|
|
3556
|
+
})
|
|
3557
|
+
},
|
|
3558
|
+
handler: withErrors(async ({ olderThan, apply }) => {
|
|
3559
|
+
output(renderPruneResult(await new SiloSync(resolveWorkspace()).prune(olderThan, apply)));
|
|
3560
|
+
})
|
|
3561
|
+
})
|
|
3275
3562
|
}
|
|
3276
3563
|
}),
|
|
3277
3564
|
database: subcommands({
|
package/package.json
CHANGED
|
@@ -13,6 +13,18 @@ silo sync status
|
|
|
13
13
|
|
|
14
14
|
An existing local database with an empty remote becomes `ahead`; push it to establish remote `HEAD`. An absent local database restores an existing remote. Do not attempt to combine an existing unconfigured local database with an existing remote.
|
|
15
15
|
|
|
16
|
+
When both sides exist, initialization reports the current remote generation and stops. Inspect the local database, deliberately choose one authority, and confirm that exact remote generation:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
# Preserve local as a recovery snapshot, then install remote.
|
|
20
|
+
silo sync adopt-remote s3://my-bucket/silo/project --confirm <remote-generation>
|
|
21
|
+
|
|
22
|
+
# Preserve the old immutable remote generation, then publish local.
|
|
23
|
+
silo sync replace-remote s3://my-bucket/silo/project --confirm <remote-generation>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Both commands report the losing copy's location. A changed confirmation is a new decision point, not permission to retry blindly.
|
|
27
|
+
|
|
16
28
|
For normal shared work:
|
|
17
29
|
|
|
18
30
|
```sh
|
|
@@ -31,4 +43,11 @@ Discard rebuilds from remote and replays the remaining pending transactions; it
|
|
|
31
43
|
|
|
32
44
|
Schema commands require `clean` status and no pending data transaction. Pull first, make one schema mutation, and push it before further work. Concurrent schema changes do not merge; discard the losing schema transaction, adopt the remote winner, and reapply a compatible change deliberately.
|
|
33
45
|
|
|
34
|
-
Synchronization is explicit. There is no background replication, Git-style history, automatic conflict winner, or
|
|
46
|
+
Synchronization is explicit. There is no background replication, Git-style history, automatic conflict winner, or background remote generation cleanup. Operators can preview generations older than the default seven-day grace period, then deliberately apply the reviewed cleanup:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
silo sync prune
|
|
50
|
+
silo sync prune --apply
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use `--older-than <days>` for a longer retention boundary. Never bypass the preview or remove the generation named by remote `HEAD` directly.
|