@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +101 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +403 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +124 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +757 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Log history: full container/daemon log history, paged backwards.
3
+ *
4
+ * Docker retains the complete log of a running container (json-file driver),
5
+ * but `docker logs --until <ts> --tail N` returns nothing (docker applies
6
+ * --tail BEFORE the time filter), so timestamp cursors can't page backwards.
7
+ * Instead: history-before-now is APPEND-ONLY, so the first request snapshots
8
+ * `docker logs --timestamps` to a file once per container instance, and pages
9
+ * are served from that file by BYTE-OFFSET cursors — exact, immutable, and
10
+ * O(chunk) per page. Host-run services page their den daemon log file
11
+ * directly (also append-only).
12
+ *
13
+ * Future-proofing: the API cursor is an OPAQUE STRING. Local sources encode a
14
+ * byte offset; a cloudwatch source can later encode a filter-log-events
15
+ * backward token without any client change. `resolveHistorySource` is the
16
+ * single extension point per env.
17
+ */
18
+
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ import { execFile, spawnSync } from "child_process";
22
+
23
+ import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
24
+ import {
25
+ resolveService,
26
+ hasRunningOpctlContainers,
27
+ } from "../../shared/environment-manager/service-runner.js";
28
+ import { checkServiceHealth } from "../../shared/environment-manager/health.js";
29
+ import { getServiceLogPath } from "../../shared/process/spawn-streaming.js";
30
+ import { readManifest } from "../../shared/manifest/read-manifest.js";
31
+ import type { Environment, LogSource } from "../../shared/types/index.js";
32
+ import { getStateDir } from "../../shared/paths.js";
33
+
34
+ const HISTORY_DIR = path.join(getStateDir(), "history");
35
+
36
+ // ── Source resolution ─────────────────────────────────────────────────────────
37
+
38
+ export type HistorySource =
39
+ | { kind: "docker"; container: string }
40
+ | { kind: "file"; path: string }
41
+ | { kind: "unsupported"; reason: string };
42
+
43
+ /**
44
+ * Repo/service names as they appear in catalogs and manifests: must start
45
+ * alphanumeric (rejects ".", "..", dotfiles) with no path separators. This is
46
+ * the security boundary for every history/search entry point — these values
47
+ * reach `getServiceLogPath` (which mkdirs and opens files) and docker CLIs.
48
+ */
49
+ const SAFE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
50
+
51
+ export function isSafeServiceName(name: string): boolean {
52
+ return SAFE_NAME_RE.test(name);
53
+ }
54
+
55
+ /**
56
+ * Extract a docker `--filter key=value` from a manifest logs shell command
57
+ * (every fleet manifest resolves its container via `docker ps --filter …`).
58
+ * Returns the filter argument (e.g. "name=shop-postgres") or null.
59
+ */
60
+ export function parseManifestDockerFilter(cmd: string): string | null {
61
+ const m = cmd.match(/--filter[= ]['"]?((?:name|publish|expose)=[^'"\s]+)/);
62
+ return m ? m[1] : null;
63
+ }
64
+
65
+ /**
66
+ * Resolve where a service's log HISTORY lives. Local env only in v1 —
67
+ * non-local envs return `unsupported` (cloudwatch backward paging is a
68
+ * follow-up that plugs in here).
69
+ */
70
+ export function resolveHistorySource(
71
+ repoName: string,
72
+ svcName: string,
73
+ env: string,
74
+ ): HistorySource {
75
+ // Security boundary: these names flow into filesystem paths
76
+ // (getServiceLogPath mkdirs + reads `<repo>/<service>.log`) and docker
77
+ // invocations. Reject anything that isn't a plain catalog-style name.
78
+ if (!isSafeServiceName(repoName) || !isSafeServiceName(svcName)) {
79
+ return { kind: "unsupported", reason: "invalid repo/service name" };
80
+ }
81
+
82
+ if (env !== "local") {
83
+ return {
84
+ kind: "unsupported",
85
+ reason: `history for env "${env}" is not supported yet (local only)`,
86
+ };
87
+ }
88
+
89
+ const resolved =
90
+ resolveService(`${repoName}/${svcName}`) ?? resolveService(repoName);
91
+ const repoPath = resolved?.repoPath;
92
+
93
+ // 1. The service's container: its compose stack's, else opctl's
94
+ // conventional one (opctl_<base>_<hash>).
95
+ const conventional = findServiceContainer(repoName, repoPath, svcName);
96
+ if (conventional) return { kind: "docker", container: conventional };
97
+
98
+ // 2. Container named by the manifest log command's docker ps filter.
99
+ const manifest = repoPath ? readManifest(repoPath) : null;
100
+ const logSource = manifest?.services?.[svcName]?.logs?.["local"] as
101
+ | LogSource
102
+ | undefined;
103
+ if (logSource?.type === "shell" && logSource.cmd) {
104
+ const filter = parseManifestDockerFilter(logSource.cmd);
105
+ if (filter) {
106
+ const out = spawnSync(
107
+ "docker",
108
+ ["ps", "--filter", filter, "--format", "{{.Names}}"],
109
+ { encoding: "utf-8", timeout: 5_000 },
110
+ );
111
+ const name = out.stdout?.split("\n").map((l) => l.trim())[0];
112
+ if (name) return { kind: "docker", container: name };
113
+ }
114
+ }
115
+
116
+ // 3. Host-run process — the den daemon log file holds history since
117
+ // process start.
118
+ const daemonPath = getServiceLogPath(repoName, svcName);
119
+ if (fs.existsSync(daemonPath)) return { kind: "file", path: daemonPath };
120
+
121
+ return {
122
+ kind: "unsupported",
123
+ reason: `no running container or daemon log found for ${repoName}/${svcName}`,
124
+ };
125
+ }
126
+
127
+ // ── Snapshots ─────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Materialize the immutable history file for a source. Docker: one-time
131
+ * `docker logs --timestamps` dump per container instance (backward pages are
132
+ * immutable, so an existing snapshot is never stale — new lines belong to the
133
+ * live tail stream). File sources ARE their own snapshot (append-only).
134
+ */
135
+ export function ensureSnapshot(source: HistorySource): string | null {
136
+ if (source.kind === "file") return source.path;
137
+ if (source.kind !== "docker") return null;
138
+
139
+ fs.mkdirSync(HISTORY_DIR, { recursive: true });
140
+ const snapPath = path.join(HISTORY_DIR, `${source.container}.log`);
141
+ if (fs.existsSync(snapPath)) return snapPath;
142
+
143
+ const tmp = `${snapPath}.tmp`;
144
+ const fd = fs.openSync(tmp, "w");
145
+ try {
146
+ // stdout straight to the file — a multi-GB log must never transit a
147
+ // JS string buffer.
148
+ const res = spawnSync(
149
+ "docker",
150
+ ["logs", "--timestamps", source.container],
151
+ { stdio: ["ignore", fd, fd], timeout: 120_000 },
152
+ );
153
+ if (res.status !== 0) {
154
+ fs.rmSync(tmp, { force: true });
155
+ return null;
156
+ }
157
+ } finally {
158
+ fs.closeSync(fd);
159
+ }
160
+ fs.renameSync(tmp, snapPath);
161
+ return snapPath;
162
+ }
163
+
164
+ /**
165
+ * Drop snapshots whose container no longer exists (their docker history is
166
+ * gone with the container; the snapshot is orphaned disk). Call at web-server
167
+ * startup, like pruneDeadDaemons().
168
+ */
169
+ export function pruneDeadSnapshots(): number {
170
+ let removed = 0;
171
+ let entries: string[];
172
+ try {
173
+ entries = fs.readdirSync(HISTORY_DIR);
174
+ } catch {
175
+ return 0;
176
+ }
177
+ const out = spawnSync("docker", ["ps", "-a", "--format", "{{.Names}}"], {
178
+ encoding: "utf-8",
179
+ timeout: 10_000,
180
+ });
181
+ if (out.status !== 0) return 0; // docker unavailable — keep everything
182
+ const alive = new Set(
183
+ (out.stdout ?? "")
184
+ .split("\n")
185
+ .map((l) => l.trim())
186
+ .filter(Boolean),
187
+ );
188
+ for (const entry of entries) {
189
+ if (!entry.endsWith(".log")) continue;
190
+ if (!alive.has(entry.slice(0, -4))) {
191
+ try {
192
+ fs.rmSync(path.join(HISTORY_DIR, entry), { force: true });
193
+ removed++;
194
+ } catch {
195
+ /* best effort */
196
+ }
197
+ }
198
+ }
199
+ return removed;
200
+ }
201
+
202
+ // ── Byte-offset backward paging ───────────────────────────────────────────────
203
+
204
+ export interface HistoryPage {
205
+ /** Lines in file order (oldest first). */
206
+ lines: string[];
207
+ /** Opaque cursor for the NEXT (older) page — byte offset of lines[0]. */
208
+ cursor: string;
209
+ /** True when lines[0] is the first line of the file. */
210
+ atStart: boolean;
211
+ }
212
+
213
+ const DEFAULT_BLOCK = 256 * 1024;
214
+
215
+ /** Safety valve for pathological single-line files (no newlines for GBs). */
216
+ const MAX_CARRY = 64 * 1024 * 1024;
217
+
218
+ /**
219
+ * Read up to `limit` lines ENDING just before byte offset `cursor`
220
+ * (cursor null/"" = end of file). Reads backwards in bounded blocks —
221
+ * O(returned bytes), no full-file scan, no shell.
222
+ *
223
+ * Cursor contract: the returned cursor is the byte offset of the FIRST
224
+ * returned line; passing it back returns the lines immediately before it.
225
+ * Offsets into an append-only file are immutable, so pages never shift.
226
+ */
227
+ export function readPageBefore(
228
+ filePath: string,
229
+ cursor: string | null,
230
+ limit: number,
231
+ blockSize: number = DEFAULT_BLOCK,
232
+ ): HistoryPage {
233
+ const fd = fs.openSync(filePath, "r");
234
+ try {
235
+ const size = fs.fstatSync(fd).size;
236
+ const end = cursor ? Math.min(parseInt(cursor, 10) || 0, size) : size;
237
+ if (end <= 0 || limit <= 0)
238
+ return { lines: [], cursor: "0", atStart: true };
239
+
240
+ // The region [0, end) ends with the terminator of its last line (except
241
+ // possibly at EOF with no trailing newline). Work with the region's
242
+ // CONTENT — exclude that trailing newline so split() yields real lines.
243
+ const lastByte = Buffer.alloc(1);
244
+ fs.readSync(fd, lastByte, 0, 1, end - 1);
245
+ const logicalEnd = lastByte[0] === 0x0a ? end - 1 : end;
246
+ if (logicalEnd <= 0) return { lines: [], cursor: "0", atStart: true };
247
+
248
+ // Read backwards until the buffer holds at least `limit` newlines
249
+ // (⇒ limit+1 segments, first possibly partial) or we reach byte 0.
250
+ let pos = logicalEnd;
251
+ let carry = Buffer.alloc(0);
252
+ let newlines = 0;
253
+ while (pos > 0 && newlines < limit && carry.length < MAX_CARRY) {
254
+ const readStart = Math.max(0, pos - blockSize);
255
+ const buf = Buffer.alloc(pos - readStart);
256
+ fs.readSync(fd, buf, 0, buf.length, readStart);
257
+ carry = Buffer.concat([buf, carry]);
258
+ pos = readStart;
259
+ newlines = 0;
260
+ for (const b of carry) if (b === 0x0a) newlines++;
261
+ }
262
+
263
+ // NB: byte math below must run on the RAW segments — \r stripping for
264
+ // display happens at the end so cursors stay exact.
265
+ const segs = carry.toString("utf-8").split("\n");
266
+ let lines: string[];
267
+ if (pos === 0 && segs.length <= limit) {
268
+ lines = segs; // whole remaining region fits
269
+ } else if (pos > 0 && segs.length <= limit) {
270
+ // MAX_CARRY tripped inside one enormous line: represent it truncated
271
+ // and jump the cursor to the read boundary so paging stays monotonic.
272
+ return {
273
+ lines: [
274
+ "… [oversized line truncated by history pager] …",
275
+ ...segs.slice(1).map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l)),
276
+ ],
277
+ cursor: String(pos),
278
+ atStart: false,
279
+ };
280
+ } else {
281
+ // Last `limit` segments are guaranteed-complete lines; segs[0] may be a
282
+ // partial line cut at a block boundary and is discarded implicitly.
283
+ lines = segs.slice(-limit);
284
+ }
285
+ const startOffset =
286
+ logicalEnd - Buffer.byteLength(lines.join("\n"), "utf-8");
287
+ return {
288
+ // CRLF sources (docker preserves container \r\n) — strip for display
289
+ // AFTER the byte math; live-tail lines are \r-free, so this keeps the
290
+ // client's seam dedupe exact.
291
+ lines: lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l)),
292
+ cursor: String(startOffset),
293
+ atStart: startOffset === 0,
294
+ };
295
+ } finally {
296
+ fs.closeSync(fd);
297
+ }
298
+ }
299
+
300
+ // ── Cross-system history search ───────────────────────────────────────────────
301
+
302
+ export interface SearchMatch {
303
+ /** RFC3339 timestamp when the source has one (docker --timestamps). */
304
+ ts: string | null;
305
+ line: string;
306
+ /** Surrounding context lines (already timestamp-stripped like `line`). */
307
+ context: string[];
308
+ }
309
+
310
+ export interface SystemSearchResult {
311
+ system: string;
312
+ matches: SearchMatch[];
313
+ truncated: boolean;
314
+ error?: string;
315
+ /** True when the system was excluded because it is not running. */
316
+ skipped?: boolean;
317
+ }
318
+
319
+ const TS_PREFIX = /^(\d{4}-\d{2}-\d{2}T\S+Z)\s?/;
320
+
321
+ function splitTs(raw: string): { ts: string | null; line: string } {
322
+ const m = raw.match(TS_PREFIX);
323
+ return m
324
+ ? { ts: m[1], line: raw.slice(m[0].length) }
325
+ : { ts: null, line: raw };
326
+ }
327
+
328
+ /**
329
+ * Parse `grep -n -C k` output (`N:line` matches, `N-line` context, `--`
330
+ * group separators) into structured matches.
331
+ */
332
+ export function parseGrepOutput(
333
+ out: string,
334
+ maxMatches: number,
335
+ ): { matches: SearchMatch[]; truncated: boolean } {
336
+ const matches: SearchMatch[] = [];
337
+ let current: SearchMatch | null = null;
338
+ let truncated = false;
339
+ for (let raw of out.split("\n")) {
340
+ // CRLF logs: `.` doesn't match \r in JS regexes, so strip it first.
341
+ if (raw.endsWith("\r")) raw = raw.slice(0, -1);
342
+ if (!raw || raw === "--") {
343
+ current = null;
344
+ continue;
345
+ }
346
+ const m = raw.match(/^(\d+)([:-])(.*)$/);
347
+ if (!m) continue;
348
+ const [, , sep, rest] = m;
349
+ const { ts, line } = splitTs(rest);
350
+ if (sep === ":") {
351
+ if (matches.length >= maxMatches) {
352
+ truncated = true;
353
+ break;
354
+ }
355
+ current = { ts, line, context: [] };
356
+ matches.push(current);
357
+ } else if (current) {
358
+ current.context.push(line);
359
+ } else if (matches.length < maxMatches) {
360
+ // Leading context before the first match of a group.
361
+ current = null;
362
+ }
363
+ }
364
+ return { matches, truncated };
365
+ }
366
+
367
+ const MAX_MATCHES_PER_SYSTEM = 200;
368
+
369
+ /**
370
+ * Search one system's FULL history (snapshot / daemon file) for a
371
+ * case-insensitive fixed string. Bounded output.
372
+ */
373
+ export async function searchSystemHistory(
374
+ system: { repo: string; service: string },
375
+ q: string,
376
+ env: string,
377
+ ): Promise<SystemSearchResult> {
378
+ const label = `${system.repo}/${system.service}`;
379
+ const source = resolveHistorySource(system.repo, system.service, env);
380
+ if (source.kind === "unsupported") {
381
+ return {
382
+ system: label,
383
+ matches: [],
384
+ truncated: false,
385
+ error: source.reason,
386
+ };
387
+ }
388
+
389
+ // Search covers RUNNING systems only. Docker sources are running by
390
+ // construction (`docker ps` lists live containers). File sources leak in
391
+ // stopped systems — their daemon log survives the process — so gate them
392
+ // on actual run-state (same test as the workspace down-skip: opctl
393
+ // containers, or a healthy probe for host-run processes).
394
+ if (source.kind === "file") {
395
+ const resolved =
396
+ resolveService(`${system.repo}/${system.service}`) ??
397
+ resolveService(system.repo);
398
+ const running =
399
+ resolved !== null &&
400
+ (hasRunningOpctlContainers(resolved) ||
401
+ (await checkServiceHealth(resolved, env as Environment)).health ===
402
+ "ok");
403
+ if (!running) {
404
+ return { system: label, matches: [], truncated: false, skipped: true };
405
+ }
406
+ }
407
+
408
+ const file = ensureSnapshot(source);
409
+ if (!file) {
410
+ return {
411
+ system: label,
412
+ matches: [],
413
+ truncated: false,
414
+ error: "could not snapshot container log",
415
+ };
416
+ }
417
+
418
+ return new Promise((resolve) => {
419
+ // -m bounds matching lines server-side; +context stays small. 10 MB
420
+ // buffer is far above 200 matches × context.
421
+ execFile(
422
+ "grep",
423
+ [
424
+ "-inF",
425
+ "-m",
426
+ String(MAX_MATCHES_PER_SYSTEM + 1),
427
+ "-C",
428
+ "2",
429
+ "--",
430
+ q,
431
+ file,
432
+ ],
433
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
434
+ (err, stdout) => {
435
+ // grep exit 1 = no matches; treat other failures as empty-with-error.
436
+ if (err && (err as { code?: number }).code !== 1 && !stdout) {
437
+ resolve({
438
+ system: label,
439
+ matches: [],
440
+ truncated: false,
441
+ error: err.message.slice(0, 200),
442
+ });
443
+ return;
444
+ }
445
+ const { matches, truncated } = parseGrepOutput(
446
+ stdout ?? "",
447
+ MAX_MATCHES_PER_SYSTEM,
448
+ );
449
+ resolve({ system: label, matches, truncated });
450
+ },
451
+ );
452
+ });
453
+ }
@@ -0,0 +1,42 @@
1
+ import { loadCurrentPerfRunReport } from "../../shared/perf/report-store.js";
2
+ import type { Pillar } from "../../shared/types/index.js";
3
+
4
+ interface RunDonePayloadInput {
5
+ pillar: Pillar;
6
+ exitCode: number;
7
+ duration: number;
8
+ rootDir?: string;
9
+ }
10
+
11
+ export function getRunDonePayload(input: RunDonePayloadInput): {
12
+ type: "done";
13
+ exitCode: number;
14
+ duration: number;
15
+ perfReport?: {
16
+ available: boolean;
17
+ url: string;
18
+ };
19
+ } {
20
+ const base = {
21
+ type: "done" as const,
22
+ exitCode: input.exitCode,
23
+ duration: input.duration,
24
+ };
25
+
26
+ if (input.pillar !== "performance") {
27
+ return base;
28
+ }
29
+
30
+ const report = loadCurrentPerfRunReport(input.rootDir);
31
+ if (!report) {
32
+ return base;
33
+ }
34
+
35
+ return {
36
+ ...base,
37
+ perfReport: {
38
+ available: true,
39
+ url: "/api/perf-report/current",
40
+ },
41
+ };
42
+ }