@app-vitals/agent-runtime 0.0.1 → 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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/Dockerfile ADDED
@@ -0,0 +1,265 @@
1
+ # Agent runtime — container image (ghcr.io/app-vitals/agent)
2
+ #
3
+ # Build from the repository root: docker build -f runtime/Dockerfile .
4
+ #
5
+ # Environment variables:
6
+ # AGENT_HOME — persistent storage root (default: /data/agent-home)
7
+ # Mount a PVC here so mise tool caches, workspace files,
8
+ # and the ~/.claude symlink target all survive restarts.
9
+ # SHIPWRIGHT_HEALTH_PORT — K8s liveness probe port (default: 3459)
10
+ #
11
+ # PVC mount point:
12
+ # /data/agent-home — mount a PersistentVolumeClaim (or any durable volume) here.
13
+ # The agent symlinks ~/.claude → $AGENT_HOME/dot-claude so
14
+ # Claude Code settings and memory persist across pod restarts.
15
+ # The container runs as uid 1000, so the pod must set
16
+ # `securityContext.fsGroup: 1000` for the mounted volume to be
17
+ # writable.
18
+ #
19
+ # Required env vars at runtime:
20
+ # SHIPWRIGHT_AGENT_ID — the agent's ID in the Shipwright platform
21
+ # SHIPWRIGHT_API_URL — base URL of the Shipwright API
22
+ # SHIPWRIGHT_AGENT_API_KEY — bearer token for the config endpoint
23
+ # ANTHROPIC_API_KEY — Anthropic API key (or CLAUDE_CODE_OAUTH_TOKEN)
24
+
25
+ # ─── Node.js binary source ─────────────────────────────────────────────────
26
+ # Copied into the base stage below instead of piping deb.nodesource.com's
27
+ # setup script through bash — that endpoint intermittently 403s in CI,
28
+ # blocking every build. node:22-trixie-slim matches oven/bun:1-slim's Debian
29
+ # release (trixie) so the copied binary is glibc-compatible.
30
+ FROM node:24-trixie-slim AS node-source
31
+
32
+ # ─── Base: system tooling installed as root ───────────────────────────────────
33
+
34
+ FROM oven/bun:1-slim AS base
35
+
36
+ # Install system dependencies:
37
+ # - git — required by Claude Code and mise
38
+ # - curl — used by the mise installer and gh keyring setup
39
+ # - ca-certificates — TLS trust for API calls
40
+ # - unzip — needed by some mise-managed tools
41
+ # - jq — bash JSON parsing in the time-tracking Claude Code hooks
42
+ # - build-essential / libssl-dev / pkg-config — native + Rust toolchain so the
43
+ # agent can build client code at runtime (e.g. Rust projects + OpenSSL crates).
44
+ # (Piper TTS for voice-out is bundled in the runtime stage below; ffmpeg /
45
+ # python-edge-tts stay omitted — Piper emits WAV natively.)
46
+ # - Chromium/chromium-headless-shell OS deps for Playwright (PW-1.1/PW-1.2 —
47
+ # agent/src/runtime/browser.ts, follow-up task) — installed here as root so the
48
+ # runtime stage's `playwright install chromium-headless-shell` (run as USER
49
+ # 1000, no --with-deps) has everything the binary needs to actually launch.
50
+ # This exact package list is Playwright 1.61.1's real dependency set —
51
+ # verified by running `playwright install-deps --dry-run chromium-headless-shell`
52
+ # against an installed 1.61.1, not assumed from docs or an older version's
53
+ # source. That dry run confirms chromium-headless-shell needs the identical
54
+ # package set as full `chromium` on this OS.
55
+ # oven/bun:1-slim is Debian trixie (13) — see node-source stage comment above
56
+ # — hence the t64-suffixed package names (Debian's 64-bit time_t transition).
57
+ RUN apt-get update && apt-get install -y --no-install-recommends \
58
+ git \
59
+ curl \
60
+ ca-certificates \
61
+ unzip \
62
+ jq \
63
+ build-essential \
64
+ libssl-dev \
65
+ pkg-config \
66
+ fonts-freefont-ttf \
67
+ fonts-ipafont-gothic \
68
+ fonts-liberation \
69
+ fonts-noto-color-emoji \
70
+ fonts-tlwg-loma-otf \
71
+ fonts-unifont \
72
+ fonts-wqy-zenhei \
73
+ libasound2t64 \
74
+ libatk-bridge2.0-0t64 \
75
+ libatk1.0-0t64 \
76
+ libatspi2.0-0t64 \
77
+ libcairo2 \
78
+ libcups2t64 \
79
+ libdbus-1-3 \
80
+ libdrm2 \
81
+ libfontconfig1 \
82
+ libfreetype6 \
83
+ libgbm1 \
84
+ libglib2.0-0t64 \
85
+ libnspr4 \
86
+ libnss3 \
87
+ libpango-1.0-0 \
88
+ libx11-6 \
89
+ libxcb1 \
90
+ libxcomposite1 \
91
+ libxdamage1 \
92
+ libxext6 \
93
+ libxfixes3 \
94
+ libxkbcommon0 \
95
+ libxrandr2 \
96
+ xfonts-scalable \
97
+ xvfb \
98
+ && rm -rf /var/lib/apt/lists/*
99
+
100
+ # Install gh CLI via official GitHub apt keyring
101
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
102
+ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
103
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
104
+ > /etc/apt/sources.list.d/github-cli.list \
105
+ && apt-get update \
106
+ && apt-get install -y --no-install-recommends gh \
107
+ && rm -rf /var/lib/apt/lists/*
108
+
109
+ # Install the mise binary to a world-executable location (NOT /root, so the
110
+ # non-root runtime user can invoke it). Downloaded to a file first (rather than
111
+ # piped straight into sh) so curl's own exit code gates the step — dash (this
112
+ # base image's /bin/sh) has no pipefail, so `curl ... | sh` on a transient
113
+ # curl failure (e.g. a mid-download connection reset) sends sh 0 bytes on
114
+ # stdin, which sh happily runs as a no-op and exits 0, silently shipping an
115
+ # image with no /usr/local/bin/mise. The trailing `test -x` is a second,
116
+ # independent gate against the installer itself running but not landing the
117
+ # binary at this exact path.
118
+ RUN curl -fsSL -o /tmp/mise-install.sh https://mise.run \
119
+ && MISE_INSTALL_PATH=/usr/local/bin/mise sh /tmp/mise-install.sh \
120
+ && rm /tmp/mise-install.sh \
121
+ && test -x /usr/local/bin/mise
122
+
123
+ # Install Node.js + Claude Code CLI system-wide (as root) on /usr/bin.
124
+ # claude is a plain binary on PATH (/usr/bin/claude) backed by system node — it is
125
+ # NOT a mise shim, so its resolution never depends on the active mise/workspace
126
+ # context (which is what broke the previous mise-reshim approach: "claude is not a
127
+ # valid shim"). mise stays purely a runtime *workspace* tool-version manager.
128
+ # Node itself is copied from the node-source stage above rather than fetched via
129
+ # deb.nodesource.com (see that stage's comment).
130
+ COPY --from=node-source /usr/local/bin/node /usr/local/bin/node
131
+ COPY --from=node-source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
132
+ # renovate: datasource=npm depName=@anthropic-ai/claude-code
133
+ ARG CLAUDE_CODE_VERSION=2.1.292
134
+ # The trailing check fails the build if the installed CLI doesn't report exactly
135
+ # the pinned version (e.g. a registry/tag mismatch), so a drifted image can't ship.
136
+ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
137
+ && ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
138
+ && npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
139
+ && INSTALLED="$(claude --version)" \
140
+ && echo "claude --version: ${INSTALLED}" \
141
+ && case "${INSTALLED}" in \
142
+ "${CLAUDE_CODE_VERSION} "*) ;; \
143
+ *) echo "claude --version reported '${INSTALLED}', expected ${CLAUDE_CODE_VERSION}" >&2; exit 1 ;; \
144
+ esac
145
+
146
+ # Anthropic's container guidance for reproducible builds: pin the version above
147
+ # and disable the in-container auto-updater. Self-update can't work here anyway
148
+ # (root-owned npm global dir, uid 1000 runtime, ephemeral pods). Set in the base
149
+ # stage so it carries into runtime; runtime/claude.ts builds the child env
150
+ # from process.env, so spawned claude processes inherit it.
151
+ ENV DISABLE_AUTOUPDATER=1
152
+
153
+ # ─── Build stage ──────────────────────────────────────────────────────────────
154
+
155
+ FROM base AS build
156
+
157
+ WORKDIR /app
158
+
159
+ # Workspace manifests first (layer caching for deps). admin/prisma is needed
160
+ # because admin's postinstall runs `prisma generate`. bun.lock is copied when
161
+ # the repository has one.
162
+ COPY package.json bunfig.toml bun.lock* ./
163
+ COPY runtime/package.json ./runtime/
164
+ COPY admin/package.json ./admin/
165
+ COPY admin/prisma ./admin/prisma/
166
+ COPY admin/prisma.config.ts ./admin/
167
+ COPY lib/package.json ./lib/
168
+ COPY patches/ ./patches/
169
+
170
+ RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
171
+
172
+ # Workspace sources. @app-vitals/agent-lib / -runtime / -admin are workspace
173
+ # symlinks; only their package.json files were copied above, so the sources
174
+ # must exist for the symlinks to resolve.
175
+ COPY runtime/ ./runtime/
176
+ COPY admin/ ./admin/
177
+ COPY lib/ ./lib/
178
+ # Agent-type definitions (setup.ts reads identity.templatesDir from them).
179
+ COPY agent-types/ ./agent-types/
180
+ # Composition root spawned by runtime/entrypoint.ts (../index.ts).
181
+ COPY index.ts ./index.ts
182
+ COPY scripts/ ./scripts/
183
+
184
+ # Module-resolution guard: bundle every runtime source file as its own entry
185
+ # point so an unresolvable import fails the build itself, rather than only
186
+ # surfacing as a runtime crash after the image ships. A plain `docker build`
187
+ # succeeding is not proof the image runs; this bundles the whole runtime tree,
188
+ # not just entrypoint-main.ts's reachable graph.
189
+ # playwright/playwright-core are externalized: bun's bundler eagerly resolves
190
+ # playwright-core's internal optional chromium-bidi requires, which aren't
191
+ # actually installed or used by our CDP-based launch path.
192
+ RUN find runtime -name "*.ts" | xargs bun build --target=bun --external playwright --external playwright-core --outdir=/tmp/module-check
193
+
194
+ # ─── Runtime stage (non-root) ─────────────────────────────────────────────────
195
+
196
+ FROM base AS runtime
197
+
198
+ WORKDIR /app
199
+
200
+ ENV NODE_ENV=production
201
+
202
+ # AGENT_HOME: mount a persistent volume here.
203
+ ENV AGENT_HOME=/data/agent-home
204
+
205
+ # Create the persistent-volume mount point and hand it to uid 1000 so the agent
206
+ # can write when no volume is mounted. A mounted PVC needs `fsGroup: 1000`.
207
+ RUN mkdir -p /data/agent-home && chown 1000:1000 /data/agent-home
208
+
209
+ USER 1000
210
+ ENV HOME=/home/bun
211
+
212
+ # mise workspace tool shims first on PATH; /app/scripts/bin holds the
213
+ # git-credential helper (runtime/entrypoint-main.ts resolves it relative to
214
+ # the repository root), the gh owner-routing wrapper and the git wrapper that
215
+ # routes push through signed-push.
216
+ ENV PATH="/home/bun/.local/share/mise/shims:/app/scripts/bin:${PATH}"
217
+
218
+ COPY --from=build --chown=1000:1000 /app/node_modules ./node_modules
219
+ COPY --from=build --chown=1000:1000 /app/runtime ./runtime
220
+ COPY --from=build --chown=1000:1000 /app/admin ./admin
221
+ COPY --from=build --chown=1000:1000 /app/lib ./lib
222
+ COPY --from=build --chown=1000:1000 /app/agent-types ./agent-types
223
+ COPY --from=build --chown=1000:1000 /app/index.ts ./index.ts
224
+ COPY --from=build --chown=1000:1000 /app/scripts ./scripts
225
+ COPY --from=build --chown=1000:1000 /app/package.json ./package.json
226
+
227
+ RUN chmod +x /app/scripts/bin/git-credential-shipwright.sh \
228
+ && chmod +x /app/scripts/bin/gh \
229
+ && chmod +x /app/scripts/bin/git
230
+
231
+ # ─── Playwright chromium-headless-shell (runtime/browser.ts) ──────────────────
232
+ # Installed as the already-active USER 1000 under /home/bun, so nothing lands
233
+ # root-owned. No --with-deps: the OS-level deps were installed as root in the
234
+ # base stage.
235
+ ENV PLAYWRIGHT_BROWSERS_PATH=/home/bun/.cache/ms-playwright
236
+ RUN bunx playwright install chromium-headless-shell
237
+
238
+ # ─── Piper offline TTS (voice-out) ────────────────────────────────────────────
239
+ # runtime/piper-voice.ts hardcodes the model dir to /app/agent/voices/, so the
240
+ # model MUST live at that exact path. amd64 only.
241
+ USER root
242
+ ARG PIPER_VERSION=2023.11.14-2
243
+ RUN curl -fsSL -o /tmp/piper.tar.gz \
244
+ "https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/piper_linux_x86_64.tar.gz" \
245
+ && tar -xzf /tmp/piper.tar.gz -C /opt \
246
+ && rm /tmp/piper.tar.gz \
247
+ && mkdir -p /app/agent/voices \
248
+ && curl -fsSL -o /app/agent/voices/en_US-hfc_female-medium.onnx \
249
+ "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/hfc_female/medium/en_US-hfc_female-medium.onnx" \
250
+ && curl -fsSL -o /app/agent/voices/en_US-hfc_female-medium.onnx.json \
251
+ "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/hfc_female/medium/en_US-hfc_female-medium.onnx.json" \
252
+ && chown -R 1000:1000 /app/agent/voices
253
+ ENV PATH="/opt/piper:${PATH}"
254
+ USER 1000
255
+
256
+ VOLUME ["/data/agent-home"]
257
+
258
+ # Health probe port
259
+ EXPOSE 3459
260
+
261
+ # Base-image contract (downstream images build FROM this one):
262
+ # - marketplaces COPYed to /opt/shipwright/marketplaces/<name>/ are registered
263
+ # at boot by installPlugins() (runtime/setup.ts)
264
+ # - the entrypoint path below is stable
265
+ ENTRYPOINT ["bun", "run", "/app/runtime/entrypoint-main.ts"]
@@ -0,0 +1,53 @@
1
+ /**
2
+ * agent/src/runtime/agent-account-status-ref.ts
3
+ *
4
+ * A tiny mutable box holding the agent's most recently synced owning-account
5
+ * status (SSP-8.3), so agent/src/runtime/slack.ts's isAccountPaused() gate can read a
6
+ * live view without closing over a single syncConfig() tick. Pure and
7
+ * zero-I/O — mirrors agent-trial-expiry-ref.ts exactly.
8
+ */
9
+
10
+ export interface AgentAccountStatusRef {
11
+ /**
12
+ * Most recently synced account status ('active' | 'suspended' |
13
+ * 'trial_expired'), or null when set() was never called or the agent has
14
+ * no account. Use hasSynced() to tell "never synced" from "no account".
15
+ */
16
+ get(): string | null;
17
+ /**
18
+ * True once set() has been called at least once. Consumers gating on
19
+ * account status should fail open while this is false, so a persistent
20
+ * config-sync failure never locks a customer out of their own agent.
21
+ */
22
+ hasSynced(): boolean;
23
+ /** Replaces the current account status. */
24
+ set(status: string | null): void;
25
+ }
26
+
27
+ /** Creates a new, independent ref defaulting to "no account". */
28
+ export function createAgentAccountStatusRef(): AgentAccountStatusRef {
29
+ let accountStatus: string | null = null;
30
+ let synced = false;
31
+
32
+ return {
33
+ get(): string | null {
34
+ return accountStatus;
35
+ },
36
+ hasSynced(): boolean {
37
+ return synced;
38
+ },
39
+ set(next: string | null): void {
40
+ accountStatus = next;
41
+ synced = true;
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The process-wide account-status ref. agent/src/index.ts's syncConfig()
48
+ * writes into it on every successful config-sync tick; slack.ts's
49
+ * isAccountPaused() reads it on every inbound Slack event, so a status change
50
+ * (suspension or reactivation) takes effect on the next sync with no restart.
51
+ */
52
+ export const agentAccountStatusRef: AgentAccountStatusRef =
53
+ createAgentAccountStatusRef();
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createAgentAccountStatusRef } from "./agent-account-status-ref.ts";
3
+
4
+ describe("createAgentAccountStatusRef", () => {
5
+ test("defaults to unsynced with a null status", () => {
6
+ const ref = createAgentAccountStatusRef();
7
+ expect(ref.hasSynced()).toBe(false);
8
+ expect(ref.get()).toBeNull();
9
+ });
10
+
11
+ test("set() stores the status and marks the ref synced", () => {
12
+ const ref = createAgentAccountStatusRef();
13
+ ref.set("suspended");
14
+ expect(ref.hasSynced()).toBe(true);
15
+ expect(ref.get()).toBe("suspended");
16
+ });
17
+
18
+ test("set(null) marks synced with no account", () => {
19
+ const ref = createAgentAccountStatusRef();
20
+ ref.set(null);
21
+ expect(ref.hasSynced()).toBe(true);
22
+ expect(ref.get()).toBeNull();
23
+ });
24
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * agent/src/runtime/agent-extension.ts
3
+ *
4
+ * The extension seam between the generic agent runtime (boot.ts, config
5
+ * sync, cron scheduling) and specialty-specific behavior (today: the coding
6
+ * agent's PR/task reconcilers and shipwright-loop orchestrator).
7
+ *
8
+ * The runtime calls an AgentExtension at fixed points:
9
+ *
10
+ * onConfigSync(bundle) — every successful config-sync tick
11
+ * periodicPasses — each tick of the periodic reconciler interval
12
+ * onCronJobs(jobs) — every cron-sync tick, BEFORE classifyCronJobs
13
+ * classifyCronJobs(jobs) — which jobs get a schedule + dispatch kind
14
+ * dispatchLoop(loopCronId) — fire of a job classified as dispatch "loop"
15
+ *
16
+ * Every hook is optional; the defaults reproduce an agent with no extension
17
+ * (all jobs generic, no periodic passes). This file must not import any
18
+ * coding module — index.ts (the composition root) is the only file that wires
19
+ * a concrete extension in.
20
+ */
21
+
22
+ import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
23
+ import type { createRunClaude } from "./claude.ts";
24
+ import type { createConfig } from "./config.ts";
25
+ import type { createContextStampReader } from "./context-stamp.ts";
26
+ import type { CronRunReporter } from "./cron-run-reporter.ts";
27
+ import type { createFileSessionStore } from "./sessions.ts";
28
+
29
+ /** Minimal structural view of a cron job, as the seam needs it. */
30
+ export interface ExtensionCronJob {
31
+ id: string;
32
+ name: string | null;
33
+ enabled: boolean;
34
+ parentCronId: string | null;
35
+ }
36
+
37
+ export type CronDispatchKind = "loop" | "generic";
38
+
39
+ export interface ScheduledCronJob<T extends ExtensionCronJob> {
40
+ job: T;
41
+ dispatch: CronDispatchKind;
42
+ }
43
+
44
+ /** A named, independent unit of periodic work run on the reconciler tick. */
45
+ export interface PeriodicPass {
46
+ name: string;
47
+ /** Prefix for the non-fatal failure log: `${logPrefix} tick failed (non-fatal):`. */
48
+ logPrefix: string;
49
+ run(): Promise<void>;
50
+ }
51
+
52
+ /** Everything an extension may need from the runtime to build itself. */
53
+ export interface AgentExtensionContext {
54
+ config: ReturnType<typeof createConfig>["config"];
55
+ runner: ReturnType<typeof createRunClaude>;
56
+ sessions: ReturnType<typeof createFileSessionStore>;
57
+ cronRunReporter: CronRunReporter | undefined;
58
+ contextStamp: ReturnType<typeof createContextStampReader>;
59
+ sentryClient: ErrorCapturingClient | undefined;
60
+ }
61
+
62
+ export interface AgentExtension<TBundle = unknown> {
63
+ onConfigSync?(bundle: TBundle): void;
64
+ periodicPasses?: PeriodicPass[];
65
+ onCronJobs?(jobs: ExtensionCronJob[]): void;
66
+ classifyCronJobs?<T extends ExtensionCronJob>(
67
+ jobs: T[],
68
+ ): ScheduledCronJob<T>[];
69
+ /** Runs one loop tick; the orchestrator behind it is built once, lazily. */
70
+ dispatchLoop?(loopCronId: string): Promise<void>;
71
+ }
72
+
73
+ /** Default classification: every enabled job is independently scheduled, generic. */
74
+ export function classifyAllGeneric<T extends ExtensionCronJob>(
75
+ jobs: T[],
76
+ ): ScheduledCronJob<T>[] {
77
+ return jobs
78
+ .filter((job) => job.enabled)
79
+ .map((job) => ({ job, dispatch: "generic" as const }));
80
+ }
81
+
82
+ /**
83
+ * One cron-sync tick's view of the job list: tells the extension about the
84
+ * fresh list first (so e.g. the loop's live jobs ref is current even when
85
+ * nothing gets rescheduled), then classifies it.
86
+ */
87
+ export function planCronSchedule<T extends ExtensionCronJob>(
88
+ extension: AgentExtension,
89
+ jobs: T[],
90
+ ): ScheduledCronJob<T>[] {
91
+ extension.onCronJobs?.(jobs);
92
+ return (extension.classifyCronJobs ?? classifyAllGeneric)(jobs);
93
+ }
94
+
95
+ /**
96
+ * Runs passes in order, each in its own try/catch so one failing pass never
97
+ * prevents the next. Failures are logged non-fatally with the pass's prefix.
98
+ */
99
+ export async function runPeriodicPasses(
100
+ passes: readonly PeriodicPass[],
101
+ ): Promise<void> {
102
+ for (const pass of passes) {
103
+ try {
104
+ await pass.run();
105
+ } catch (err) {
106
+ console.error(
107
+ `${pass.logPrefix} tick failed (non-fatal):`,
108
+ err instanceof Error ? err.message : String(err),
109
+ );
110
+ }
111
+ }
112
+ }
113
+
114
+ /** Builds an extension from the runtime context once the runtime has booted far enough to provide it. */
115
+ export type AgentExtensionFactory = (
116
+ ctx: AgentExtensionContext,
117
+ ) => AgentExtension;
118
+
119
+ /**
120
+ * Folds the extensions a consumer passed to startAgent into the single
121
+ * extension the runtime talks to. No extensions reproduces an agent with no
122
+ * specialty behavior; one extension is returned as-is. With several, notify
123
+ * hooks (onConfigSync, onCronJobs) fan out to all, periodic passes concatenate
124
+ * in order, and the first extension defining classifyCronJobs / dispatchLoop
125
+ * owns it — those two decide a single outcome, so they cannot be merged.
126
+ */
127
+ export function composeExtensions(
128
+ extensions: readonly AgentExtension[],
129
+ ): AgentExtension {
130
+ if (extensions.length === 1) return extensions[0];
131
+ const classifier = extensions.find((ext) => ext.classifyCronJobs);
132
+ const dispatcher = extensions.find((ext) => ext.dispatchLoop);
133
+ return {
134
+ onConfigSync: (bundle) => {
135
+ for (const ext of extensions) ext.onConfigSync?.(bundle);
136
+ },
137
+ periodicPasses: extensions.flatMap((ext) => ext.periodicPasses ?? []),
138
+ onCronJobs: (jobs) => {
139
+ for (const ext of extensions) ext.onCronJobs?.(jobs);
140
+ },
141
+ classifyCronJobs: classifier?.classifyCronJobs?.bind(classifier),
142
+ dispatchLoop: dispatcher?.dispatchLoop?.bind(dispatcher),
143
+ };
144
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it, spyOn } from "bun:test";
2
+ import {
3
+ type AgentExtension,
4
+ classifyAllGeneric,
5
+ composeExtensions,
6
+ type ExtensionCronJob,
7
+ type PeriodicPass,
8
+ planCronSchedule,
9
+ runPeriodicPasses,
10
+ } from "./agent-extension.ts";
11
+
12
+ const job = (id: string, enabled = true): ExtensionCronJob => ({
13
+ id,
14
+ name: id,
15
+ enabled,
16
+ parentCronId: null,
17
+ });
18
+
19
+ describe("planCronSchedule", () => {
20
+ it("calls onCronJobs before classifyCronJobs, with the same fresh list", () => {
21
+ const calls: string[] = [];
22
+ const jobs = [job("a")];
23
+ const extension: AgentExtension = {
24
+ onCronJobs: (j) => {
25
+ calls.push(`onCronJobs:${j === jobs}`);
26
+ },
27
+ classifyCronJobs: (j) => {
28
+ calls.push(`classify:${j === jobs}`);
29
+ return [];
30
+ },
31
+ };
32
+ planCronSchedule(extension, jobs);
33
+ planCronSchedule(extension, jobs);
34
+ expect(calls).toEqual([
35
+ "onCronJobs:true",
36
+ "classify:true",
37
+ "onCronJobs:true",
38
+ "classify:true",
39
+ ]);
40
+ });
41
+
42
+ it("defaults to all-generic when the extension has no classifier", () => {
43
+ const planned = planCronSchedule({}, [job("a"), job("b", false)]);
44
+ expect(planned).toEqual([{ job: job("a"), dispatch: "generic" }]);
45
+ });
46
+ });
47
+
48
+ describe("classifyAllGeneric", () => {
49
+ it("keeps only enabled jobs, all generic", () => {
50
+ expect(
51
+ classifyAllGeneric([job("a"), job("b", false)]).map((e) => e.dispatch),
52
+ ).toEqual(["generic"]);
53
+ });
54
+ });
55
+
56
+ describe("runPeriodicPasses", () => {
57
+ it("runs passes in order and isolates a failing pass with its own log prefix", async () => {
58
+ const order: string[] = [];
59
+ const errors = spyOn(console, "error").mockImplementation(() => {});
60
+ const pass = (name: string, fail = false): PeriodicPass => ({
61
+ name,
62
+ logPrefix: `[${name}]`,
63
+ run: async () => {
64
+ order.push(name);
65
+ if (fail) throw new Error("boom");
66
+ },
67
+ });
68
+ await runPeriodicPasses([pass("one"), pass("two", true), pass("three")]);
69
+ expect(order).toEqual(["one", "two", "three"]);
70
+ expect(errors).toHaveBeenCalledWith(
71
+ "[two] tick failed (non-fatal):",
72
+ "boom",
73
+ );
74
+ errors.mockRestore();
75
+ });
76
+ });
77
+
78
+ describe("composeExtensions", () => {
79
+ it("with no extensions behaves like an agent with no specialty behavior", async () => {
80
+ const extension = composeExtensions([]);
81
+ expect(() => extension.onConfigSync?.({})).not.toThrow();
82
+ expect(extension.periodicPasses).toEqual([]);
83
+ expect(planCronSchedule(extension, [job("a"), job("b", false)])).toEqual([
84
+ { job: job("a"), dispatch: "generic" },
85
+ ]);
86
+ expect(extension.dispatchLoop).toBeUndefined();
87
+ });
88
+
89
+ it("returns a single extension as-is", () => {
90
+ const stub: AgentExtension = { periodicPasses: [] };
91
+ expect(composeExtensions([stub])).toBe(stub);
92
+ });
93
+
94
+ it("fans out notify hooks and concatenates passes in order", () => {
95
+ const calls: string[] = [];
96
+ const pass = (name: string): PeriodicPass => ({
97
+ name,
98
+ logPrefix: `[${name}]`,
99
+ run: async () => {},
100
+ });
101
+ const a: AgentExtension = {
102
+ onConfigSync: () => calls.push("a:sync"),
103
+ onCronJobs: () => calls.push("a:jobs"),
104
+ periodicPasses: [pass("a1")],
105
+ };
106
+ const b: AgentExtension = {
107
+ onConfigSync: () => calls.push("b:sync"),
108
+ periodicPasses: [pass("b1"), pass("b2")],
109
+ };
110
+ const composed = composeExtensions([a, b]);
111
+ composed.onConfigSync?.({});
112
+ composed.onCronJobs?.([]);
113
+ expect(calls).toEqual(["a:sync", "b:sync", "a:jobs"]);
114
+ expect(composed.periodicPasses?.map((p) => p.name)).toEqual([
115
+ "a1",
116
+ "b1",
117
+ "b2",
118
+ ]);
119
+ });
120
+
121
+ it("lets the first extension defining classify / dispatchLoop own it", async () => {
122
+ const dispatched: string[] = [];
123
+ const owner: AgentExtension = {
124
+ classifyCronJobs: (jobs) =>
125
+ jobs.map((j) => ({ job: j, dispatch: "loop" })),
126
+ dispatchLoop: async (id) => {
127
+ dispatched.push(id);
128
+ },
129
+ };
130
+ const composed = composeExtensions([
131
+ {},
132
+ owner,
133
+ { dispatchLoop: async () => {} },
134
+ ]);
135
+ expect(planCronSchedule(composed, [job("x")])[0].dispatch).toBe("loop");
136
+ await composed.dispatchLoop?.("x");
137
+ expect(dispatched).toEqual(["x"]);
138
+ });
139
+ });