@sigrun/sigrun 0.0.0-stage → 0.1.1

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,28 @@
1
+ # Third-party notices
2
+
3
+ ## hermes-webui
4
+
5
+ - <https://github.com/nesquena/hermes-webui>
6
+ - sigrun's web UI visual design is adapted from hermes-webui.
7
+
8
+ MIT License
9
+
10
+ Copyright (c) 2025 Hermes Web UI Contributors
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The backup last-success marker (core-hardening §6.8, R15 — "backup
3
+ * honesty"): `run-backup.ts` stamps `<stateDir>/backup/last-success.json`
4
+ * after a successful run, and `GET /v1/meta` reads it PER REQUEST to
5
+ * derive `health.backup` — before this, a configured-but-silently-failing
6
+ * CronJob still reported "configured" (the badge lied).
7
+ *
8
+ * Read posture: the marker is observability, never a gate. An absent,
9
+ * unreadable, unparseable, or wrong-shape marker reads as `undefined`
10
+ * — which DERIDES health ("degraded") but never fails the request. A
11
+ * corrupt marker must cost the badge, not the endpoint.
12
+ */
13
+ import { mkdir, readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { writeFileAtomic } from "../harness/stores.js";
16
+ /** The marker's directory + filename under the state root. */
17
+ export const BACKUP_MARKER_DIR = "backup";
18
+ export const BACKUP_MARKER_NAME = "last-success.json";
19
+ /** The marker's path under a state root. */
20
+ export function backupMarkerPath(stateDir) {
21
+ return join(stateDir, BACKUP_MARKER_DIR, BACKUP_MARKER_NAME);
22
+ }
23
+ /**
24
+ * Write the marker: mkdir the `backup/` dir, then atomic-replace with
25
+ * mode 0644 (an operator file — unlike secrets, nothing in it is
26
+ * sensitive; 0644 keeps `kubectl exec … cat` and restores simple).
27
+ */
28
+ export async function writeBackupMarker(stateDir, marker) {
29
+ await mkdir(join(stateDir, BACKUP_MARKER_DIR), { recursive: true });
30
+ await writeFileAtomic(backupMarkerPath(stateDir), `${JSON.stringify(marker, null, "\t")}\n`, 0o644);
31
+ }
32
+ /** Structural check: every field present and correctly typed — `at`
33
+ * must PARSE as a timestamp too (a marker whose `at` is garbage reads as
34
+ * no marker: surfacing an unparseable `lastSuccessAt` on the wire would
35
+ * trade a honest degraded badge for a confusing one). */
36
+ function isBackupMarker(value) {
37
+ if (typeof value !== "object" || value === null)
38
+ return false;
39
+ const record = value;
40
+ return (typeof record.at === "string" &&
41
+ Number.isFinite(Date.parse(record.at)) &&
42
+ (record.target === "path" || record.target === "s3") &&
43
+ typeof record.bytes === "number" &&
44
+ Number.isFinite(record.bytes));
45
+ }
46
+ /**
47
+ * Read the marker from a state root. `undefined` when absent (ENOENT) —
48
+ * and when anything else is wrong with it (unreadable, unparseable,
49
+ * wrong shape): a marker that cannot be trusted must not read as a
50
+ * success.
51
+ */
52
+ export async function readBackupMarker(stateDir) {
53
+ let raw;
54
+ try {
55
+ raw = await readFile(backupMarkerPath(stateDir), "utf8");
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ return isBackupMarker(parsed) ? parsed : undefined;
68
+ }
69
+ /**
70
+ * Whether a marker is stale: `now - at` strictly beyond
71
+ * `staleAfterHours` hours. A marker whose `at` does not parse as a
72
+ * timestamp is stale by definition (the derivation never surfaces it,
73
+ * but the pure rule stays total).
74
+ */
75
+ export function isBackupMarkerStale(marker, staleAfterHours, now = Date.now()) {
76
+ const at = Date.parse(marker.at);
77
+ if (!Number.isFinite(at))
78
+ return true;
79
+ return now - at > staleAfterHours * 60 * 60 * 1000;
80
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * The optional backup script (spec §4.3, Task 11) — the CronJob's payload.
3
+ *
4
+ * Invoked (tsx / node) with `SIGRUN_STORAGE_PATH` pointing at the live
5
+ * harness sqlite database plus the backup target env (see
6
+ * `parseBackupConfig`). It takes a `sqlite3 .backup` — sqlite3's online
7
+ * backup API (`.backup`): ONLINE-SAFE, it locks the source only per
8
+ * page, so the copy is consistent even while the harness process holds
9
+ * the database in WAL mode (a second process touching the file is
10
+ * exactly the class of assumption spec §4.3's App C ledger rule covers;
11
+ * the restore drill in test/backup.test.ts verifies it live).
12
+ *
13
+ * Flow, fail-loud at every step:
14
+ * 1. `sqlite3 <db> ".backup '<tmpfile>'"` into a sibling temp file (same
15
+ * filesystem as the target for the atomic rename; never a half-written
16
+ * target). A sqlite3 failure deletes the temp file and exits non-zero
17
+ * (sqlite3 leaves a 0-byte file on failure — it must not survive).
18
+ * 2. path target: rename into `<dir>/sigrun-<UTC timestamp>.db`, then
19
+ * 30-day retention (`applyRetention`).
20
+ * 2'. s3 target: `PutObject` the temp file to
21
+ * `s3://<bucket>[/<prefix>]sigrun-<UTC timestamp>.db`. Endpoint and
22
+ * credentials come from the environment (`AWS_*`,
23
+ * `SIGRUN_BACKUP_S3_ENDPOINT`) — core secrets per spec §4.3, injected
24
+ * into the CronJob from a Secret. A failed upload exits non-zero.
25
+ * 3. No backup target env → exit 1 with a config error (the CronJob must
26
+ * not silently do nothing).
27
+ *
28
+ * Last-success marker (§6.8 backup honesty): after a run succeeds — the
29
+ * db rename/upload AND every enabled section — the script writes
30
+ * `<SIGRUN_STATE_DIR>/backup/last-success.json` (`{at, target, bytes}`, 0644,
31
+ * atomic; see `marker.ts`), which `GET /v1/meta` reads per request to
32
+ * derive `health.backup` (absent or older than `server.backup.staleAfterHours`,
33
+ * default 36 h → "degraded"). With `SIGRUN_STATE_DIR` unset the script WARNS
34
+ * and skips the marker — the backup itself still succeeds; refusing to
35
+ * guess the state root matters because a marker written where the server
36
+ * does not read it would read as fresh backups that exist nowhere.
37
+ *
38
+ * The script deliberately touches NOTHING but the db file and the target —
39
+ * no pi-durable internals, no lock negotiation; that is the whole point of
40
+ * `sqlite3 .backup` over an application-level snapshot.
41
+ */
42
+ import { spawn } from "node:child_process";
43
+ import { chmod, mkdtemp, readdir, rename, rm, stat, unlink } from "node:fs/promises";
44
+ import { realpathSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import { dirname, join } from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
49
+ import { readFile } from "node:fs/promises";
50
+ import { parseBackupConfig } from "../config.js";
51
+ import { resolveStoreRoots } from "../harness/stores.js";
52
+ import { writeBackupMarker } from "./marker.js";
53
+ /** Retention floor (spec §4.3: "retention ≥ 30 days"). */
54
+ export const BACKUP_RETENTION_DAYS = 30;
55
+ /** The db path under backup — `SIGRUN_STORAGE_PATH` in deployment. */
56
+ const DB_PATH = process.env.SIGRUN_STORAGE_PATH ?? "";
57
+ /** Promise-wrapped spawn: a step's exit code and stderr. */
58
+ function run(command, args) {
59
+ return new Promise((resolve, reject) => {
60
+ const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
61
+ let stderr = "";
62
+ child.stderr.on("data", (chunk) => {
63
+ stderr += chunk.toString("utf8");
64
+ });
65
+ child.on("error", reject);
66
+ child.on("close", (code) => resolve({ code: code ?? -1, stderr }));
67
+ });
68
+ }
69
+ /**
70
+ * Timestamp segment of a backup filename: `2026-10-07T03-15-02Z` — UTC,
71
+ * sortable, colon-free (safe on every filesystem / object-store key).
72
+ */
73
+ function timestampSegment(date = new Date()) {
74
+ return date.toISOString().replace(/:/g, "-").slice(0, 19) + "Z";
75
+ }
76
+ /** Run `sqlite3 <db> ".backup '<dest>'"`; fail loud on a non-zero exit. */
77
+ async function sqliteBackup(dbPath, dest) {
78
+ // The quoting inside the dot-command is sqlite3's own (the dot-command
79
+ // parser treats the quoted string as one filename; an unquoted path with
80
+ // spaces would split).
81
+ const result = await run("sqlite3", [dbPath, `.backup '${dest}'`]);
82
+ if (result.code !== 0) {
83
+ // sqlite3 leaves a 0-byte dest on failure — a later restore would
84
+ // open a valid EMPTY database and "resume" to nothing. Delete it.
85
+ await rm(dest, { force: true });
86
+ throw new Error(`sqlite3 .backup failed with exit ${result.code}: ${result.stderr.trim()}`);
87
+ }
88
+ }
89
+ /**
90
+ * Path-target retention: delete `sigrun-*.db` files in `dir` whose mtime is
91
+ * older than {@link BACKUP_RETENTION_DAYS} days. Non-matching names are
92
+ * never touched (the directory is the deployment's backup dir, but it is
93
+ * still conservative to prune only what this script produced).
94
+ */
95
+ export async function applyRetention(dir) {
96
+ const cutoff = Date.now() - BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000;
97
+ for (const name of await readdir(dir)) {
98
+ if (!name.startsWith("sigrun-") || !name.endsWith(".db"))
99
+ continue;
100
+ const path = join(dir, name);
101
+ const info = await stat(path);
102
+ if (info.mtimeMs < cutoff)
103
+ await unlink(path);
104
+ }
105
+ }
106
+ /**
107
+ * The S3 client the uploads go through. A module-level factory with the
108
+ * real client as its default: the test swaps in a recording fake, and the
109
+ * deployment path is untouched (no public API surface beyond this seam).
110
+ *
111
+ * Credentials come from the SDK's own default provider chain — the
112
+ * standard `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
113
+ * `AWS_REGION` env, or an AWS config file. sigrun adds no credential
114
+ * settings of its own.
115
+ */
116
+ /** The production client factory — what a restore puts back. */
117
+ const defaultS3ClientFactory = (config) => new S3Client(config);
118
+ let s3ClientFactory = defaultS3ClientFactory;
119
+ /** Replace the S3 client factory; `undefined` restores the production one. */
120
+ export function setS3ClientFactory(factory) {
121
+ s3ClientFactory = factory ?? defaultS3ClientFactory;
122
+ }
123
+ /**
124
+ * The client config for a backup target: an endpoint means an
125
+ * S3-COMPATIBLE store (MinIO, RustFS, …), which needs path-style
126
+ * addressing — `bucket.host` virtual-host addressing does not resolve
127
+ * there. No endpoint = AWS's own endpoints, virtual-host style.
128
+ */
129
+ function s3ClientConfig() {
130
+ const endpoint = process.env.SIGRUN_BACKUP_S3_ENDPOINT;
131
+ return endpoint === undefined || endpoint === ""
132
+ ? {}
133
+ : { endpoint, forcePathStyle: true };
134
+ }
135
+ /**
136
+ * Put one local file at `key` in the target bucket. The bytes are read
137
+ * once and sent as a Buffer: the SDK would otherwise want a stream, and
138
+ * the files here are a sqlite snapshot and small tarballs — reading them
139
+ * is the honest, testable shape. Failures reject with the SDK's error.
140
+ */
141
+ async function s3Put(backup, key, file) {
142
+ const client = s3ClientFactory(s3ClientConfig());
143
+ try {
144
+ await client.send(new PutObjectCommand({
145
+ Bucket: backup.bucket,
146
+ Key: key,
147
+ Body: await readFile(file),
148
+ }));
149
+ }
150
+ catch (cause) {
151
+ throw new Error(`s3 upload of ${key} to ${backup.bucket} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
152
+ }
153
+ finally {
154
+ client.destroy();
155
+ }
156
+ }
157
+ /** Upload the backup to the s3 target; fail loud when the upload fails. */
158
+ async function s3Upload(backup, file) {
159
+ await s3Put(backup, `${backup.prefix ?? ""}sigrun-${timestampSegment()}.db`, file);
160
+ }
161
+ /**
162
+ * One backup run: take the sqlite backup, then ship it to the target.
163
+ * Exported for the test's direct (in-process) invocation; the script's
164
+ * main() wraps it with env parsing and process exit codes.
165
+ *
166
+ * `stateDir` (§6.8): where the last-success marker is written after the
167
+ * WHOLE run settles (db + sections) — a run whose section leg failed must
168
+ * not refresh the marker, or the badge would hide the failure the exit
169
+ * code records. `undefined` skips the marker with a warning (the run
170
+ * itself still succeeds).
171
+ */
172
+ export async function runBackup(dbPath, backup, stateDir) {
173
+ if (!dbPath) {
174
+ throw new Error("SIGRUN_STORAGE_PATH is required (the db to back up)");
175
+ }
176
+ // sqlite3 opens its source with create flags: a MISSING db file backs up
177
+ // "successfully" as an empty database (a SIGRUN_STORAGE_PATH typo or a
178
+ // CronJob firing before first boot would then produce nightly empty
179
+ // backups indistinguishable from real ones). Fail loud instead.
180
+ const dbStat = await stat(dbPath).catch(() => undefined);
181
+ if (dbStat === undefined || dbStat.size === 0) {
182
+ throw new Error(`database ${dbPath} does not exist or is empty — refusing to back up nothing`);
183
+ }
184
+ // Temp file in the TARGET's filesystem (path target) so the final move
185
+ // is an atomic rename, not a copy; for s3 the temp dir is irrelevant to
186
+ // atomicity (the upload is the move) so the OS temp dir serves.
187
+ const tempDir = backup.target === "path" ? backup.path : tmpdir();
188
+ const tempFile = join(await mkdtemp(join(tempDir, ".sigrun-backup-")), `backup-${timestampSegment()}.db`);
189
+ // The shipped size for the marker — captured inside the try after the
190
+ // sqlite step, read by the marker write below (rename/upload/cleanup all
191
+ // consume the temp file, so the size must be taken while it exists).
192
+ let bytes = 0;
193
+ try {
194
+ await sqliteBackup(dbPath, tempFile);
195
+ bytes = (await stat(tempFile)).size;
196
+ if (backup.target === "path") {
197
+ const dest = join(backup.path, `sigrun-${timestampSegment()}.db`);
198
+ await rename(tempFile, dest);
199
+ await applyRetention(backup.path);
200
+ }
201
+ else {
202
+ await s3Upload(backup, tempFile);
203
+ }
204
+ }
205
+ finally {
206
+ // The temp file survives only on the failure paths between sqlite
207
+ // success and the rename/upload — always clean it up.
208
+ await rm(tempFile, { force: true });
209
+ await rm(dirname(tempFile), { force: true, recursive: true });
210
+ }
211
+ // Sections fold in AFTER the DB steps (Task 7): with no sections
212
+ // enabled the loop is a no-op and the run is byte-identical to the
213
+ // DB-only contract.
214
+ await backupSections(backup);
215
+ // §6.8: the marker — after EVERYTHING above succeeded (see the
216
+ // docblock for why a sections failure must leave it stale). A marker
217
+ // WRITE failure warns and is swallowed (fix round 1): the marker is
218
+ // observability, never a gate — a read-only state dir or a full disk
219
+ // must not turn a completed backup into a failed one. The consequence
220
+ // is honest: the server reads no marker and reports degraded.
221
+ if (stateDir === undefined) {
222
+ console.warn("SIGRUN_STATE_DIR is not set — skipping the backup last-success marker " +
223
+ "(the backup itself succeeded; set SIGRUN_STATE_DIR on the CronJob and " +
224
+ "the server to the same root so health.backup can see it)");
225
+ return;
226
+ }
227
+ try {
228
+ await writeBackupMarker(stateDir, {
229
+ at: new Date().toISOString(),
230
+ target: backup.target,
231
+ bytes,
232
+ });
233
+ }
234
+ catch (error) {
235
+ console.warn(`backup succeeded but writing the last-success marker under ${stateDir} failed: ${String(error)} ` +
236
+ "(health.backup will report degraded until this is fixed)");
237
+ }
238
+ }
239
+ /** The store root each backup section tars. */
240
+ const SECTION_ROOTS = {
241
+ // config and secrets both live under the config root (settings.json /
242
+ // auth.json); secrets differs by its 0600 tarball mode.
243
+ config: "configDir",
244
+ secrets: "configDir",
245
+ data: "dataDir",
246
+ state: "stateDir",
247
+ };
248
+ /**
249
+ * Sections whose content can carry secret files → tarball mode 0600
250
+ * (fix round 1). `config` tars the same root as `secrets`, so its
251
+ * archive carries auth.json too and must share the mode; `data` and
252
+ * `state` carry no key material and stay at the umask default.
253
+ */
254
+ const SECRET_SECTIONS = new Set([
255
+ "config",
256
+ "secrets",
257
+ ]);
258
+ /** Tar one section's root directory into `tarball`; fail loud on error. */
259
+ async function tarSection(sourceDir, tarball) {
260
+ // `-C source .` keeps the entries relative (settings.json, not
261
+ // /data/config/settings.json) and sidesteps tar's leading-`/` strip.
262
+ const result = await run("tar", ["-czf", tarball, "-C", sourceDir, "."]);
263
+ if (result.code !== 0) {
264
+ await rm(tarball, { force: true });
265
+ throw new Error(`tar of ${sourceDir} failed with exit ${result.code}: ${result.stderr.trim()}`);
266
+ }
267
+ }
268
+ /** Upload one section tarball to the s3 target; fail loud when it fails. */
269
+ async function s3UploadSection(backup, file, name) {
270
+ await s3Put(backup, `${backup.prefix ?? ""}${name}`, file);
271
+ }
272
+ /**
273
+ * The per-section opt-in (Task 7): for each enabled section, tar its
274
+ * store root (`resolveStoreRoots(process.env)` — the same chain boot
275
+ * uses) into `<section>-<UTC timestamp>.tar.gz` and ship it to the
276
+ * target. Sections whose content can carry secret files get mode 0600
277
+ * (fix round 1: config tars the same root as secrets — auth.json rides
278
+ * BOTH tarballs, and in-archive modes don't gate extraction, so the
279
+ * config ARCHIVE itself must be 0600 too or enabling `config` would
280
+ * defeat the secrets tarball's mode). No enabled sections → no-op.
281
+ */
282
+ async function backupSections(backup) {
283
+ const enabled = Object.keys(SECTION_ROOTS).filter((name) => backup.sections?.[name] === true);
284
+ if (enabled.length === 0)
285
+ return;
286
+ const roots = resolveStoreRoots(process.env);
287
+ for (const name of enabled) {
288
+ const tarballName = `${name}-${timestampSegment()}.tar.gz`;
289
+ if (backup.target === "path") {
290
+ const dest = join(backup.path, tarballName);
291
+ await tarSection(roots[SECTION_ROOTS[name]], dest);
292
+ if (SECRET_SECTIONS.has(name))
293
+ await chmod(dest, 0o600);
294
+ }
295
+ else {
296
+ // Tar to the OS temp dir, upload, clean up — same shape as the
297
+ // DB's s3 leg (the upload is the move).
298
+ const tempFile = join(await mkdtemp(join(tmpdir(), ".sigrun-backup-section-")), tarballName);
299
+ try {
300
+ await tarSection(roots[SECTION_ROOTS[name]], tempFile);
301
+ await s3UploadSection(backup, tempFile, tarballName);
302
+ }
303
+ finally {
304
+ await rm(tempFile, { force: true });
305
+ await rm(dirname(tempFile), { force: true, recursive: true });
306
+ }
307
+ }
308
+ }
309
+ }
310
+ /**
311
+ * Script entry — runs only when executed directly (tsx run-backup.ts / a
312
+ * built node dist), not when the test imports `runBackup` in-process.
313
+ * Compares the realpath of argv[1] with this module (tsx and ts-node both
314
+ * keep argv[1] as the script path).
315
+ */
316
+ const invokedAsScript = (() => {
317
+ if (process.argv[1] === undefined)
318
+ return false;
319
+ try {
320
+ return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ })();
326
+ if (invokedAsScript) {
327
+ const backup = parseBackupConfig(process.env);
328
+ if (backup === undefined) {
329
+ console.error("No backup target configured: set SIGRUN_BACKUP_PATH or SIGRUN_BACKUP_S3_BUCKET");
330
+ process.exit(1);
331
+ }
332
+ try {
333
+ await runBackup(DB_PATH, backup, process.env.SIGRUN_STATE_DIR?.trim() || undefined);
334
+ }
335
+ catch (error) {
336
+ console.error(`sigrun backup failed: ${String(error)}`);
337
+ process.exit(1);
338
+ }
339
+ }