@app-vitals/agent-runtime 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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +40 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
});
|