getbotons 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,303 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process';
2
+ import { lstatSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { setPriority } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DatabaseSync } from 'node:sqlite';
6
+ import { fileURLToPath } from 'node:url';
7
+ import type { RehearsalProblem, RehearsalStatus, RehearsalWorkspaces, RestoreRehearsal } from '../../shared/types.ts';
8
+ import { buildSha, versionNumber } from '../../shared/util.ts';
9
+ import type { Audit, AuditActor } from './audit.ts';
10
+ import type { Principal } from './auth.ts';
11
+ import { backupTime, listBackups } from './backup.ts';
12
+ import { log } from './log.ts';
13
+ import { REHEARSAL_LIMITS, type RehearsalLimits, type RehearsalRequest } from './rehearsal.ts';
14
+ import { SCHEMA_VERSION } from './schema.ts';
15
+ import { HttpError } from './store.ts';
16
+ import { nowIso, parseJson } from './util.ts';
17
+
18
+ /*
19
+ * Settings → Health and backups → Rehearse a restore. The work runs in a child process (rehearsal.ts) so a large
20
+ * database or archive never blocks the server: one at a time, at most REHEARSAL_LIMIT an hour, under a low priority,
21
+ * a small heap and REHEARSAL_TIMEOUT_MS, with an environment that holds no credentials. Its copy lives in
22
+ * <home>/run/rehearsal/, which nothing else uses and which is emptied at every start. The result is kept in the
23
+ * settings table (fixed words, counts, times, versions) and each finished rehearsal is in the audit log.
24
+ */
25
+
26
+ /** Rehearsals allowed per hour from the app. */
27
+ export const REHEARSAL_LIMIT = 6;
28
+ const REHEARSAL_WINDOW_MS = 3_600_000;
29
+ export const REHEARSAL_TIMEOUT_MS = 30 * 60_000;
30
+ /** The child's heap, in MiB: it streams the archive and lets SQLite read the copy, so it needs little. */
31
+ const CHILD_HEAP_MB = 256;
32
+ const CHILD = fileURLToPath(new URL('./rehearsal.ts', import.meta.url));
33
+ const SETTING = 'restoreRehearsal';
34
+
35
+ const PROBLEMS: ReadonlySet<string> = new Set<RehearsalProblem>([
36
+ 'not-found',
37
+ 'not-a-backup',
38
+ 'database-too-large',
39
+ 'no-space',
40
+ 'damaged-database',
41
+ 'newer-schema',
42
+ 'older-schema',
43
+ 'upgrade-failed',
44
+ 'damaged-archive',
45
+ 'unsafe-archive',
46
+ 'archive-too-large',
47
+ 'timed-out',
48
+ 'interrupted',
49
+ 'failed',
50
+ ]);
51
+ const WORKSPACES: ReadonlySet<string> = new Set<RehearsalWorkspaces>(['checked', 'none', 'not-checked']);
52
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
53
+
54
+ const count = (v: unknown): number | null => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null);
55
+ const schemaOf = (v: unknown): number | null => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0 && v < 1_000_000 ? v : null);
56
+ const time = (v: unknown): string | null => (typeof v === 'string' && ISO.test(v) && Number.isFinite(Date.parse(v)) ? v : null);
57
+
58
+ /** What the child printed, or what the settings row holds, kept only field by field in its expected shape. */
59
+ function outcomeFields(raw: Record<string, unknown>): Pick<RestoreRehearsal, 'problem' | 'fromSchema' | 'toSchema' | 'matchesManifest' | 'workspaces' | 'files' | 'folders' | 'bytes'> {
60
+ return {
61
+ problem: typeof raw.problem === 'string' && PROBLEMS.has(raw.problem) ? (raw.problem as RehearsalProblem) : null,
62
+ fromSchema: schemaOf(raw.fromSchema),
63
+ toSchema: schemaOf(raw.toSchema),
64
+ matchesManifest: typeof raw.matchesManifest === 'boolean' ? raw.matchesManifest : null,
65
+ workspaces: typeof raw.workspaces === 'string' && WORKSPACES.has(raw.workspaces) ? (raw.workspaces as RehearsalWorkspaces) : 'not-checked',
66
+ files: count(raw.files),
67
+ folders: count(raw.folders),
68
+ bytes: count(raw.bytes),
69
+ };
70
+ }
71
+
72
+ function stored(raw: unknown): RestoreRehearsal | null {
73
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
74
+ const r = raw as Record<string, unknown>;
75
+ const startedAt = time(r.startedAt);
76
+ if (typeof r.backup !== 'string' || !Number.isFinite(backupTime(r.backup)) || !startedAt) return null;
77
+ const outcome = r.outcome === 'running' || r.outcome === 'passed' || r.outcome === 'failed' ? r.outcome : null;
78
+ if (!outcome) return null;
79
+ const by = (r.ranBy && typeof r.ranBy === 'object' ? r.ranBy : {}) as Record<string, unknown>;
80
+ const fields = outcomeFields(r);
81
+ return {
82
+ backup: r.backup,
83
+ startedAt,
84
+ finishedAt: time(r.finishedAt),
85
+ outcome,
86
+ ...fields,
87
+ problem: outcome === 'failed' ? (fields.problem ?? 'failed') : null,
88
+ ranBy: { version: versionNumber(typeof by.version === 'string' ? by.version : null), build: buildSha(typeof by.build === 'string' ? by.build : null), schema: schemaOf(by.schema) ?? 0 },
89
+ };
90
+ }
91
+
92
+ interface Running {
93
+ rehearsal: RestoreRehearsal;
94
+ child: ChildProcess;
95
+ actor: AuditActor;
96
+ workDir: string;
97
+ /** Set when the server is closing or the time ran out, so the end says why. */
98
+ stopReason: RehearsalProblem | null;
99
+ done: Promise<void>;
100
+ }
101
+
102
+ export class RestoreRehearsals {
103
+ #db: DatabaseSync;
104
+ #audit: Audit;
105
+ #backupsDir: string;
106
+ #workRoot: string;
107
+ #ranBy: RestoreRehearsal['ranBy'];
108
+ #limits: RehearsalLimits;
109
+ #timeoutMs: number;
110
+ #running: Running | null = null;
111
+ #starts: number[] = [];
112
+ #closing = false;
113
+
114
+ constructor(opts: {
115
+ db: DatabaseSync;
116
+ audit: Audit;
117
+ home: string;
118
+ backupsDir: string;
119
+ version: string;
120
+ build: string | null;
121
+ limits?: Partial<RehearsalLimits>;
122
+ timeoutMs?: number;
123
+ }) {
124
+ this.#db = opts.db;
125
+ this.#audit = opts.audit;
126
+ this.#backupsDir = opts.backupsDir;
127
+ this.#workRoot = join(opts.home, 'run', 'rehearsal');
128
+ this.#ranBy = { version: versionNumber(opts.version), build: buildSha(opts.build), schema: SCHEMA_VERSION };
129
+ this.#limits = { ...REHEARSAL_LIMITS, ...opts.limits };
130
+ this.#timeoutMs = opts.timeoutMs ?? REHEARSAL_TIMEOUT_MS;
131
+ // A copy left by a rehearsal that a crash cut off. Only this server uses the folder: it holds the data folder's lock.
132
+ rmSync(this.#workRoot, { recursive: true, force: true });
133
+ const last = this.#read();
134
+ if (last?.outcome === 'running') this.#write({ ...last, outcome: 'failed', problem: 'interrupted' });
135
+ }
136
+
137
+ status(): RehearsalStatus {
138
+ return { last: this.#running?.rehearsal ?? this.#read(), limitPerHour: REHEARSAL_LIMIT };
139
+ }
140
+
141
+ get running(): boolean {
142
+ return this.#running !== null;
143
+ }
144
+
145
+ /** Starts one and returns at once; GET /api/operations shows it running, then its result. */
146
+ start(principal: Principal | null, backup: string): RestoreRehearsal {
147
+ if (this.#closing) throw new HttpError(503, 'The server is shutting down; rehearse after it starts again');
148
+ if (typeof backup !== 'string') throw new HttpError(400, 'backup must be a backup name or "latest"');
149
+ let name: string | undefined = backup;
150
+ if (backup === 'latest') {
151
+ try {
152
+ name = listBackups(this.#backupsDir).filter((n) => this.#isFolder(n)).at(-1);
153
+ } catch {
154
+ // An unreadable backups folder: the Backups check already says so, without its path.
155
+ name = undefined;
156
+ }
157
+ }
158
+ if (name === undefined) throw new HttpError(404, 'There is no backup to rehearse');
159
+ if (!Number.isFinite(backupTime(name))) throw new HttpError(400, 'backup must be a backup name or "latest"');
160
+ if (!this.#isFolder(name)) throw new HttpError(404, 'There is no backup by that name');
161
+ if (this.#running) throw new HttpError(409, 'A restore rehearsal is already running');
162
+ const now = Date.now();
163
+ while (this.#starts.length && now - this.#starts[0] >= REHEARSAL_WINDOW_MS) this.#starts.shift();
164
+ if (this.#starts.length >= REHEARSAL_LIMIT) throw new HttpError(429, `At most ${REHEARSAL_LIMIT} restore rehearsals an hour; try again later`);
165
+
166
+ mkdirSync(this.#workRoot, { recursive: true, mode: 0o700 });
167
+ const workDir = mkdtempSync(join(this.#workRoot, 'r-'));
168
+ const rehearsal: RestoreRehearsal = {
169
+ backup: name,
170
+ startedAt: nowIso(),
171
+ finishedAt: null,
172
+ outcome: 'running',
173
+ problem: null,
174
+ fromSchema: null,
175
+ toSchema: null,
176
+ matchesManifest: null,
177
+ workspaces: 'not-checked',
178
+ files: null,
179
+ folders: null,
180
+ bytes: null,
181
+ ranBy: this.#ranBy,
182
+ };
183
+ let child: ChildProcess;
184
+ try {
185
+ child = spawn(process.execPath, [`--max-old-space-size=${CHILD_HEAP_MB}`, CHILD], {
186
+ cwd: workDir,
187
+ // Nothing from the server's environment: no tokens, no CLI logins, no proxies, no PATH (it runs no program).
188
+ env: { LC_ALL: 'C', TZ: 'UTC' },
189
+ stdio: ['pipe', 'pipe', 'ignore'],
190
+ });
191
+ } catch {
192
+ rmSync(workDir, { recursive: true, force: true });
193
+ throw new HttpError(500, 'Could not start the rehearsal');
194
+ }
195
+ this.#starts.push(now);
196
+ try {
197
+ if (child.pid) setPriority(child.pid, 10);
198
+ } catch {}
199
+ const running: Running = { rehearsal, child, actor: this.#audit.actorOf(principal), workDir, stopReason: null, done: Promise.resolve() };
200
+ this.#running = running;
201
+ this.#write(rehearsal);
202
+ running.done = this.#watch(running);
203
+ const request: RehearsalRequest = { backupsDir: this.#backupsDir, name, workDir, limits: this.#limits };
204
+ // stdin stays open: the child stops when it closes, so it never outlives the server.
205
+ child.stdin!.on('error', () => {});
206
+ child.stdin!.write(`${JSON.stringify(request)}\n`);
207
+ return rehearsal;
208
+ }
209
+
210
+ /** Stops a running rehearsal, recording it as interrupted, and refuses new ones. */
211
+ async close(): Promise<void> {
212
+ this.#closing = true;
213
+ const r = this.#running;
214
+ if (!r) return;
215
+ r.stopReason = 'interrupted';
216
+ r.child.kill('SIGKILL');
217
+ await r.done;
218
+ }
219
+
220
+ #isFolder(name: string): boolean {
221
+ try {
222
+ return lstatSync(join(this.#backupsDir, name)).isDirectory();
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ #watch(r: Running): Promise<void> {
229
+ return new Promise((resolve) => {
230
+ let out = '';
231
+ r.child.stdout!.setEncoding('utf8');
232
+ r.child.stdout!.on('data', (chunk: string) => {
233
+ // One short line is all it prints.
234
+ if (out.length < 8192) out += chunk;
235
+ });
236
+ const timer = setTimeout(() => {
237
+ r.stopReason = 'timed-out';
238
+ r.child.kill('SIGKILL');
239
+ }, this.#timeoutMs);
240
+ timer.unref();
241
+ let finished = false;
242
+ const finish = () => {
243
+ if (finished) return;
244
+ finished = true;
245
+ clearTimeout(timer);
246
+ try {
247
+ r.child.stdin!.destroy();
248
+ } catch {}
249
+ try {
250
+ rmSync(r.workDir, { recursive: true, force: true });
251
+ } catch {}
252
+ const line = out.split('\n')[0];
253
+ const raw = r.stopReason ? null : parseJson<Record<string, unknown> | null>(line, null);
254
+ const fields = raw && typeof raw === 'object' ? outcomeFields(raw) : null;
255
+ const passed = !!raw && raw.ok === true && !!fields && fields.problem === null && fields.toSchema === SCHEMA_VERSION;
256
+ const result: RestoreRehearsal = {
257
+ ...r.rehearsal,
258
+ ...(fields ?? {}),
259
+ finishedAt: nowIso(),
260
+ outcome: passed ? 'passed' : 'failed',
261
+ problem: passed ? null : (r.stopReason ?? fields?.problem ?? 'failed'),
262
+ };
263
+ this.#running = null;
264
+ this.#write(result);
265
+ const durationMs = Date.parse(result.finishedAt!) - Date.parse(result.startedAt);
266
+ if (passed) log.info('restore rehearsal passed', { durationMs });
267
+ else log.warn('restore rehearsal failed', { durationMs });
268
+ this.#audit.record(r.actor, {
269
+ action: 'backup.rehearsed',
270
+ target: { kind: 'backup', id: result.backup },
271
+ details: {
272
+ outcome: result.outcome,
273
+ problem: result.problem,
274
+ fromSchema: result.fromSchema,
275
+ toSchema: result.toSchema,
276
+ workspaces: result.workspaces,
277
+ matchesManifest: result.matchesManifest,
278
+ },
279
+ });
280
+ resolve();
281
+ };
282
+ r.child.once('error', finish);
283
+ r.child.once('close', finish);
284
+ });
285
+ }
286
+
287
+ #read(): RestoreRehearsal | null {
288
+ try {
289
+ const raw = this.#db.prepare('select value from settings where key = ?').get(SETTING)?.value;
290
+ return stored(parseJson(typeof raw === 'string' ? raw : null, null));
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ #write(r: RestoreRehearsal): void {
297
+ try {
298
+ this.#db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run(SETTING, JSON.stringify(r));
299
+ } catch (err) {
300
+ log.error('could not record a restore rehearsal', { err });
301
+ }
302
+ }
303
+ }
@@ -0,0 +1,165 @@
1
+ import type { Message } from '../../shared/types.ts';
2
+
3
+ /**
4
+ * What a new session is given of the chat, sized to one budget. Sizes are estimated tokens, counted as UTF-8
5
+ * bytes: a byte-level tokenizer never makes more tokens than a text has bytes, so the estimate is never below the
6
+ * real count (for English it is about four times it). Budgeting by that bound keeps a chat in any language inside it.
7
+ */
8
+
9
+ /** One budget for everything a new session starts with ahead of the turn's own input: the handoff note, the summary and the replayed chat. */
10
+ export const REPLAY_BUDGET = 64_000;
11
+ /** Each replayed message longer than this is given as an excerpt: its start and end, cut between sentences. */
12
+ export const MESSAGE_LIMIT = 4_000;
13
+ /** The owner's pins: at most this many at once, each replayed whole up to PIN_LIMIT_BYTES (longer: an excerpt). */
14
+ export const PIN_LIMIT = 10;
15
+ export const PIN_LIMIT_BYTES = 2_400;
16
+ /** Routine messages take at most this much, apart from the chat, so a run of busy routines can't push the owner's messages out. */
17
+ export const ROUTINE_SHARE = 8_000;
18
+ /**
19
+ * A webhook run is given at most this many of the chat's latest messages (the owner's choice for its routine, 0 by
20
+ * default), inside WEBHOOK_HISTORY_BUDGET: its payload comes from outside, so it sees a bounded part of the chat or none.
21
+ */
22
+ export const WEBHOOK_HISTORY_MAX = 20;
23
+ export const WEBHOOK_HISTORY_BUDGET = 16_000;
24
+ /** The store reads the chat for a replay this many messages at a time, newest first, until the budget is full. */
25
+ export const REPLAY_PAGE = 200;
26
+
27
+ /** A message the replay may give, with its place in the chat. */
28
+ export interface Replayable {
29
+ message: Message;
30
+ seq: number;
31
+ }
32
+
33
+ export const estimateTokens = (text: string): number => Buffer.byteLength(text, 'utf8');
34
+
35
+ const sentences = new Intl.Segmenter('en', { granularity: 'sentence' });
36
+ const words = new Intl.Segmenter('en', { granularity: 'word' });
37
+ const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' });
38
+
39
+ /** The marker an excerpt carries where text was left out: it says it is an excerpt, never a summary. */
40
+ export const excerptMarker = (left: number) => ` [… excerpt: ${left.toLocaleString('en-US')} characters left out …] `;
41
+ const MARKER_MAX = estimateTokens(excerptMarker(10_000_000_000));
42
+
43
+ /** The longest start of `text` within `max` bytes, ending between segments (sentences, else words, else characters). */
44
+ function fitStart(text: string, max: number): number {
45
+ for (const seg of [sentences, words, graphemes]) {
46
+ let end = 0;
47
+ let used = 0;
48
+ for (const s of seg.segment(text)) {
49
+ const n = estimateTokens(s.segment);
50
+ if (used + n > max) break;
51
+ used += n;
52
+ end = s.index + s.segment.length;
53
+ }
54
+ // A sentence boundary only if it keeps at least half of what fits: else words read better than one short sentence.
55
+ if (end > 0 && (seg !== sentences || used * 2 >= max)) return end;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ /** The latest index of `text` whose end from there fits in `max` bytes, starting between segments. */
61
+ function fitEnd(text: string, max: number): number {
62
+ for (const seg of [sentences, words, graphemes]) {
63
+ const parts = [...seg.segment(text)];
64
+ let start = text.length;
65
+ let used = 0;
66
+ for (let i = parts.length - 1; i >= 0; i--) {
67
+ const n = estimateTokens(parts[i].segment);
68
+ if (used + n > max) break;
69
+ used += n;
70
+ start = parts[i].index;
71
+ }
72
+ if (start < text.length && (seg !== sentences || used * 2 >= max)) return start;
73
+ }
74
+ return text.length;
75
+ }
76
+
77
+ /**
78
+ * `text` whole if it fits in `max` estimated tokens, else an excerpt that does: its start and its end, each cut
79
+ * between sentences where it can (else between words, else characters, never inside one), and a marker in between
80
+ * that says how much was left out. Only whole code points and graphemes are kept.
81
+ */
82
+ export function excerpt(text: string, max: number): { text: string; cut: boolean } {
83
+ if (estimateTokens(text) <= max) return { text, cut: false };
84
+ const room = Math.max(0, max - MARKER_MAX);
85
+ const headRoom = Math.floor((room * 2) / 3);
86
+ // Segmenting only what can fit: no text has fewer bytes than characters (UTF-16 units count at most 3 bytes each, one each at least).
87
+ const head = text.slice(0, Math.min(text.length, headRoom + 1));
88
+ const h = fitStart(head, headRoom);
89
+ const tailRoom = room - estimateTokens(text.slice(0, h));
90
+ const from = Math.max(h, text.length - tailRoom - 1);
91
+ const tail = text.slice(from);
92
+ const t = from + fitEnd(tail, tailRoom);
93
+ // A slice edge can split a surrogate pair; a lone half is never kept (fitStart and fitEnd only take whole segments
94
+ // inside the slice, and the slice's own edge segment fits only when the slice is the whole text).
95
+ const kept = text.slice(0, h).trimEnd();
96
+ const end = text.slice(t).trimStart();
97
+ const left = [...text.slice(h, t)].length;
98
+ return { text: `${kept}${excerptMarker(left)}${end}`.trim(), cut: true };
99
+ }
100
+
101
+ /** One message as the replay would give it: its line, and whether it is an excerpt. */
102
+ export interface ReplayLine {
103
+ seq: number;
104
+ at: string;
105
+ /** Another Boton's message, in its own block. */
106
+ boton: boolean;
107
+ line: string;
108
+ cut: boolean;
109
+ pinned: boolean;
110
+ routine: boolean;
111
+ }
112
+
113
+ export interface ReplayPick {
114
+ /** In chat order. */
115
+ lines: ReplayLine[];
116
+ /** Pinned messages older than the oldest recent message kept: given apart, ahead of the rest. */
117
+ earlierPins: ReplayLine[];
118
+ /** Messages newer than the reset that there was no room for (pins apart): the rest was not read. */
119
+ leftOut: boolean;
120
+ pinsLeftOut: number;
121
+ used: number;
122
+ }
123
+
124
+ /**
125
+ * Picks what fits in `room`: the pins first (newest first: the oldest is the one left out if even they don't fit,
126
+ * which the limits make impossible, and is counted if it ever happens), then routine messages up to their share,
127
+ * then the chat, stopping at the first that doesn't fit so no gap opens in the middle. Each line's newline counts.
128
+ * `chat` and `routines` come newest first and are read only as far as they fit: every line costs at least its
129
+ * head, so the budget bounds how many are read, whatever the size of the history.
130
+ */
131
+ export function pickReplay(pins: ReplayLine[], chat: Iterable<ReplayLine>, routines: Iterable<ReplayLine>, room: number): ReplayPick {
132
+ const cost = (l: ReplayLine) => estimateTokens(l.line) + 1;
133
+ let used = 0;
134
+ const keptPins: ReplayLine[] = [];
135
+ let pinsLeftOut = 0;
136
+ for (const p of [...pins].sort((a, b) => b.seq - a.seq)) {
137
+ if (used + cost(p) <= room) {
138
+ keptPins.push(p);
139
+ used += cost(p);
140
+ } else pinsLeftOut++;
141
+ }
142
+ const pinSeqs = new Set(pins.map((p) => p.seq));
143
+ const take = (list: Iterable<ReplayLine>, limit: number) => {
144
+ const out: ReplayLine[] = [];
145
+ let spent = 0;
146
+ let rest = false;
147
+ for (const l of list) {
148
+ if (pinSeqs.has(l.seq)) continue;
149
+ if (spent + cost(l) > limit) {
150
+ rest = true;
151
+ break;
152
+ }
153
+ out.push(l);
154
+ spent += cost(l);
155
+ }
156
+ used += spent;
157
+ return { out, rest };
158
+ };
159
+ const r = take(routines, Math.min(ROUTINE_SHARE, room - used));
160
+ const c = take(chat, room - used);
161
+ const oldest = c.out.length ? Math.min(...c.out.map((l) => l.seq)) : Infinity;
162
+ const earlierPins = keptPins.filter((p) => p.seq < oldest).sort((a, b) => a.seq - b.seq);
163
+ const lines = [...c.out, ...r.out, ...keptPins.filter((p) => p.seq >= oldest)].sort((a, b) => a.seq - b.seq);
164
+ return { lines, earlierPins, leftOut: c.rest || r.rest, pinsLeftOut, used };
165
+ }