@evident-ai/runner-synchroniser 3.5.2-dev.43244a2 → 3.5.2-dev.7130f36
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 +43 -9
- package/dist/cli.js +456 -121
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ build time.
|
|
|
56
56
|
| `env` | resolved config as shell-eval'able vars | `0` = ran; non-zero = tool broken |
|
|
57
57
|
| `litestream-config` | the generated `litestream.yml` | `0` = ran; non-zero = tool broken |
|
|
58
58
|
| `restore <claude\|opencode>` | — | `0` = ran; non-zero = tool broken |
|
|
59
|
-
| `sync-once <claude\|opencode>` | — | `0` = uploaded/unchanged/absent/disabled; `40` = not persisted; other = tool broken |
|
|
59
|
+
| `sync-once <claude\|opencode>` | — | `0` = uploaded (per-runner) or published/adopted/unchanged/absent/disabled (shared vault); `40` = not persisted this tick; other = tool broken |
|
|
60
60
|
| `model-auth-ready` | — | `0` = ready, `10` = not ready; other = tool broken |
|
|
61
61
|
| `self-stop` | — | `0` = stopped, `20` = keep the task; other = tool broken |
|
|
62
62
|
| `session-db-classify <litestream-restore-exit-code> <attempt> [--on-unusable-replica=<prune\|leave\|clear\|crash>] [--recovery-occurred] [--fresh-db-fallback]` | — | `0` = restored/no-replica/disabled, `32` = re-run and ask again, `31` = unusable (booted fresh, replicates into the existing prefix), `30` = fatal; other = usage/tool broken |
|
|
@@ -329,8 +329,10 @@ same way it already reuses `SESSION-DB-REPLICA-UNUSABLE` for `session-db-classif
|
|
|
329
329
|
`restore` logs domain outcomes and still exits `0`: a missing remote object or a
|
|
330
330
|
restore failure should not abort a boot, because a runner with no credentials yet
|
|
331
331
|
is a runner a human can still log into. `sync-once` returns `40` when the current
|
|
332
|
-
credential file is not persisted
|
|
333
|
-
`
|
|
332
|
+
credential file is not persisted. In per-runner mode, that means `failed`, `hashFailed`,
|
|
333
|
+
or `localInvalid`; in shared-vault mode it also includes `remoteFailed`, `remoteInvalid`,
|
|
334
|
+
`noEtag`, `contended`, and `adoptFailed`. `published` (created or replaced), `adopted`,
|
|
335
|
+
`unchanged`, `absent`, and `disabled` are legitimate `0` outcomes.
|
|
334
336
|
|
|
335
337
|
The consequence is the point: **any non-zero status from `restore` means the tool itself
|
|
336
338
|
broke** — and `sync-once` uses `40` for its typed persistence answer; other non-zero
|
|
@@ -379,6 +381,7 @@ key, so they never appear in `env`'s output or `litestream.yml`.
|
|
|
379
381
|
| ------------------------------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
380
382
|
| `HOME` | Every credential and database path is derived from it. | Throws; logged `FATAL:`, exits `1`. Affects every command. |
|
|
381
383
|
| `LITESTREAM_BUCKET` / `LITESTREAM_PREFIX` | Object-store bucket and key prefix. | Persistence off (needs **both**): `restore`/`sync-once` no-op, `env` warns. |
|
|
384
|
+
| `CREDENTIAL_VAULT_PREFIX` | Shared prefix for both credential objects; it does not redirect the session database. | Unset or blank: use this runner's `LITESTREAM_PREFIX`; ignored when persistence is disabled. |
|
|
382
385
|
| `AWS_REGION` | Region for the AWS clients (S3, and the ECS/STS calls `self-stop` makes). | Left to the SDK's own resolution. |
|
|
383
386
|
| `CREDS_SYNC_INTERVAL` | Seconds between sync ticks, reported by `env` for the caller's loop. | Falls back to `60`; also on unparseable or non-positive values. |
|
|
384
387
|
| `EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS` | Distinct restore points `session-db-verify`'s walkback will try before giving up. | Falls back to `10`; also on unparseable or non-positive values. |
|
|
@@ -402,6 +405,11 @@ The paths and keys that follow from the bucket/prefix:
|
|
|
402
405
|
| OpenCode | `$HOME/.local/share/opencode/auth.json` | `<prefix>/opencode/auth.json` |
|
|
403
406
|
| OpenCode DB | `$HOME/.local/share/opencode/opencode.db` | `<prefix>/opencode.db` (litestream) |
|
|
404
407
|
|
|
408
|
+
When persistence is enabled and `CREDENTIAL_VAULT_PREFIX` is set, both credential keys
|
|
409
|
+
use that shared prefix instead of `<prefix>`. The OpenCode database continues replicating
|
|
410
|
+
under `LITESTREAM_PREFIX`; the vault prefix only redirects the two credential objects. The
|
|
411
|
+
Evident CLI resolves this value from the runner's vault binding.
|
|
412
|
+
|
|
405
413
|
The session database is replicated by litestream itself; this package only generates its
|
|
406
414
|
config. `env` emits `PERSISTENCE_BUCKET`, `CLAUDE_CREDS`, `OPENCODE_DB_PATH` and
|
|
407
415
|
`CREDS_SYNC_INTERVAL`, each single-quoted so a value containing shell metacharacters is
|
|
@@ -435,18 +443,44 @@ running that version — the runner installs this package from a floating npm ta
|
|
|
435
443
|
- **Nothing throws out of `restore`/`sync`.** `sync-once` runs on an unsupervised timer,
|
|
436
444
|
where an unguarded failure would silently stop persisting credentials for the rest of
|
|
437
445
|
the task's life. Failures degrade to a warning and are retried on the next tick.
|
|
438
|
-
-
|
|
439
|
-
the credentials) is kept in a state file next to
|
|
440
|
-
self-contained process.
|
|
446
|
+
- **Per-runner `sync-once` uploads only on change.** Its last uploaded hash (a sha256, not
|
|
447
|
+
the credentials) is kept in a state file next to the credential file, so every invocation
|
|
448
|
+
is a self-contained process.
|
|
449
|
+
- **Shared-vault mode is two-way and conditional.** `sync-once` adopts a changed shared
|
|
450
|
+
credential or publishes a locally changed one with a conditional write. A writer that
|
|
451
|
+
loses the race adopts the winner instead of overwriting it. A `40` exit is an expected
|
|
452
|
+
"not persisted this tick" result, not a broken tool; the polling loop accepts it and
|
|
453
|
+
retries on the next tick.
|
|
441
454
|
- **Not-found is distinguished from failed.** A missing object is reported as "nothing to
|
|
442
455
|
restore"; an access or network error is reported as a failure, because that tells the
|
|
443
456
|
operator their persisted credentials may still be recoverable.
|
|
444
457
|
|
|
458
|
+
### Shared-vault log markers
|
|
459
|
+
|
|
460
|
+
In shared-vault mode, these markers identify the synchronization result without logging
|
|
461
|
+
credential bytes. Hash values are short prefixes and ETags remain opaque:
|
|
462
|
+
|
|
463
|
+
| Marker | Meaning |
|
|
464
|
+
| ------ | ------- |
|
|
465
|
+
| `VAULT-MODE` | Both credential stores use the shared vault prefix. |
|
|
466
|
+
| `VAULT-PUBLISHED` | A credential was created in, or conditionally replaced in, the vault. |
|
|
467
|
+
| `VAULT-ADOPTED` | A valid vault credential was installed locally. |
|
|
468
|
+
| `VAULT-CONTENDED` | Another writer won the conditional write, so this runner is adopting its result. |
|
|
469
|
+
| `VAULT-CONTENDED-UNRESOLVED` | The winning object could not be read; the next tick will retry. |
|
|
470
|
+
| `VAULT-REMOTE-INVALID` | The vault object was not valid JSON and was either replaced or left untouched. |
|
|
471
|
+
| `VAULT-REMOTE-UNREADABLE` | The vault read failed; the next tick will retry. |
|
|
472
|
+
| `VAULT-NO-ETAG` | Publication was skipped because a remote object had no ETag. |
|
|
473
|
+
| `VAULT-ADOPT-FAILED` | A valid vault credential could not be installed locally; the next tick will retry. |
|
|
474
|
+
|
|
445
475
|
## The `ObjectStore` port
|
|
446
476
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
477
|
+
The `ObjectStore` port has seven methods. `get(key)` returns `{ body, etag }` or `null`
|
|
478
|
+
when the object is absent; `etag` is an opaque compare-and-swap token and may be `null`
|
|
479
|
+
when the store did not report one. `put(key, body)` is the unconditional write used by
|
|
480
|
+
per-runner synchronization. `putIfAbsent(key, body)` creates with a conditional
|
|
481
|
+
`If-None-Match: *` write, and `putIfMatch(key, body, etag)` replaces with a conditional
|
|
482
|
+
`If-Match: <etag>` write. Both return `'written'` or `'conflict'`; only contention
|
|
483
|
+
returns `'conflict'`, while every other error throws. `list(prefix)` returns `{ key, size }` entries
|
|
450
484
|
(`ObjectSummary[]`) — every object under `prefix`, size in bytes. It throws on any error
|
|
451
485
|
and never maps one to `[]` (an empty array means "reached the store, nothing there"); the
|
|
452
486
|
adapter owns pagination, and `delete` is idempotent. S3 vocabulary (`@aws-sdk/client-s3`,
|
package/dist/cli.js
CHANGED
|
@@ -50903,22 +50903,24 @@ function resolveConfig(env4) {
|
|
|
50903
50903
|
const bucket = nonEmpty(env4.LITESTREAM_BUCKET);
|
|
50904
50904
|
const prefix = nonEmpty(env4.LITESTREAM_PREFIX);
|
|
50905
50905
|
const persistenceEnabled = bucket !== null && prefix !== null;
|
|
50906
|
+
const vaultPrefix = persistenceEnabled ? nonEmpty(env4.CREDENTIAL_VAULT_PREFIX)?.replace(/\/+$/, "") || null : null;
|
|
50906
50907
|
return {
|
|
50907
50908
|
homeDir,
|
|
50908
50909
|
claude: {
|
|
50909
50910
|
label: "Claude",
|
|
50910
50911
|
path: `${homeDir}/.claude/.credentials.json`,
|
|
50911
|
-
key: persistenceEnabled ? `${prefix}/claude/credentials.json` : null
|
|
50912
|
+
key: persistenceEnabled ? `${vaultPrefix ?? prefix}/claude/credentials.json` : null
|
|
50912
50913
|
},
|
|
50913
50914
|
opencode: {
|
|
50914
50915
|
label: "OpenCode",
|
|
50915
50916
|
path: `${homeDir}/.local/share/opencode/auth.json`,
|
|
50916
|
-
key: persistenceEnabled ? `${prefix}/opencode/auth.json` : null
|
|
50917
|
+
key: persistenceEnabled ? `${vaultPrefix ?? prefix}/opencode/auth.json` : null
|
|
50917
50918
|
},
|
|
50918
50919
|
opencodeDbPath: `${homeDir}/.local/share/opencode/opencode.db`,
|
|
50919
50920
|
sessionDbRecoveryReportPath: nonEmpty(env4.EVIDENT_SESSION_DB_RECOVERY_REPORT) ?? `${homeDir}/.local/state/evident/session-db-recovery.jsonl`,
|
|
50920
50921
|
bucket: persistenceEnabled ? bucket : null,
|
|
50921
50922
|
prefix: persistenceEnabled ? prefix : null,
|
|
50923
|
+
vaultPrefix,
|
|
50922
50924
|
region: nonEmpty(env4.AWS_REGION),
|
|
50923
50925
|
syncIntervalSeconds: positiveIntOr(env4.CREDS_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL_SECONDS),
|
|
50924
50926
|
walkbackMaxPoints: positiveIntOr(
|
|
@@ -50940,9 +50942,14 @@ function storeOf(config, name) {
|
|
|
50940
50942
|
|
|
50941
50943
|
// src/diagnostics.ts
|
|
50942
50944
|
init_esm_shims();
|
|
50943
|
-
|
|
50944
|
-
|
|
50945
|
+
|
|
50946
|
+
// src/assert-never.ts
|
|
50947
|
+
init_esm_shims();
|
|
50948
|
+
function assertNever(value, subject) {
|
|
50949
|
+
throw new Error(`Unhandled ${subject}: ${JSON.stringify(value)}`);
|
|
50945
50950
|
}
|
|
50951
|
+
|
|
50952
|
+
// src/diagnostics.ts
|
|
50946
50953
|
function describeRestoreOutcome(label, path, outcome) {
|
|
50947
50954
|
switch (outcome.kind) {
|
|
50948
50955
|
case "local":
|
|
@@ -50960,21 +50967,9 @@ function describeRestoreOutcome(label, path, outcome) {
|
|
|
50960
50967
|
case "disabled":
|
|
50961
50968
|
return `No ${label} credentials at ${path}; credential persistence is disabled (LITESTREAM_BUCKET/LITESTREAM_PREFIX unset), so nothing could be restored.`;
|
|
50962
50969
|
default:
|
|
50963
|
-
return assertNever(outcome);
|
|
50970
|
+
return assertNever(outcome, "restore outcome");
|
|
50964
50971
|
}
|
|
50965
50972
|
}
|
|
50966
|
-
function assertNeverSessionDb(value) {
|
|
50967
|
-
throw new Error(`Unhandled session-DB restore outcome: ${JSON.stringify(value)}`);
|
|
50968
|
-
}
|
|
50969
|
-
function assertNeverUnusableReplicaReason(value) {
|
|
50970
|
-
throw new Error(`Unhandled unusable-replica reason: ${JSON.stringify(value)}`);
|
|
50971
|
-
}
|
|
50972
|
-
function assertNeverFatalCause(value) {
|
|
50973
|
-
throw new Error(`Unhandled fatal cause: ${JSON.stringify(value)}`);
|
|
50974
|
-
}
|
|
50975
|
-
function assertNeverNoReplicaLocalDb(value) {
|
|
50976
|
-
throw new Error(`Unhandled noReplica local-DB state: ${JSON.stringify(value)}`);
|
|
50977
|
-
}
|
|
50978
50973
|
function sessionDbExitCode(outcome) {
|
|
50979
50974
|
switch (outcome.kind) {
|
|
50980
50975
|
case "disabled":
|
|
@@ -50992,7 +50987,7 @@ function sessionDbExitCode(outcome) {
|
|
|
50992
50987
|
return 30;
|
|
50993
50988
|
// shared by a genuine misconfig and a deliberate `crash` choice
|
|
50994
50989
|
default:
|
|
50995
|
-
return
|
|
50990
|
+
return assertNever(outcome, "session-DB restore outcome");
|
|
50996
50991
|
}
|
|
50997
50992
|
}
|
|
50998
50993
|
function describeUnusableReplica(reason) {
|
|
@@ -51011,7 +51006,7 @@ function describeUnusableReplica(reason) {
|
|
|
51011
51006
|
case "targetHealthy":
|
|
51012
51007
|
return `${base2} (--on-unusable-replica=prune found a newest L0 object but could not confirm it is corrupt \u2014 it either passed the LTX structural check or could not be re-read \u2014 so it declined to delete it; nothing in S3 was touched.)`;
|
|
51013
51008
|
default:
|
|
51014
|
-
return
|
|
51009
|
+
return assertNever(reason, "unusable-replica reason");
|
|
51015
51010
|
}
|
|
51016
51011
|
}
|
|
51017
51012
|
function describeSessionDbFatal(outcome, config) {
|
|
@@ -51024,7 +51019,7 @@ function describeSessionDbFatal(outcome, config) {
|
|
|
51024
51019
|
case "deliberate":
|
|
51025
51020
|
return `FATAL: ${outcome.detail}`;
|
|
51026
51021
|
default:
|
|
51027
|
-
return
|
|
51022
|
+
return assertNever(outcome.cause, "fatal cause");
|
|
51028
51023
|
}
|
|
51029
51024
|
}
|
|
51030
51025
|
function describeNoReplica(localDb) {
|
|
@@ -51034,7 +51029,7 @@ function describeNoReplica(localDb) {
|
|
|
51034
51029
|
case "empty":
|
|
51035
51030
|
return "litestream restore succeeded but left a zero-byte opencode.db; opencode will create a fresh session DB.";
|
|
51036
51031
|
default:
|
|
51037
|
-
return
|
|
51032
|
+
return assertNever(localDb, "noReplica local-DB state");
|
|
51038
51033
|
}
|
|
51039
51034
|
}
|
|
51040
51035
|
function describeSessionDbClassification(outcome, config) {
|
|
@@ -51059,24 +51054,9 @@ function describeSessionDbClassification(outcome, config) {
|
|
|
51059
51054
|
case "fatal":
|
|
51060
51055
|
return describeSessionDbFatal(outcome, config);
|
|
51061
51056
|
default:
|
|
51062
|
-
return
|
|
51057
|
+
return assertNever(outcome, "session-DB restore outcome");
|
|
51063
51058
|
}
|
|
51064
51059
|
}
|
|
51065
|
-
function assertNeverSessionDbVerify(value) {
|
|
51066
|
-
throw new Error(`Unhandled session-DB verify outcome: ${JSON.stringify(value)}`);
|
|
51067
|
-
}
|
|
51068
|
-
function assertNeverSessionDbVerifySkippedLocalDb(value) {
|
|
51069
|
-
throw new Error(`Unhandled session-DB verify skipped local-DB state: ${JSON.stringify(value)}`);
|
|
51070
|
-
}
|
|
51071
|
-
function assertNeverSessionDbVerifyExhaustedReason(value) {
|
|
51072
|
-
throw new Error(`Unhandled session-DB verify exhausted reason: ${JSON.stringify(value)}`);
|
|
51073
|
-
}
|
|
51074
|
-
function assertNeverSessionDbVerifySeparation(value) {
|
|
51075
|
-
throw new Error(`Unhandled session-DB verify separation: ${JSON.stringify(value)}`);
|
|
51076
|
-
}
|
|
51077
|
-
function assertNeverSessionDbVerifyLocalDb(value) {
|
|
51078
|
-
throw new Error(`Unhandled session-DB verify local-DB disposal: ${JSON.stringify(value)}`);
|
|
51079
|
-
}
|
|
51080
51060
|
function sessionDbVerifyExitCode(outcome) {
|
|
51081
51061
|
switch (outcome.kind) {
|
|
51082
51062
|
case "skipped":
|
|
@@ -51093,7 +51073,7 @@ function sessionDbVerifyExitCode(outcome) {
|
|
|
51093
51073
|
case "unreachable":
|
|
51094
51074
|
return 34;
|
|
51095
51075
|
default:
|
|
51096
|
-
return
|
|
51076
|
+
return assertNever(outcome.separation, "session-DB verify separation");
|
|
51097
51077
|
}
|
|
51098
51078
|
switch (outcome.localDb.kind) {
|
|
51099
51079
|
case "discarded":
|
|
@@ -51102,10 +51082,10 @@ function sessionDbVerifyExitCode(outcome) {
|
|
|
51102
51082
|
case "discardFailed":
|
|
51103
51083
|
return 34;
|
|
51104
51084
|
default:
|
|
51105
|
-
return
|
|
51085
|
+
return assertNever(outcome.localDb, "session-DB verify local-DB disposal");
|
|
51106
51086
|
}
|
|
51107
51087
|
default:
|
|
51108
|
-
return
|
|
51088
|
+
return assertNever(outcome, "session-DB verify outcome");
|
|
51109
51089
|
}
|
|
51110
51090
|
}
|
|
51111
51091
|
function describeSessionDbVerifySkipped(localDb) {
|
|
@@ -51115,7 +51095,7 @@ function describeSessionDbVerifySkipped(localDb) {
|
|
|
51115
51095
|
case "empty":
|
|
51116
51096
|
return "opencode.db is zero bytes; nothing to verify at boot.";
|
|
51117
51097
|
default:
|
|
51118
|
-
return
|
|
51098
|
+
return assertNever(localDb, "session-DB verify skipped local-DB state");
|
|
51119
51099
|
}
|
|
51120
51100
|
}
|
|
51121
51101
|
function describeSessionDbVerifyExhausted(outcome) {
|
|
@@ -51135,7 +51115,7 @@ function describeSessionDbVerifyExhausted(outcome) {
|
|
|
51135
51115
|
reason = `opencode.db failed its integrity check; the walkback search budget ran out before every retained restore point was tried (${tried}) \u2014 widen it with EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS/EVIDENT_SESSION_DB_WALKBACK_BUDGET_SECONDS if there was more to try`;
|
|
51136
51116
|
break;
|
|
51137
51117
|
default:
|
|
51138
|
-
return
|
|
51118
|
+
return assertNever(outcome.reason, "session-DB verify exhausted reason");
|
|
51139
51119
|
}
|
|
51140
51120
|
let separation;
|
|
51141
51121
|
switch (outcome.separation.kind) {
|
|
@@ -51153,7 +51133,7 @@ function describeSessionDbVerifyExhausted(outcome) {
|
|
|
51153
51133
|
separation = ` Boot is stopping because replica separation could not be proven (${outcome.separation.detail}); backup history remains readable at its original key or quarantine destination.`;
|
|
51154
51134
|
break;
|
|
51155
51135
|
default:
|
|
51156
|
-
return
|
|
51136
|
+
return assertNever(outcome.separation, "session-DB verify separation");
|
|
51157
51137
|
}
|
|
51158
51138
|
let localDb;
|
|
51159
51139
|
switch (outcome.localDb.kind) {
|
|
@@ -51167,7 +51147,7 @@ function describeSessionDbVerifyExhausted(outcome) {
|
|
|
51167
51147
|
localDb = ` Boot is stopping because local disposal could not be proven; removed=${outcome.localDb.removed.join(", ") || "(none)"} survived=${outcome.localDb.survived.join(", ") || "(none)"}. See SESSION-DB-LOCAL-DISCARD-FAILED.`;
|
|
51168
51148
|
break;
|
|
51169
51149
|
default:
|
|
51170
|
-
return
|
|
51150
|
+
return assertNever(outcome.localDb, "session-DB verify local-DB disposal");
|
|
51171
51151
|
}
|
|
51172
51152
|
return `SESSION-DB-INTEGRITY-EXHAUSTED: ${reason}.${separation}${localDb}`;
|
|
51173
51153
|
}
|
|
@@ -51182,7 +51162,7 @@ function describeSessionDbVerification(outcome) {
|
|
|
51182
51162
|
case "exhausted":
|
|
51183
51163
|
return describeSessionDbVerifyExhausted(outcome);
|
|
51184
51164
|
default:
|
|
51185
|
-
return
|
|
51165
|
+
return assertNever(outcome, "session-DB verify outcome");
|
|
51186
51166
|
}
|
|
51187
51167
|
}
|
|
51188
51168
|
|
|
@@ -51339,20 +51319,23 @@ init_esm_shims();
|
|
|
51339
51319
|
|
|
51340
51320
|
// src/valid-json.ts
|
|
51341
51321
|
init_esm_shims();
|
|
51342
|
-
|
|
51343
|
-
|
|
51322
|
+
function isValidJsonBytes(contents) {
|
|
51323
|
+
if (contents.length === 0) return false;
|
|
51344
51324
|
try {
|
|
51345
|
-
|
|
51325
|
+
const value = JSON.parse(contents.toString("utf8"));
|
|
51326
|
+
return value !== null && value !== false;
|
|
51346
51327
|
} catch {
|
|
51347
51328
|
return false;
|
|
51348
51329
|
}
|
|
51349
|
-
|
|
51330
|
+
}
|
|
51331
|
+
async function isValidJson(fileOps, path) {
|
|
51332
|
+
let contents;
|
|
51350
51333
|
try {
|
|
51351
|
-
|
|
51352
|
-
return value !== null && value !== false;
|
|
51334
|
+
contents = await fileOps.readFile(path);
|
|
51353
51335
|
} catch {
|
|
51354
51336
|
return false;
|
|
51355
51337
|
}
|
|
51338
|
+
return isValidJsonBytes(contents);
|
|
51356
51339
|
}
|
|
51357
51340
|
|
|
51358
51341
|
// src/model-auth.ts
|
|
@@ -51409,55 +51392,20 @@ function isDeletableReplicaKey(prefix, key) {
|
|
|
51409
51392
|
|
|
51410
51393
|
// src/restore.ts
|
|
51411
51394
|
init_esm_shims();
|
|
51395
|
+
import { dirname as dirname4 } from "node:path";
|
|
51396
|
+
|
|
51397
|
+
// src/credential-install.ts
|
|
51398
|
+
init_esm_shims();
|
|
51412
51399
|
import { dirname as dirname3 } from "node:path";
|
|
51413
|
-
async function
|
|
51414
|
-
|
|
51400
|
+
async function installCredentialBytes({
|
|
51401
|
+
label,
|
|
51402
|
+
path,
|
|
51403
|
+
body,
|
|
51404
|
+
source,
|
|
51405
|
+
fileOps,
|
|
51406
|
+
log
|
|
51407
|
+
}) {
|
|
51415
51408
|
const dir = dirname3(path);
|
|
51416
|
-
try {
|
|
51417
|
-
await fileOps.mkdirp(dir);
|
|
51418
|
-
} catch (error) {
|
|
51419
|
-
log(`WARNING: could not create ${label} credential directory ${dir}: ${describeError(error)}`);
|
|
51420
|
-
return { kind: "failed", reason: `could not create ${dir}: ${describeError(error)}` };
|
|
51421
|
-
}
|
|
51422
|
-
try {
|
|
51423
|
-
await fileOps.chmod(dir, 448);
|
|
51424
|
-
} catch (error) {
|
|
51425
|
-
log(
|
|
51426
|
-
`WARNING: could not restrict ${label} credential directory ${dir}: ${describeError(error)}`
|
|
51427
|
-
);
|
|
51428
|
-
}
|
|
51429
|
-
const local = await fileOps.stat(path);
|
|
51430
|
-
if (local !== null && local.size > 0 && !await isValidJson(fileOps, path)) {
|
|
51431
|
-
log(
|
|
51432
|
-
`WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping restore.`
|
|
51433
|
-
);
|
|
51434
|
-
return { kind: "localInvalid" };
|
|
51435
|
-
}
|
|
51436
|
-
if (local !== null && local.size === 0) {
|
|
51437
|
-
log(
|
|
51438
|
-
`Local ${label} credentials at ${path} are empty; will attempt to restore from S3 instead of protecting them.`
|
|
51439
|
-
);
|
|
51440
|
-
}
|
|
51441
|
-
if (await isValidJson(fileOps, path)) {
|
|
51442
|
-
await chmodOrWarn(fileOps, log, path, `local ${label} credentials at ${path}`);
|
|
51443
|
-
log(`Keeping valid local ${label} credentials at ${path}; no restore.`);
|
|
51444
|
-
return { kind: "local" };
|
|
51445
|
-
}
|
|
51446
|
-
if (store === null || bucket === null || key === null) {
|
|
51447
|
-
return { kind: "disabled" };
|
|
51448
|
-
}
|
|
51449
|
-
const location = `s3://${bucket}/${key}`;
|
|
51450
|
-
let body;
|
|
51451
|
-
try {
|
|
51452
|
-
body = await store.get(key);
|
|
51453
|
-
} catch (error) {
|
|
51454
|
-
log(`WARNING: ${label} credentials download failed from ${location}: ${describeError(error)}`);
|
|
51455
|
-
return { kind: "failed", reason: describeError(error) };
|
|
51456
|
-
}
|
|
51457
|
-
if (body === null) {
|
|
51458
|
-
log(`No persisted ${label} credentials at ${location}; nothing to restore.`);
|
|
51459
|
-
return { kind: "missing" };
|
|
51460
|
-
}
|
|
51461
51409
|
const temp = `${dir}/.credentials.${Math.random().toString(36).slice(2, 8)}`;
|
|
51462
51410
|
try {
|
|
51463
51411
|
await fileOps.createExclusive(temp);
|
|
@@ -51486,12 +51434,12 @@ async function restore(params) {
|
|
|
51486
51434
|
await fileOps.writeFile(temp, body);
|
|
51487
51435
|
} catch (error) {
|
|
51488
51436
|
await fileOps.remove(temp);
|
|
51489
|
-
log(`WARNING: ${label} credentials download failed from ${
|
|
51437
|
+
log(`WARNING: ${label} credentials download failed from ${source}: ${describeError(error)}`);
|
|
51490
51438
|
return { kind: "failed", reason: describeError(error) };
|
|
51491
51439
|
}
|
|
51492
51440
|
if (!await isValidJson(fileOps, temp)) {
|
|
51493
51441
|
await fileOps.remove(temp);
|
|
51494
|
-
log(`WARNING: discarded invalid ${label} credentials from ${
|
|
51442
|
+
log(`WARNING: discarded invalid ${label} credentials from ${source}.`);
|
|
51495
51443
|
return { kind: "invalid" };
|
|
51496
51444
|
}
|
|
51497
51445
|
try {
|
|
@@ -51504,8 +51452,7 @@ async function restore(params) {
|
|
|
51504
51452
|
return { kind: "failed", reason: `could not install ${path}: ${describeError(error)}` };
|
|
51505
51453
|
}
|
|
51506
51454
|
await chmodOrWarn(fileOps, log, path, `restored ${label} credentials at ${path}`);
|
|
51507
|
-
|
|
51508
|
-
return { kind: "restored" };
|
|
51455
|
+
return { kind: "installed" };
|
|
51509
51456
|
}
|
|
51510
51457
|
async function chmodOrWarn(fileOps, log, path, subject) {
|
|
51511
51458
|
try {
|
|
@@ -51515,6 +51462,71 @@ async function chmodOrWarn(fileOps, log, path, subject) {
|
|
|
51515
51462
|
}
|
|
51516
51463
|
}
|
|
51517
51464
|
|
|
51465
|
+
// src/restore.ts
|
|
51466
|
+
async function restore(params) {
|
|
51467
|
+
const { label, path, bucket, key, store, fileOps, log } = params;
|
|
51468
|
+
const dir = dirname4(path);
|
|
51469
|
+
try {
|
|
51470
|
+
await fileOps.mkdirp(dir);
|
|
51471
|
+
} catch (error) {
|
|
51472
|
+
log(`WARNING: could not create ${label} credential directory ${dir}: ${describeError(error)}`);
|
|
51473
|
+
return { kind: "failed", reason: `could not create ${dir}: ${describeError(error)}` };
|
|
51474
|
+
}
|
|
51475
|
+
try {
|
|
51476
|
+
await fileOps.chmod(dir, 448);
|
|
51477
|
+
} catch (error) {
|
|
51478
|
+
log(
|
|
51479
|
+
`WARNING: could not restrict ${label} credential directory ${dir}: ${describeError(error)}`
|
|
51480
|
+
);
|
|
51481
|
+
}
|
|
51482
|
+
const local = await fileOps.stat(path);
|
|
51483
|
+
if (local !== null && local.size > 0 && !await isValidJson(fileOps, path)) {
|
|
51484
|
+
log(
|
|
51485
|
+
`WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping restore.`
|
|
51486
|
+
);
|
|
51487
|
+
return { kind: "localInvalid" };
|
|
51488
|
+
}
|
|
51489
|
+
if (local !== null && local.size === 0) {
|
|
51490
|
+
log(
|
|
51491
|
+
`Local ${label} credentials at ${path} are empty; will attempt to restore from S3 instead of protecting them.`
|
|
51492
|
+
);
|
|
51493
|
+
}
|
|
51494
|
+
if (await isValidJson(fileOps, path)) {
|
|
51495
|
+
await chmodOrWarn(fileOps, log, path, `local ${label} credentials at ${path}`);
|
|
51496
|
+
log(`Keeping valid local ${label} credentials at ${path}; no restore.`);
|
|
51497
|
+
return { kind: "local" };
|
|
51498
|
+
}
|
|
51499
|
+
if (store === null || bucket === null || key === null) {
|
|
51500
|
+
return { kind: "disabled" };
|
|
51501
|
+
}
|
|
51502
|
+
const location = `s3://${bucket}/${key}`;
|
|
51503
|
+
let object;
|
|
51504
|
+
try {
|
|
51505
|
+
object = await store.get(key);
|
|
51506
|
+
} catch (error) {
|
|
51507
|
+
log(`WARNING: ${label} credentials download failed from ${location}: ${describeError(error)}`);
|
|
51508
|
+
return { kind: "failed", reason: describeError(error) };
|
|
51509
|
+
}
|
|
51510
|
+
if (object === null) {
|
|
51511
|
+
log(`No persisted ${label} credentials at ${location}; nothing to restore.`);
|
|
51512
|
+
return { kind: "missing" };
|
|
51513
|
+
}
|
|
51514
|
+
const installation = await installCredentialBytes({
|
|
51515
|
+
label,
|
|
51516
|
+
path,
|
|
51517
|
+
body: object.body,
|
|
51518
|
+
source: location,
|
|
51519
|
+
fileOps,
|
|
51520
|
+
log
|
|
51521
|
+
});
|
|
51522
|
+
if (installation.kind === "installed") {
|
|
51523
|
+
log(`Restored ${label} credentials from ${location} to ${path}.`);
|
|
51524
|
+
return { kind: "restored" };
|
|
51525
|
+
}
|
|
51526
|
+
if (installation.kind === "invalid") return { kind: "invalid" };
|
|
51527
|
+
return { kind: "failed", reason: installation.reason };
|
|
51528
|
+
}
|
|
51529
|
+
|
|
51518
51530
|
// src/s3-object-store.ts
|
|
51519
51531
|
init_esm_shims();
|
|
51520
51532
|
|
|
@@ -61293,6 +61305,11 @@ function isNotFound(error) {
|
|
|
61293
61305
|
if (candidate.name === "NoSuchKey" || candidate.name === "NotFound") return true;
|
|
61294
61306
|
return candidate.$metadata?.httpStatusCode === 404;
|
|
61295
61307
|
}
|
|
61308
|
+
function isConditionalConflict(error) {
|
|
61309
|
+
if (typeof error !== "object" || error === null) return false;
|
|
61310
|
+
const candidate = error;
|
|
61311
|
+
return candidate.name === "PreconditionFailed" || candidate.name === "ConditionalRequestConflict" || candidate.$metadata?.httpStatusCode === 412 || candidate.$metadata?.httpStatusCode === 409;
|
|
61312
|
+
}
|
|
61296
61313
|
var S3ObjectStore = class {
|
|
61297
61314
|
constructor(bucket, region = null, client) {
|
|
61298
61315
|
this.bucket = bucket;
|
|
@@ -61305,7 +61322,10 @@ var S3ObjectStore = class {
|
|
|
61305
61322
|
new GetObjectCommand({ Bucket: this.bucket, Key: key })
|
|
61306
61323
|
);
|
|
61307
61324
|
if (!response.Body) return null;
|
|
61308
|
-
return
|
|
61325
|
+
return {
|
|
61326
|
+
body: Buffer.from(await response.Body.transformToByteArray()),
|
|
61327
|
+
etag: response.ETag ?? null
|
|
61328
|
+
};
|
|
61309
61329
|
} catch (error) {
|
|
61310
61330
|
if (isNotFound(error)) return null;
|
|
61311
61331
|
throw error;
|
|
@@ -61314,6 +61334,38 @@ var S3ObjectStore = class {
|
|
|
61314
61334
|
async put(key, body) {
|
|
61315
61335
|
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body }));
|
|
61316
61336
|
}
|
|
61337
|
+
async putIfAbsent(key, body) {
|
|
61338
|
+
try {
|
|
61339
|
+
await this.client.send(
|
|
61340
|
+
new PutObjectCommand({
|
|
61341
|
+
Bucket: this.bucket,
|
|
61342
|
+
Key: key,
|
|
61343
|
+
Body: body,
|
|
61344
|
+
IfNoneMatch: "*"
|
|
61345
|
+
})
|
|
61346
|
+
);
|
|
61347
|
+
return "written";
|
|
61348
|
+
} catch (error) {
|
|
61349
|
+
if (isConditionalConflict(error)) return "conflict";
|
|
61350
|
+
throw error;
|
|
61351
|
+
}
|
|
61352
|
+
}
|
|
61353
|
+
async putIfMatch(key, body, etag) {
|
|
61354
|
+
try {
|
|
61355
|
+
await this.client.send(
|
|
61356
|
+
new PutObjectCommand({
|
|
61357
|
+
Bucket: this.bucket,
|
|
61358
|
+
Key: key,
|
|
61359
|
+
Body: body,
|
|
61360
|
+
IfMatch: etag
|
|
61361
|
+
})
|
|
61362
|
+
);
|
|
61363
|
+
return "written";
|
|
61364
|
+
} catch (error) {
|
|
61365
|
+
if (isConditionalConflict(error) || isNotFound(error)) return "conflict";
|
|
61366
|
+
throw error;
|
|
61367
|
+
}
|
|
61368
|
+
}
|
|
61317
61369
|
async list(prefix) {
|
|
61318
61370
|
const objects = [];
|
|
61319
61371
|
let continuationToken;
|
|
@@ -61421,7 +61473,8 @@ async function pruneNewestL0(store, prefix, keys, log) {
|
|
|
61421
61473
|
}
|
|
61422
61474
|
let bytes;
|
|
61423
61475
|
try {
|
|
61424
|
-
|
|
61476
|
+
const object = await store.get(target);
|
|
61477
|
+
bytes = object === null ? null : object.body;
|
|
61425
61478
|
} catch (error) {
|
|
61426
61479
|
log(
|
|
61427
61480
|
`WARNING: could not re-read ${target} to check whether it is actually corrupt before pruning it: ${describeError(error)}. Declining to prune without positive evidence.`
|
|
@@ -61540,9 +61593,6 @@ async function clearReplica(store, prefix, keys, log) {
|
|
|
61540
61593
|
}
|
|
61541
61594
|
|
|
61542
61595
|
// src/session-db.ts
|
|
61543
|
-
function assertNeverStrategy(value) {
|
|
61544
|
-
throw new Error(`Unhandled unusable-replica strategy: ${JSON.stringify(value)}`);
|
|
61545
|
-
}
|
|
61546
61596
|
async function classifySessionDbRestore(params) {
|
|
61547
61597
|
const outcome = await decideSessionDbRestore(params);
|
|
61548
61598
|
if (outcome.kind === "unusableReplica") {
|
|
@@ -61634,7 +61684,7 @@ async function decideSessionDbRestore(params) {
|
|
|
61634
61684
|
return { kind: "recovered", strategy: "clear", deleted: result.deleted, recovery };
|
|
61635
61685
|
}
|
|
61636
61686
|
default:
|
|
61637
|
-
return
|
|
61687
|
+
return assertNever(strategy, "unusable-replica strategy");
|
|
61638
61688
|
}
|
|
61639
61689
|
}
|
|
61640
61690
|
function shouldEscalateToQuarantine(reason, prefix, keys) {
|
|
@@ -61653,7 +61703,7 @@ async function discardLocalDebris(fileOps, dbPath, log) {
|
|
|
61653
61703
|
|
|
61654
61704
|
// src/session-db-recovery-report.ts
|
|
61655
61705
|
init_esm_shims();
|
|
61656
|
-
import { dirname as
|
|
61706
|
+
import { dirname as dirname5 } from "node:path";
|
|
61657
61707
|
function base(at2, stage) {
|
|
61658
61708
|
return {
|
|
61659
61709
|
v: 1,
|
|
@@ -61761,7 +61811,7 @@ async function appendSessionDbRecoveryRecord(record, path, fileOps, log) {
|
|
|
61761
61811
|
);
|
|
61762
61812
|
return;
|
|
61763
61813
|
}
|
|
61764
|
-
await fileOps.mkdirp(
|
|
61814
|
+
await fileOps.mkdirp(dirname5(path));
|
|
61765
61815
|
await fileOps.appendFile(path, line);
|
|
61766
61816
|
} catch (error) {
|
|
61767
61817
|
log(
|
|
@@ -62108,21 +62158,25 @@ var nodeSqliteIntegrity = {
|
|
|
62108
62158
|
|
|
62109
62159
|
// src/sync.ts
|
|
62110
62160
|
init_esm_shims();
|
|
62111
|
-
|
|
62112
|
-
|
|
62113
|
-
|
|
62114
|
-
|
|
62115
|
-
|
|
62161
|
+
|
|
62162
|
+
// src/local-credential.ts
|
|
62163
|
+
init_esm_shims();
|
|
62164
|
+
async function readLocalCredential({
|
|
62165
|
+
label,
|
|
62166
|
+
path,
|
|
62167
|
+
fileOps,
|
|
62168
|
+
log
|
|
62169
|
+
}) {
|
|
62116
62170
|
const exists = await fileOps.stat(path) !== null;
|
|
62117
62171
|
const valid = await isValidJson(fileOps, path);
|
|
62118
62172
|
if (exists && !valid) {
|
|
62119
62173
|
log(
|
|
62120
62174
|
`WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync.`
|
|
62121
62175
|
);
|
|
62122
|
-
return {
|
|
62176
|
+
return { kind: "invalid" };
|
|
62123
62177
|
}
|
|
62124
62178
|
if (!valid) {
|
|
62125
|
-
return {
|
|
62179
|
+
return { kind: "absent" };
|
|
62126
62180
|
}
|
|
62127
62181
|
try {
|
|
62128
62182
|
await fileOps.chmod(path, 384);
|
|
@@ -62138,8 +62192,28 @@ async function sync(params) {
|
|
|
62138
62192
|
log(
|
|
62139
62193
|
`WARNING: could not hash local ${label} credentials at ${path} (${describeError(error)}); skipping this sync tick.`
|
|
62140
62194
|
);
|
|
62195
|
+
return { kind: "hashFailed" };
|
|
62196
|
+
}
|
|
62197
|
+
return { kind: "ok", hash };
|
|
62198
|
+
}
|
|
62199
|
+
|
|
62200
|
+
// src/sync.ts
|
|
62201
|
+
async function sync(params) {
|
|
62202
|
+
const { label, path, bucket, key, store, lastHash, fileOps, log } = params;
|
|
62203
|
+
if (store === null || bucket === null || key === null) {
|
|
62204
|
+
return { outcome: { kind: "disabled" }, hash: lastHash };
|
|
62205
|
+
}
|
|
62206
|
+
const local = await readLocalCredential({ label, path, fileOps, log });
|
|
62207
|
+
if (local.kind === "invalid") {
|
|
62208
|
+
return { outcome: { kind: "localInvalid" }, hash: lastHash };
|
|
62209
|
+
}
|
|
62210
|
+
if (local.kind === "absent") {
|
|
62211
|
+
return { outcome: { kind: "absent" }, hash: lastHash };
|
|
62212
|
+
}
|
|
62213
|
+
if (local.kind === "hashFailed") {
|
|
62141
62214
|
return { outcome: { kind: "hashFailed" }, hash: lastHash };
|
|
62142
62215
|
}
|
|
62216
|
+
const { hash } = local;
|
|
62143
62217
|
if (hash === lastHash) {
|
|
62144
62218
|
return { outcome: { kind: "unchanged" }, hash: lastHash };
|
|
62145
62219
|
}
|
|
@@ -62154,6 +62228,256 @@ async function sync(params) {
|
|
|
62154
62228
|
return { outcome: { kind: "uploaded" }, hash };
|
|
62155
62229
|
}
|
|
62156
62230
|
|
|
62231
|
+
// src/vault-sync.ts
|
|
62232
|
+
init_esm_shims();
|
|
62233
|
+
import { createHash as createHash10 } from "node:crypto";
|
|
62234
|
+
import { dirname as dirname6 } from "node:path";
|
|
62235
|
+
async function syncVault(params) {
|
|
62236
|
+
const { label, path, bucket, key, store, lastHash, fileOps, log } = params;
|
|
62237
|
+
if (store === null || bucket === null || key === null) {
|
|
62238
|
+
return { outcome: { kind: "disabled" }, hash: lastHash };
|
|
62239
|
+
}
|
|
62240
|
+
const location = `s3://${bucket}/${key}`;
|
|
62241
|
+
let remote;
|
|
62242
|
+
try {
|
|
62243
|
+
remote = await store.get(key);
|
|
62244
|
+
} catch (error) {
|
|
62245
|
+
const reason = describeError(error);
|
|
62246
|
+
log(
|
|
62247
|
+
`WARNING: VAULT-REMOTE-UNREADABLE: could not read the shared ${label} credential at ${location}: ${reason}`
|
|
62248
|
+
);
|
|
62249
|
+
return { outcome: { kind: "remoteFailed", reason }, hash: lastHash };
|
|
62250
|
+
}
|
|
62251
|
+
let local;
|
|
62252
|
+
try {
|
|
62253
|
+
local = await readLocalCredential({ label, path, fileOps, log });
|
|
62254
|
+
} catch (error) {
|
|
62255
|
+
log(
|
|
62256
|
+
`WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync. (${describeError(error)})`
|
|
62257
|
+
);
|
|
62258
|
+
return { outcome: { kind: "localInvalid" }, hash: lastHash };
|
|
62259
|
+
}
|
|
62260
|
+
if (local.kind === "invalid") {
|
|
62261
|
+
return { outcome: { kind: "localInvalid" }, hash: lastHash };
|
|
62262
|
+
}
|
|
62263
|
+
if (local.kind === "hashFailed") {
|
|
62264
|
+
return { outcome: { kind: "hashFailed" }, hash: lastHash };
|
|
62265
|
+
}
|
|
62266
|
+
if (remote === null) {
|
|
62267
|
+
if (local.kind === "absent") return { outcome: { kind: "absent" }, hash: lastHash };
|
|
62268
|
+
let body;
|
|
62269
|
+
try {
|
|
62270
|
+
body = await fileOps.readFile(path);
|
|
62271
|
+
} catch (error) {
|
|
62272
|
+
log(
|
|
62273
|
+
`WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync. (${describeError(error)})`
|
|
62274
|
+
);
|
|
62275
|
+
return { outcome: { kind: "localInvalid" }, hash: lastHash };
|
|
62276
|
+
}
|
|
62277
|
+
return publish(
|
|
62278
|
+
{ ...params, bucket, key, store, lastHash, log },
|
|
62279
|
+
body,
|
|
62280
|
+
local.hash,
|
|
62281
|
+
null,
|
|
62282
|
+
location
|
|
62283
|
+
);
|
|
62284
|
+
}
|
|
62285
|
+
const remoteHash = hashBytes(remote.body);
|
|
62286
|
+
const remoteValid = isValidJsonBytes(remote.body);
|
|
62287
|
+
if (local.kind === "absent") {
|
|
62288
|
+
if (!remoteValid) {
|
|
62289
|
+
log(
|
|
62290
|
+
`WARNING: VAULT-REMOTE-INVALID: the shared ${label} credential at ${location} is not valid JSON; keeping this runner's local file and not adopting it.`
|
|
62291
|
+
);
|
|
62292
|
+
return { outcome: { kind: "remoteInvalid" }, hash: lastHash };
|
|
62293
|
+
}
|
|
62294
|
+
return adopt2(
|
|
62295
|
+
{ ...params, bucket, key, store, lastHash, log },
|
|
62296
|
+
remote.body,
|
|
62297
|
+
remoteHash,
|
|
62298
|
+
location
|
|
62299
|
+
);
|
|
62300
|
+
}
|
|
62301
|
+
let localBody;
|
|
62302
|
+
try {
|
|
62303
|
+
localBody = await fileOps.readFile(path);
|
|
62304
|
+
} catch (error) {
|
|
62305
|
+
log(
|
|
62306
|
+
`WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync. (${describeError(error)})`
|
|
62307
|
+
);
|
|
62308
|
+
return { outcome: { kind: "localInvalid" }, hash: lastHash };
|
|
62309
|
+
}
|
|
62310
|
+
if (remoteValid && remoteHash === local.hash) {
|
|
62311
|
+
return { outcome: { kind: "unchanged" }, hash: remoteHash };
|
|
62312
|
+
}
|
|
62313
|
+
if (!remoteValid) {
|
|
62314
|
+
if (remote.etag === null) {
|
|
62315
|
+
return noEtag({ label, location, lastHash, log });
|
|
62316
|
+
}
|
|
62317
|
+
log(
|
|
62318
|
+
`WARNING: VAULT-REMOTE-INVALID: the shared ${label} credential at ${location} is not valid JSON; replacing it with this runner's local credential.`
|
|
62319
|
+
);
|
|
62320
|
+
return publish(
|
|
62321
|
+
{ ...params, bucket, key, store, lastHash, log },
|
|
62322
|
+
localBody,
|
|
62323
|
+
local.hash,
|
|
62324
|
+
remote,
|
|
62325
|
+
location
|
|
62326
|
+
);
|
|
62327
|
+
}
|
|
62328
|
+
if (remoteHash !== lastHash) {
|
|
62329
|
+
return adopt2(
|
|
62330
|
+
{ ...params, bucket, key, store, lastHash, log },
|
|
62331
|
+
remote.body,
|
|
62332
|
+
remoteHash,
|
|
62333
|
+
location
|
|
62334
|
+
);
|
|
62335
|
+
}
|
|
62336
|
+
if (remote.etag === null) {
|
|
62337
|
+
return noEtag({ label, location, lastHash, log });
|
|
62338
|
+
}
|
|
62339
|
+
return publish(
|
|
62340
|
+
{ ...params, bucket, key, store, lastHash, log },
|
|
62341
|
+
localBody,
|
|
62342
|
+
local.hash,
|
|
62343
|
+
remote,
|
|
62344
|
+
location
|
|
62345
|
+
);
|
|
62346
|
+
}
|
|
62347
|
+
function isVaultCredentialPersisted(outcome) {
|
|
62348
|
+
switch (outcome.kind) {
|
|
62349
|
+
case "disabled":
|
|
62350
|
+
case "absent":
|
|
62351
|
+
case "unchanged":
|
|
62352
|
+
case "published":
|
|
62353
|
+
case "adopted":
|
|
62354
|
+
return true;
|
|
62355
|
+
case "localInvalid":
|
|
62356
|
+
case "hashFailed":
|
|
62357
|
+
case "adoptFailed":
|
|
62358
|
+
case "contended":
|
|
62359
|
+
case "remoteFailed":
|
|
62360
|
+
case "remoteInvalid":
|
|
62361
|
+
case "noEtag":
|
|
62362
|
+
return false;
|
|
62363
|
+
default:
|
|
62364
|
+
return assertNever(outcome, "vault sync outcome");
|
|
62365
|
+
}
|
|
62366
|
+
}
|
|
62367
|
+
async function publish(params, body, hash, remote, location) {
|
|
62368
|
+
const { label, key, store, lastHash, log } = params;
|
|
62369
|
+
if (store === null || key === null) return { outcome: { kind: "disabled" }, hash: lastHash };
|
|
62370
|
+
if (remote !== null && remote.etag === null) {
|
|
62371
|
+
return noEtag({ label, location, lastHash, log });
|
|
62372
|
+
}
|
|
62373
|
+
let result;
|
|
62374
|
+
try {
|
|
62375
|
+
if (remote === null) {
|
|
62376
|
+
result = await store.putIfAbsent(key, body);
|
|
62377
|
+
} else {
|
|
62378
|
+
const { etag } = remote;
|
|
62379
|
+
if (etag === null) return noEtag({ label, location, lastHash, log });
|
|
62380
|
+
result = await store.putIfMatch(key, body, etag);
|
|
62381
|
+
}
|
|
62382
|
+
} catch (error) {
|
|
62383
|
+
const reason = describeError(error);
|
|
62384
|
+
log(`WARNING: ${label} credentials upload failed to ${location}: ${reason}`);
|
|
62385
|
+
return { outcome: { kind: "remoteFailed", reason }, hash: lastHash };
|
|
62386
|
+
}
|
|
62387
|
+
if (result === "written") {
|
|
62388
|
+
const mode = remote === null ? "created" : "replaced";
|
|
62389
|
+
const previous = remote === null ? "" : ` previous=${shortHash(lastHash ?? hashBytes(remote.body))}`;
|
|
62390
|
+
log(
|
|
62391
|
+
`VAULT-PUBLISHED: ${mode === "created" ? "created" : "replaced"} the shared ${label} credential at ${location} hash=${shortHash(hash)}${previous}`
|
|
62392
|
+
);
|
|
62393
|
+
return { outcome: { kind: "published", mode }, hash };
|
|
62394
|
+
}
|
|
62395
|
+
log(
|
|
62396
|
+
`WARNING: VAULT-CONTENDED: another writer changed the shared ${label} credential at ${location} first; adopting theirs instead of overwriting it.`
|
|
62397
|
+
);
|
|
62398
|
+
let winner;
|
|
62399
|
+
try {
|
|
62400
|
+
winner = await store.get(key);
|
|
62401
|
+
} catch (error) {
|
|
62402
|
+
return unresolvedContention(params, location, describeError(error));
|
|
62403
|
+
}
|
|
62404
|
+
if (winner === null)
|
|
62405
|
+
return unresolvedContention(params, location, "the winning object is absent");
|
|
62406
|
+
if (!isValidJsonBytes(winner.body)) {
|
|
62407
|
+
return unresolvedContention(params, location, "the winning object is not valid JSON");
|
|
62408
|
+
}
|
|
62409
|
+
const winnerHash = hashBytes(winner.body);
|
|
62410
|
+
if (winnerHash === hash) return { outcome: { kind: "unchanged" }, hash: winnerHash };
|
|
62411
|
+
return adopt2(params, winner.body, winnerHash, location);
|
|
62412
|
+
}
|
|
62413
|
+
async function adopt2(params, body, hash, location) {
|
|
62414
|
+
const { label, path, fileOps, log, lastHash } = params;
|
|
62415
|
+
const dir = dirname6(path);
|
|
62416
|
+
try {
|
|
62417
|
+
await fileOps.mkdirp(dir);
|
|
62418
|
+
} catch (error) {
|
|
62419
|
+
return adoptFailed(params, location, describeError(error));
|
|
62420
|
+
}
|
|
62421
|
+
let installation;
|
|
62422
|
+
try {
|
|
62423
|
+
installation = await installCredentialBytes({
|
|
62424
|
+
label,
|
|
62425
|
+
path,
|
|
62426
|
+
body,
|
|
62427
|
+
source: location,
|
|
62428
|
+
fileOps,
|
|
62429
|
+
log
|
|
62430
|
+
});
|
|
62431
|
+
} catch (error) {
|
|
62432
|
+
return adoptFailed(params, location, describeError(error));
|
|
62433
|
+
}
|
|
62434
|
+
if (installation.kind === "installed") {
|
|
62435
|
+
log(
|
|
62436
|
+
`VAULT-ADOPTED: installed the shared ${label} credential from ${location} hash=${shortHash(hash)} previous=${lastHash === null ? "none" : shortHash(lastHash)}`
|
|
62437
|
+
);
|
|
62438
|
+
return { outcome: { kind: "adopted" }, hash };
|
|
62439
|
+
}
|
|
62440
|
+
return adoptFailed(
|
|
62441
|
+
params,
|
|
62442
|
+
location,
|
|
62443
|
+
installation.kind === "invalid" ? "the winning object was not valid JSON" : installation.reason
|
|
62444
|
+
);
|
|
62445
|
+
}
|
|
62446
|
+
function adoptFailed(params, location, reason) {
|
|
62447
|
+
const { label, log, lastHash } = params;
|
|
62448
|
+
log(
|
|
62449
|
+
`WARNING: VAULT-ADOPT-FAILED: could not install the shared ${label} credential from ${location} (${reason}); keeping the local file and retrying on the next tick.`
|
|
62450
|
+
);
|
|
62451
|
+
return { outcome: { kind: "adoptFailed" }, hash: lastHash };
|
|
62452
|
+
}
|
|
62453
|
+
function unresolvedContention(params, location, reason) {
|
|
62454
|
+
const { label, log, lastHash } = params;
|
|
62455
|
+
log(
|
|
62456
|
+
`WARNING: VAULT-CONTENDED-UNRESOLVED: could not read the winning ${label} credential at ${location} (${reason}); retrying on the next tick.`
|
|
62457
|
+
);
|
|
62458
|
+
return { outcome: { kind: "contended" }, hash: lastHash };
|
|
62459
|
+
}
|
|
62460
|
+
function noEtag({
|
|
62461
|
+
label,
|
|
62462
|
+
location,
|
|
62463
|
+
lastHash,
|
|
62464
|
+
log
|
|
62465
|
+
}) {
|
|
62466
|
+
log(
|
|
62467
|
+
`WARNING: VAULT-NO-ETAG: the shared ${label} credential at ${location} came back without an ETag; skipping publication this tick rather than overwriting it unconditionally.`
|
|
62468
|
+
);
|
|
62469
|
+
return {
|
|
62470
|
+
outcome: { kind: "noEtag" },
|
|
62471
|
+
hash: lastHash
|
|
62472
|
+
};
|
|
62473
|
+
}
|
|
62474
|
+
function hashBytes(body) {
|
|
62475
|
+
return createHash10("sha256").update(body).digest("hex");
|
|
62476
|
+
}
|
|
62477
|
+
function shortHash(hash) {
|
|
62478
|
+
return hash.slice(0, 12);
|
|
62479
|
+
}
|
|
62480
|
+
|
|
62157
62481
|
// src/cli.ts
|
|
62158
62482
|
var EXIT_NOT_READY = 10;
|
|
62159
62483
|
var EXIT_KEEP_TASK = 20;
|
|
@@ -62172,7 +62496,8 @@ var USAGE = `Usage: runner-synchroniser <command> [args]
|
|
|
62172
62496
|
env print the resolved config as shell-sourceable lines
|
|
62173
62497
|
litestream-config print the generated litestream.yml
|
|
62174
62498
|
restore <claude|opencode> restore a credential store from the object store
|
|
62175
|
-
sync-once <claude|opencode>
|
|
62499
|
+
sync-once <claude|opencode> synchronize a credential store; shared-vault mode may
|
|
62500
|
+
adopt or conditionally publish credentials
|
|
62176
62501
|
model-auth-ready exit 0 when some model auth is configured, ${EXIT_NOT_READY} when not
|
|
62177
62502
|
self-stop scale this agent's own ECS service to 0; exit 0 only
|
|
62178
62503
|
when desiredCount is confirmed 0, ${EXIT_KEEP_TASK} to keep the task
|
|
@@ -62302,7 +62627,7 @@ async function commandSyncOnce(name, config, deps) {
|
|
|
62302
62627
|
const store = storeOf(config, name);
|
|
62303
62628
|
const statePath = `${store.path}${STATE_SUFFIX}`;
|
|
62304
62629
|
const lastHash = await readLastHash(fileOps, statePath, log);
|
|
62305
|
-
const
|
|
62630
|
+
const params = {
|
|
62306
62631
|
label: store.label,
|
|
62307
62632
|
path: store.path,
|
|
62308
62633
|
bucket: config.bucket,
|
|
@@ -62311,10 +62636,14 @@ async function commandSyncOnce(name, config, deps) {
|
|
|
62311
62636
|
lastHash,
|
|
62312
62637
|
fileOps,
|
|
62313
62638
|
log
|
|
62314
|
-
}
|
|
62639
|
+
};
|
|
62640
|
+
const result = config.vaultPrefix === null ? await sync(params) : await syncVault(params);
|
|
62315
62641
|
if (result.hash !== null && result.hash !== lastHash) {
|
|
62316
62642
|
await writeLastHash(fileOps, statePath, result.hash, log);
|
|
62317
62643
|
}
|
|
62644
|
+
if (config.vaultPrefix !== null) {
|
|
62645
|
+
return isVaultCredentialPersisted(result.outcome) ? 0 : EXIT_SYNC_NOT_PERSISTED;
|
|
62646
|
+
}
|
|
62318
62647
|
return result.outcome.kind === "failed" || result.outcome.kind === "hashFailed" || result.outcome.kind === "localInvalid" ? EXIT_SYNC_NOT_PERSISTED : 0;
|
|
62319
62648
|
}
|
|
62320
62649
|
async function commandSessionDbClassify(args, config, deps) {
|
|
@@ -62402,8 +62731,14 @@ ${USAGE}`);
|
|
|
62402
62731
|
const config = resolveConfig(env4);
|
|
62403
62732
|
if (command12 === "env") {
|
|
62404
62733
|
if (config.bucket === null) {
|
|
62734
|
+
const vaultPrefixIgnored = (env4.CREDENTIAL_VAULT_PREFIX?.trim() ?? "") !== "";
|
|
62735
|
+
log(
|
|
62736
|
+
"WARNING: credential persistence is DISABLED (LITESTREAM_BUCKET/LITESTREAM_PREFIX unset). Credentials stay on this task's ephemeral disk and are LOST when it is replaced." + (vaultPrefixIgnored ? " The requested CREDENTIAL_VAULT_PREFIX is ignored because persistence requires both LITESTREAM_BUCKET and LITESTREAM_PREFIX." : "")
|
|
62737
|
+
);
|
|
62738
|
+
}
|
|
62739
|
+
if (config.vaultPrefix !== null) {
|
|
62405
62740
|
log(
|
|
62406
|
-
|
|
62741
|
+
`VAULT-MODE: both Claude and OpenCode credential files are shared through bucket=${config.bucket} prefix=${config.vaultPrefix} instead of this runner's own prefix.`
|
|
62407
62742
|
);
|
|
62408
62743
|
}
|
|
62409
62744
|
out(renderEnv(config));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evident-ai/runner-synchroniser",
|
|
3
|
-
"version": "3.5.2-dev.
|
|
3
|
+
"version": "3.5.2-dev.7130f36",
|
|
4
4
|
"description": "Restores and syncs the Evident runner's OpenCode credential stores (and litestream config) to an object store, so a runner survives task replacement with almost no state loss.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|