Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5bfd40233 | |||
| a40500eea9 | |||
| f8212f102f | |||
| 59302b465d | |||
| efafe44db1 | |||
| 6a2f81e873 | |||
| 3a43337735 | |||
| b6df89d24c | |||
| 10d992a7e4 | |||
| 5c63618b30 |
@@ -856,6 +856,9 @@
|
||||
"server/src/chat/commands/move_story.rs": [
|
||||
"fn handle_move"
|
||||
],
|
||||
"server/src/chat/commands/new_project.rs": [
|
||||
"fn handle_new_project_fallback"
|
||||
],
|
||||
"server/src/chat/commands/overview.rs": [
|
||||
"fn handle_overview"
|
||||
],
|
||||
@@ -1070,6 +1073,7 @@
|
||||
"mod config",
|
||||
"mod delete",
|
||||
"mod htop",
|
||||
"mod new_project",
|
||||
"mod rebuild",
|
||||
"mod reset",
|
||||
"mod rmtree",
|
||||
@@ -1077,6 +1081,13 @@
|
||||
"mod transport_impl",
|
||||
"fn spawn_bot"
|
||||
],
|
||||
"server/src/chat/transport/matrix/new_project.rs": [
|
||||
"struct NewProjectCommand",
|
||||
"fn extract_new_project_command",
|
||||
"fn detect_stack",
|
||||
"fn image_for_stack",
|
||||
"fn handle_new_project"
|
||||
],
|
||||
"server/src/chat/transport/matrix/rebuild.rs": [
|
||||
"struct RebuildCommand",
|
||||
"fn extract_rebuild_command",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Chat-Driven Project Bootstrap
|
||||
|
||||
Design overview for going from "I want a new project" to a running,
|
||||
container-isolated, editor-accessible huskies project in one chat command.
|
||||
|
||||
## Goal
|
||||
|
||||
A user can say to Timmy in chat:
|
||||
|
||||
```
|
||||
new project myapp --stack rust
|
||||
new project legacy-rails --git git@github.com:me/legacy-rails.git
|
||||
```
|
||||
|
||||
and end up with:
|
||||
|
||||
1. A fresh docker container running the project's huskies node.
|
||||
2. The project's source code bind-mounted from the host so the user can
|
||||
edit it in any editor.
|
||||
3. SSH into the container so editors can run LSPs, builds, and tests
|
||||
inside the container — never on the host.
|
||||
4. Optional git remote configured for push to GitHub or Gitea.
|
||||
5. The new sled registered with the gateway, so Timmy can drive coders /
|
||||
mergemaster / etc. on the project via existing chat commands.
|
||||
|
||||
Manual repo creation on GitHub/Gitea remains the user's job. Everything
|
||||
downstream of that is orchestrated.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Browser / Matrix │───┐
|
||||
└──────────────────────┘ │
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Gateway (huskies-gw) │
|
||||
│ • chat dispatcher │
|
||||
│ • new-project │
|
||||
│ • routing │
|
||||
└─────────┬─────────────┘
|
||||
│
|
||||
┌─────────┴───────────────────────────────────┐
|
||||
│ docker engine (host) │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌─────────┐ │
|
||||
│ │ project-A │ │ project-B │ │ ... │ │
|
||||
│ │ sled + │ │ sled + │ │ │ │
|
||||
│ │ sshd + │ │ sshd + │ │ │ │
|
||||
│ │ LSPs │ │ LSPs │ │ │ │
|
||||
│ └─────┬──────┘ └─────┬──────┘ └─────────┘ │
|
||||
└────────┼──────────────┼─────────────────────┘
|
||||
│ │
|
||||
bind mount │ │ bind mount
|
||||
┌────────┴───┐ ┌─────┴──────┐
|
||||
│ ~/code/A │ │ ~/code/B │ ◄── host
|
||||
└────────────┘ └────────────┘ editor opens
|
||||
these paths
|
||||
```
|
||||
|
||||
- One container per project. The container runs the project's huskies
|
||||
binary (sled), an SSH server, and the stack-appropriate LSP(s).
|
||||
- Source lives on the host (e.g. `~/code/<project>`), bind-mounted into
|
||||
the container at a known path. Host can git-diff, back up, or edit.
|
||||
- The gateway is editor-agnostic and project-agnostic — it talks to each
|
||||
sled via the existing rendezvous / CRDT-sync protocol.
|
||||
|
||||
## Three Personas
|
||||
|
||||
| Persona | What they do | What they need |
|
||||
|---------|--------------|----------------|
|
||||
| Chat-only user | Drives everything via Matrix/web chat | Installed huskies binary; chat client |
|
||||
| Editor-using technical user | Same + edits source in their editor | SSH config to the container + editor-specific remote-dev setup |
|
||||
| Multi-project user | Several projects running in parallel | Gateway-listed projects, all routable from one chat |
|
||||
|
||||
Chat-only users never touch SSH. Editor users go through a one-time
|
||||
"copy this SSH command into your editor's remote settings" handoff at
|
||||
project creation time.
|
||||
|
||||
## The Bootstrap Chat Command
|
||||
|
||||
```
|
||||
new project <name> [--stack <stack>] [--git <url>] [--path <host-path>]
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
1. **Validate**: name unique among existing projects; host path doesn't already
|
||||
exist; stack (if declared) is one of the supported overlays.
|
||||
2. **Allocate** a fresh per-project port range (gateway picks).
|
||||
3. **Create host directory** at `--path` (default `~/huskies/<name>/`).
|
||||
4. If `--git` provided, `git clone` into that directory; else `git init`.
|
||||
5. **Detect stack** from cloned content if not declared:
|
||||
- `Cargo.toml` → `rust`
|
||||
- `package.json` → `node`
|
||||
- `go.mod` → `go`
|
||||
- `pyproject.toml` / `requirements.txt` / `setup.py` → `python`
|
||||
- `Gemfile` → `ruby`
|
||||
- `pom.xml` / `build.gradle` → `jvm`
|
||||
- Multiple → pick the dominant, warn.
|
||||
- None → minimal base image, user can install tooling later.
|
||||
6. **Compose the container** from `huskies-project-base` + the stack
|
||||
overlay (Dockerfile fragments under `docker/stacks/<stack>/`).
|
||||
7. **Launch** the container with bind mount + port forwards + an
|
||||
auto-generated SSH key.
|
||||
8. **Seed `.huskies/project.toml`** with sensible defaults.
|
||||
9. **Register** the project with the gateway (`gateway_projects` LWW-map).
|
||||
10. **Reply in chat** with: project name, host path, SSH command, and
|
||||
a `huskies status <name>` invocation to verify.
|
||||
|
||||
## Container Template
|
||||
|
||||
Layered:
|
||||
|
||||
- **`huskies-project-base`**: debian-slim + git + huskies binary + sshd
|
||||
+ sudo + a `huskies` user with the SSH pubkey installed.
|
||||
- **`huskies-stack-<stack>`**: per-stack additions. E.g. rust gets
|
||||
`rustup` + `rust-analyzer` + `cargo-nextest`; node gets `node@22` +
|
||||
`typescript-language-server`; etc.
|
||||
- **Project layer**: the bind-mounted `/workspace` is the project source,
|
||||
written by the host's editor, read by the in-container tooling.
|
||||
|
||||
The container's SSH server is bound to a host-local port (not exposed
|
||||
externally). Auth is the per-project keypair generated at bootstrap;
|
||||
the public key sits inside the container, the private key on host.
|
||||
|
||||
## Build Sandbox Model
|
||||
|
||||
The threat: editing code in a host-side editor causes the editor (or its
|
||||
LSP plugin) to run `cargo check` / `npm install` / `pip install` /
|
||||
similar, which executes arbitrary code from project dependencies —
|
||||
`build.rs`, proc-macros, npm `postinstall`, Python `setup.py`, Ruby
|
||||
native-extension build scripts, etc. A malicious dependency compromises
|
||||
the host.
|
||||
|
||||
The mitigation: all build / type-check / dependency-install commands
|
||||
execute **inside the project container**. The host's editor connects to
|
||||
the container over SSH; rust-analyzer (or equivalent) runs inside the
|
||||
container; the host process never `exec`s untrusted build scripts.
|
||||
|
||||
Container isolation is the docker default plus:
|
||||
- No `--privileged`.
|
||||
- No host bind mounts beyond the project source and the SSH key.
|
||||
- No host network beyond the gateway's CRDT sync port.
|
||||
- `--cap-drop=ALL` plus the minimum caps needed (probably none).
|
||||
|
||||
This isn't a hardened sandbox in the gvisor / Firecracker sense — a
|
||||
docker-escape exploit on a compromised container still escalates to
|
||||
host. For most consumer threat models (malicious crate from
|
||||
crates.io / npm), docker's default isolation is sufficient. Tighter
|
||||
sandboxing (gvisor) is a separate future spike if needed.
|
||||
|
||||
## Editor Connection — Editor-Agnostic SSH
|
||||
|
||||
| Editor | Connection mechanism |
|
||||
|--------|----------------------|
|
||||
| VSCode | Remote-SSH extension |
|
||||
| JetBrains (IntelliJ/Rover) | JetBrains Gateway (SSH) |
|
||||
| Zed | Built-in SSH remoting (mac/linux only today) |
|
||||
| Vim/Neovim | SSH terminal session, or local nvim + LSP-over-SSH |
|
||||
| Emacs | TRAMP + remote LSP via lsp-mode |
|
||||
|
||||
All converge on: `ssh huskies@127.0.0.1 -p <project-port> -i ~/.huskies/<name>/id_ed25519`.
|
||||
That string is emitted in the bootstrap chat reply.
|
||||
|
||||
## Git Integration
|
||||
|
||||
- Initial setup is `git init` or `git clone` inside the container.
|
||||
- For push: user's existing GitHub / Gitea SSH key is bind-mounted
|
||||
read-only into the container at `~/.ssh/id_*`, OR the user supplies a
|
||||
push token via `huskies secrets set GIT_TOKEN=...` (stored as a Fly
|
||||
secret equivalent — for now, a chmod 600 file in the container).
|
||||
- The container's `git` config gets `user.name` / `user.email` from the
|
||||
gateway-level user identity.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Alternative |
|
||||
|----------|--------|-------------|
|
||||
| Container per project | One container per project | One container many projects: simpler but breaks isolation, breaks per-project deps |
|
||||
| Editor model | SSH-remote (any editor) | VSCode Dev Containers only: simpler config but locks out everyone else |
|
||||
| Source location | Bind mount from host | Inside container only: breaks "I can also edit on my laptop" requirement |
|
||||
| Stack detection | Auto from project files, override with `--stack` | Always declared: more friction at bootstrap |
|
||||
| Push secrets | Bind-mounted host SSH key OR per-project token | Gateway holds tokens: bigger blast radius |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Per-project resource limits.** Should each container have a hard
|
||||
CPU / RAM cap so a runaway agent doesn't starve the host?
|
||||
2. **Lifecycle / cleanup.** If the user deletes a project from chat,
|
||||
what gets removed? Container yes; host source no (data loss); git
|
||||
remotes yes? Need a confirm step.
|
||||
3. **Multi-tenant.** Out of scope for this design (that's huskies.dev
|
||||
territory). This doc assumes single-user local-only.
|
||||
4. **Windows specifics.** Bind mounts work but line-ending /
|
||||
permission edge cases. Probably document "use WSL2 for best
|
||||
experience" rather than fight Windows native paths.
|
||||
5. **Gateway-on-host vs gateway-in-container.** The gateway today runs
|
||||
in its own container. New per-project containers connect via docker
|
||||
network. Need to confirm the network plumbing works for arbitrary
|
||||
per-project containers, not just the manually-configured ones.
|
||||
|
||||
## Phasing
|
||||
|
||||
The work breaks naturally into:
|
||||
|
||||
- **Phase 0 (now):** this design doc.
|
||||
- **Phase 1:** chat command exists and provisions a bare project
|
||||
container (no stack overlay, no SSH, no git clone — just
|
||||
"start a container, register with gateway"). Validates the
|
||||
orchestration shell.
|
||||
- **Phase 2:** stack-aware container template — base image + overlays;
|
||||
detection from project files.
|
||||
- **Phase 3:** SSH-remote editor access — sshd in the container,
|
||||
per-project keypair, chat-reply emits the connection string.
|
||||
- **Phase 4:** git integration — `--git <url>` clones, host SSH key
|
||||
mount, push verification.
|
||||
- **Phase 5:** per-project resource limits + cleanup chat commands.
|
||||
|
||||
Each phase ships independently and is usable on its own. Phase 1 alone
|
||||
gives chat-only users a working project; later phases add the editor
|
||||
and git polish.
|
||||
Generated
+1
-1
@@ -1911,7 +1911,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "huskies"
|
||||
version = "0.11.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stream",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# huskies-project-base — minimal base for all project containers.
|
||||
#
|
||||
# This image provides git, the huskies server binary, and a non-root user.
|
||||
# It carries no language tooling. Per-stack overlays (docker/stacks/<name>/
|
||||
# Dockerfile.fragment) layer their toolchains on top of this base.
|
||||
#
|
||||
# Prerequisites: build the main `huskies` image first so its binary is
|
||||
# available as a build source.
|
||||
#
|
||||
# docker build -t huskies -f docker/Dockerfile .
|
||||
# docker build -t huskies-project-base -f docker/Dockerfile.base .
|
||||
#
|
||||
# To build a stack image (e.g. rust):
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-rust -
|
||||
|
||||
FROM huskies AS huskies-src
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
procps \
|
||||
openssh-server \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the huskies binary and entrypoint from the main image.
|
||||
COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies
|
||||
COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root.
|
||||
# -s /bin/bash required for SSH sessions to start a real shell.
|
||||
RUN groupadd -r huskies \
|
||||
&& useradd -r -g huskies -m -d /home/huskies -s /bin/bash huskies \
|
||||
&& mkdir -p /home/huskies/.claude \
|
||||
&& mkdir -p /home/huskies/.ssh \
|
||||
&& chmod 700 /home/huskies/.ssh \
|
||||
&& chown -R huskies:huskies /home/huskies \
|
||||
&& mkdir -p /workspace \
|
||||
&& chown huskies:huskies /workspace \
|
||||
&& git config --global init.defaultBranch master \
|
||||
&& echo "huskies ALL=(root) NOPASSWD: /usr/sbin/sshd" > /etc/sudoers.d/huskies-sshd \
|
||||
&& chmod 0440 /etc/sudoers.d/huskies-sshd \
|
||||
&& mkdir -p /run/sshd \
|
||||
&& sed -i \
|
||||
-e 's/#PasswordAuthentication yes/PasswordAuthentication no/' \
|
||||
-e 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' \
|
||||
-e 's/UsePAM yes/UsePAM no/' \
|
||||
/etc/ssh/sshd_config
|
||||
|
||||
# Shell profile for SSH sessions: land in /workspace and load toolchain paths.
|
||||
RUN printf 'cd /workspace\n[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"\n' \
|
||||
> /home/huskies/.profile \
|
||||
&& chown huskies:huskies /home/huskies/.profile
|
||||
|
||||
USER huskies
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 3001 22
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["huskies", "/workspace"]
|
||||
@@ -1,6 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── SSH authorized key ────────────────────────────────────────────────
|
||||
# HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair.
|
||||
# Write it to authorized_keys so the user can connect with the matching
|
||||
# private key stored at ~/.huskies/<project>/id_ed25519 on the host.
|
||||
if [ -n "$HUSKIES_SSH_PUBKEY" ]; then
|
||||
mkdir -p /home/huskies/.ssh
|
||||
chmod 700 /home/huskies/.ssh
|
||||
printf '%s\n' "$HUSKIES_SSH_PUBKEY" > /home/huskies/.ssh/authorized_keys
|
||||
chmod 600 /home/huskies/.ssh/authorized_keys
|
||||
fi
|
||||
|
||||
# ── SSH daemon ────────────────────────────────────────────────────────
|
||||
# Start sshd in the background so the container accepts SSH connections.
|
||||
# Uses sudo (huskies has NOPASSWD for /usr/sbin/sshd in sudoers.d).
|
||||
sudo /usr/sbin/sshd -D -e &
|
||||
|
||||
# ── Git identity ─────────────────────────────────────────────────────
|
||||
# Agents commit code inside the container. Without a git identity,
|
||||
# commits fail or use garbage defaults. Fail loudly at startup so the
|
||||
@@ -25,6 +41,20 @@ export GIT_COMMITTER_NAME="$GIT_USER_NAME"
|
||||
export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL"
|
||||
export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL"
|
||||
|
||||
# ── Git credential helper (HTTPS push) ────────────────────────────────────
|
||||
# If GIT_PUSH_TOKEN is supplied at container creation time, configure git's
|
||||
# built-in credential store so `git push` over HTTPS authenticates without
|
||||
# user interaction. GIT_CLONE_URL provides the host portion of the URL used
|
||||
# as the key in ~/.git-credentials.
|
||||
if [ -n "$GIT_PUSH_TOKEN" ] && [ -n "$GIT_CLONE_URL" ]; then
|
||||
_scheme=$(echo "$GIT_CLONE_URL" | cut -d':' -f1)
|
||||
_host=$(echo "$GIT_CLONE_URL" | sed 's|^https\?://||' | cut -d'/' -f1)
|
||||
git config --global credential.helper store
|
||||
printf '%s://x-access-token:%s@%s\n' "$_scheme" "$GIT_PUSH_TOKEN" "$_host" \
|
||||
> /home/huskies/.git-credentials
|
||||
chmod 600 /home/huskies/.git-credentials
|
||||
fi
|
||||
|
||||
# ── Frontend native deps ────────────────────────────────────────────
|
||||
# The project repo is bind-mounted from the host, so node_modules/
|
||||
# may contain native binaries for the wrong platform (e.g. darwin
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Go stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Go 1.22, gopls (official Go language server), and standard tooling.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/go/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-go -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Official Go binary distribution — Debian's golang-go package is too old for gopls.
|
||||
# Update GOVERSION to pick up a newer release.
|
||||
ENV GOVERSION="1.22.3"
|
||||
RUN curl -fsSL "https://go.dev/dl/go${GOVERSION}.linux-amd64.tar.gz" \
|
||||
| tar -C /usr/local -xzf -
|
||||
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
|
||||
# gopls: the official Go language server.
|
||||
# GOBIN=/usr/local/bin puts the binary on the system PATH for all users.
|
||||
RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the go stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
go.mod
|
||||
@@ -0,0 +1,50 @@
|
||||
# JVM stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with OpenJDK 21, Maven, and eclipse.jdt.ls (the canonical Java/JVM LSP).
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/jvm/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-jvm -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# OpenJDK 21 (current LTS) and Maven for build support.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-21-jdk-headless \
|
||||
maven \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64"
|
||||
|
||||
# Eclipse JDT Language Server — canonical LSP for Java/JVM (Java, Kotlin, Groovy).
|
||||
# Pin to a specific release; update JDTLS_VERSION + JDTLS_BUILD for upgrades.
|
||||
# All releases: https://github.com/eclipse-jdtls/eclipse.jdt.ls/releases
|
||||
ENV JDTLS_VERSION="1.38.0" \
|
||||
JDTLS_BUILD="202503271418"
|
||||
RUN mkdir -p /opt/jdtls \
|
||||
&& curl -fsSL \
|
||||
"https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz" \
|
||||
| tar -xzf - -C /opt/jdtls
|
||||
|
||||
# Wrapper script so `jdtls` is available as a PATH command.
|
||||
RUN { \
|
||||
echo '#!/bin/sh'; \
|
||||
echo 'JAR=$(ls /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar 2>/dev/null | head -1)'; \
|
||||
echo 'exec java \'; \
|
||||
echo ' -Declipse.application=org.eclipse.jdt.ls.core.id1 \'; \
|
||||
echo ' -Dosgi.bundles.defaultStartLevel=4 \'; \
|
||||
echo ' -Declipse.product=org.eclipse.jdt.ls.core.product \'; \
|
||||
echo ' -Dlog.protocol=true \'; \
|
||||
echo ' -Dlog.level=ALL \'; \
|
||||
echo ' -jar "$JAR" \'; \
|
||||
echo ' -configuration /opt/jdtls/config_linux \'; \
|
||||
echo ' "$@"'; \
|
||||
} > /usr/local/bin/jdtls \
|
||||
&& chmod +x /usr/local/bin/jdtls
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,6 @@
|
||||
# Stack detection markers for the jvm stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
pom.xml
|
||||
build.gradle
|
||||
build.gradle.kts
|
||||
@@ -0,0 +1,26 @@
|
||||
# Node stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Node.js 22, TypeScript (tsc), and typescript-language-server.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/node/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-node -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Node.js 22.x (LTS).
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# TypeScript compiler and language server for LSP-aware agents.
|
||||
# tsc: TypeScript compiler (tsc --version)
|
||||
# typescript-language-server: LSP server used by editors/agents
|
||||
RUN npm install -g typescript typescript-language-server
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,7 @@
|
||||
# Stack detection markers for the node stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
# tsconfig.json is listed explicitly so TypeScript-only projects are detected
|
||||
# even without a package.json at the repo root.
|
||||
package.json
|
||||
tsconfig.json
|
||||
@@ -0,0 +1,27 @@
|
||||
# Python stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Python 3, pip, and pyright (the Microsoft Python LSP / type checker).
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/python/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-python -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Python 3 runtime and pip.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pyright: Microsoft's Python language server / static type checker.
|
||||
# --break-system-packages is required on Debian 12+ where pip is externally
|
||||
# managed; the flag is safe inside a Docker container.
|
||||
RUN pip install --no-cache-dir --break-system-packages pyright
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,6 @@
|
||||
# Stack detection markers for the python stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
pyproject.toml
|
||||
requirements.txt
|
||||
setup.py
|
||||
@@ -0,0 +1,28 @@
|
||||
# Ruby stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Ruby, Bundler, and ruby-lsp (the Shopify Ruby language server).
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/ruby/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-ruby -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Ruby runtime, development headers (needed by native gem extensions), and Bundler.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ruby \
|
||||
ruby-dev \
|
||||
bundler \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ruby-lsp: Shopify's Ruby language server (LSP-compliant, actively maintained).
|
||||
# Installed globally so the `ruby-lsp` binary is available on PATH.
|
||||
RUN gem install ruby-lsp
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the ruby stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
Gemfile
|
||||
@@ -0,0 +1,37 @@
|
||||
# Rust stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with a full Rust toolchain, rust-analyzer, and cargo-nextest.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-rust -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Build tools required by rustup and many Rust crates.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV RUSTUP_HOME="/home/huskies/.rustup" \
|
||||
CARGO_HOME="/home/huskies/.cargo"
|
||||
|
||||
# Install stable Rust + rust-analyzer component as the huskies user.
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| su huskies -c "sh -s -- -y --no-modify-path --default-toolchain stable" \
|
||||
&& /home/huskies/.cargo/bin/rustup component add rust-analyzer \
|
||||
&& chown -R huskies:huskies /home/huskies/.rustup /home/huskies/.cargo
|
||||
|
||||
# cargo-nextest: fast Rust test runner used by huskies quality gates.
|
||||
RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin
|
||||
|
||||
ENV PATH="/home/huskies/.cargo/bin:${PATH}"
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the rust stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
Cargo.toml
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "huskies",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.11.1"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ mod help;
|
||||
pub(crate) mod loc;
|
||||
mod logs;
|
||||
mod move_story;
|
||||
mod new_project;
|
||||
mod overview;
|
||||
mod run_tests;
|
||||
mod setup;
|
||||
@@ -262,6 +263,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
|
||||
handler: handle_cleanup_worktrees_fallback,
|
||||
},
|
||||
BotCommand {
|
||||
name: "new",
|
||||
description: "Bootstrap a new project container (gateway only): `new project <name>`",
|
||||
handler: new_project::handle_new_project_fallback,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! `new project` command stub.
|
||||
//!
|
||||
//! The command is handled asynchronously in the Matrix transport's
|
||||
//! `on_room_message` handler (gateway mode only). This file exists so that
|
||||
//! `help` lists the command and the gateway proxy block does not forward it
|
||||
//! to the active project sled.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Fallback handler for the `new` command when it is not intercepted by the
|
||||
/// async gateway handler in `on_room_message`. In practice this is never
|
||||
/// called — `new project` is detected and handled before `try_handle_command`
|
||||
/// runs in gateway mode, and in standalone mode there is no matching project
|
||||
/// bootstrap context.
|
||||
///
|
||||
/// Returns `None` to prevent the LLM from receiving the raw command text.
|
||||
pub fn handle_new_project_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::service::gateway::config::ProjectEntry;
|
||||
use crate::service::timer::TimerStore;
|
||||
use crate::services::Services;
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||
@@ -94,6 +95,11 @@ pub struct BotContext {
|
||||
/// Used to proxy bot commands to the active project over WebSocket (`/ws`).
|
||||
/// Empty in standalone mode.
|
||||
pub gateway_project_urls: BTreeMap<String, String>,
|
||||
/// In gateway mode: shared live projects map from [`GatewayState`].
|
||||
///
|
||||
/// The `new project` command writes here so HTTP handlers see the new entry
|
||||
/// immediately without requiring a gateway restart. `None` in standalone mode.
|
||||
pub gateway_projects_store: Option<Arc<RwLock<BTreeMap<String, ProjectEntry>>>>,
|
||||
/// Pipeline transition events buffered since the last LLM turn.
|
||||
///
|
||||
/// A background task appends one compact audit line per real stage
|
||||
@@ -300,6 +306,7 @@ mod tests {
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
gateway_projects_store: None,
|
||||
pending_pipeline_events: Arc::new(TokioMutex::new(Vec::new())),
|
||||
pending_gateway_events: Arc::new(TokioMutex::new(Vec::new())),
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
|
||||
|
||||
@@ -193,7 +193,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
if ctx.is_gateway() {
|
||||
// Commands that are meaningful on the gateway itself (no project state needed).
|
||||
const GATEWAY_LOCAL_COMMANDS: &[&str] =
|
||||
&["help", "ambient", "reset", "switch", "all_status"];
|
||||
&["help", "ambient", "reset", "switch", "all_status", "new"];
|
||||
|
||||
let stripped = crate::chat::util::strip_bot_mention(
|
||||
&user_message,
|
||||
@@ -260,6 +260,46 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
// Gateway-local commands and freeform text fall through to normal handling below.
|
||||
}
|
||||
|
||||
// In gateway mode, handle the "new project <name> [--stack <stack>]" command
|
||||
// to bootstrap a project container and register it with the gateway.
|
||||
if ctx.is_gateway()
|
||||
&& let Some(cmd) = super::super::super::new_project::extract_new_project_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
{
|
||||
slog!(
|
||||
"[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?}",
|
||||
cmd.name,
|
||||
cmd.stack,
|
||||
cmd.git_url,
|
||||
);
|
||||
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||
super::super::super::new_project::handle_new_project(
|
||||
&cmd.name,
|
||||
cmd.stack.as_deref(),
|
||||
cmd.git_url.as_deref(),
|
||||
cmd.git_token.as_deref(),
|
||||
store,
|
||||
&ctx.services.project_root,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
"Gateway projects store unavailable — cannot create project.".to_string()
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||
// needed here.
|
||||
|
||||
@@ -30,6 +30,13 @@ pub async fn run_bot(
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||
gateway_projects_store: Option<
|
||||
Arc<
|
||||
RwLock<
|
||||
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
timer_store: Arc<TimerStore>,
|
||||
gateway_event_rx: Option<
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
@@ -399,6 +406,7 @@ pub async fn run_bot(
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
gateway_projects_store,
|
||||
pending_pipeline_events,
|
||||
pending_gateway_events,
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
|
||||
|
||||
@@ -202,4 +202,20 @@ pub struct BotConfig {
|
||||
/// Defaults to 1 500 ms (1.5 s).
|
||||
#[serde(default = "default_coalesce_window_ms")]
|
||||
pub coalesce_window_ms: u64,
|
||||
|
||||
/// Git `user.name` to inject into project containers created by `new project`.
|
||||
///
|
||||
/// Passed as `GIT_USER_NAME` to the container entrypoint so agents can commit
|
||||
/// code with the correct author identity. Falls back to the host's
|
||||
/// `git config user.name` when absent.
|
||||
#[serde(default)]
|
||||
pub git_user_name: Option<String>,
|
||||
|
||||
/// Git `user.email` to inject into project containers created by `new project`.
|
||||
///
|
||||
/// Passed as `GIT_USER_EMAIL` to the container entrypoint so agents can commit
|
||||
/// code with the correct author identity. Falls back to the host's
|
||||
/// `git config user.email` when absent.
|
||||
#[serde(default)]
|
||||
pub git_user_email: Option<String>,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(crate) mod config;
|
||||
pub mod delete;
|
||||
/// htop-style agent monitor command — renders a live process table in Matrix.
|
||||
pub mod htop;
|
||||
/// `new project <name>` chat command — Phase 1 gateway project bootstrap.
|
||||
pub mod new_project;
|
||||
/// Rebuild command — triggers a server rebuild/restart via a bot command.
|
||||
pub mod rebuild;
|
||||
/// Reset command — handles `!reset` bot commands to restart the server state.
|
||||
@@ -81,6 +83,13 @@ pub fn spawn_bot(
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: std::collections::BTreeMap<String, String>,
|
||||
gateway_projects_store: Option<
|
||||
Arc<
|
||||
RwLock<
|
||||
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
timer_store: Arc<TimerStore>,
|
||||
gateway_event_rx: Option<
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
@@ -122,6 +131,7 @@ pub fn spawn_bot(
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
gateway_projects_store,
|
||||
timer_store,
|
||||
gateway_event_rx,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,49 +122,57 @@ pub(super) fn get_crdt() -> Option<&'static Mutex<CrdtState>> {
|
||||
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
|
||||
/// channel whose receiver is leaked (so nothing is persisted, but the channel
|
||||
/// stays open and `apply_and_persist` succeeds silently).
|
||||
/// Safe to call multiple times — subsequent calls are no-ops (thread-local).
|
||||
/// Always resets all thread-local state so each call produces a clean slate —
|
||||
/// no cross-test pollution when two tests share the same thread.
|
||||
#[cfg(test)]
|
||||
pub fn init_for_test() {
|
||||
// Initialise thread-local CRDT for test isolation.
|
||||
// Only creates a new CRDT if one isn't set yet on this thread;
|
||||
// subsequent calls are no-ops (matching the old OnceLock semantics
|
||||
// while keeping each thread isolated).
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||
let (persist_tx, rx) = mpsc::unbounded_channel();
|
||||
// Leak the receiver so the channel stays open: apply_and_persist
|
||||
// can then send without error, preventing [crdt_persist] WARNs
|
||||
// from racing with other tests that watch the global log buffer.
|
||||
std::mem::forget(rx);
|
||||
let fresh = CrdtState {
|
||||
crdt,
|
||||
keypair,
|
||||
index: HashMap::new(),
|
||||
node_index: HashMap::new(),
|
||||
token_index: HashMap::new(),
|
||||
merge_job_index: HashMap::new(),
|
||||
active_agent_index: HashMap::new(),
|
||||
test_job_index: HashMap::new(),
|
||||
agent_throttle_index: HashMap::new(),
|
||||
gateway_project_index: HashMap::new(),
|
||||
persist_tx,
|
||||
lamport_floor: 0,
|
||||
tombstones: HashSet::new(),
|
||||
};
|
||||
CRDT_STATE_TL.with(|lock| {
|
||||
if lock.get().is_none() {
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||
let (persist_tx, rx) = mpsc::unbounded_channel();
|
||||
// Leak the receiver so the channel stays open: apply_and_persist
|
||||
// can then send without error, preventing [crdt_persist] WARNs
|
||||
// from racing with other tests that watch the global log buffer.
|
||||
std::mem::forget(rx);
|
||||
let state = CrdtState {
|
||||
crdt,
|
||||
keypair,
|
||||
index: HashMap::new(),
|
||||
node_index: HashMap::new(),
|
||||
token_index: HashMap::new(),
|
||||
merge_job_index: HashMap::new(),
|
||||
active_agent_index: HashMap::new(),
|
||||
test_job_index: HashMap::new(),
|
||||
agent_throttle_index: HashMap::new(),
|
||||
gateway_project_index: HashMap::new(),
|
||||
persist_tx,
|
||||
lamport_floor: 0,
|
||||
tombstones: HashSet::new(),
|
||||
};
|
||||
let _ = lock.set(Mutex::new(state));
|
||||
if let Some(mutex) = lock.get() {
|
||||
// Already set on this thread — replace contents so the second
|
||||
// (and subsequent) test on the same thread starts clean.
|
||||
*mutex.lock().unwrap() = fresh;
|
||||
} else {
|
||||
let _ = lock.set(Mutex::new(fresh));
|
||||
}
|
||||
});
|
||||
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
|
||||
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
|
||||
// Per-thread op journal + vector clock — keeps parallel tests' writes
|
||||
// from corrupting each other's view of ALL_OPS (notably, one thread's
|
||||
// `apply_compaction` could otherwise prune another thread's ops).
|
||||
// Per-thread op journal + vector clock — always cleared so a second test
|
||||
// on the same thread cannot see ops written by the first.
|
||||
statics::ALL_OPS_TL.with(|lock| {
|
||||
let _ = lock.set(Mutex::new(Vec::new()));
|
||||
if let Some(mutex) = lock.get() {
|
||||
mutex.lock().unwrap().clear();
|
||||
} else {
|
||||
let _ = lock.set(Mutex::new(Vec::new()));
|
||||
}
|
||||
});
|
||||
statics::VECTOR_CLOCK_TL.with(|lock| {
|
||||
let _ = lock.set(Mutex::new(VectorClock::new()));
|
||||
if let Some(mutex) = lock.get() {
|
||||
mutex.lock().unwrap().clear();
|
||||
} else {
|
||||
let _ = lock.set(Mutex::new(VectorClock::new()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@ pub fn delete_content(key: ContentKey<'_>) {
|
||||
|
||||
/// Ensure the in-memory content store is initialised.
|
||||
///
|
||||
/// Safe to call multiple times — the `OnceLock` is set at most once.
|
||||
/// In non-test builds: init-once via `OnceLock` (safe to call multiple times).
|
||||
/// In test builds: always resets `CONTENT_STORE_TL` to an empty `HashMap` so
|
||||
/// each test on the same thread starts with a clean store.
|
||||
pub fn ensure_content_store() {
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
@@ -175,7 +177,11 @@ pub fn ensure_content_store() {
|
||||
#[cfg(test)]
|
||||
{
|
||||
CONTENT_STORE_TL.with(|lock| {
|
||||
if lock.get().is_none() {
|
||||
if let Some(mutex) = lock.get() {
|
||||
// Already initialised on this thread — reset to empty so the
|
||||
// next test does not see content written by a previous test.
|
||||
mutex.lock().unwrap().clear();
|
||||
} else {
|
||||
let _ = lock.set(Mutex::new(HashMap::new()));
|
||||
}
|
||||
});
|
||||
@@ -203,6 +209,41 @@ pub(super) fn init_content_store(map: HashMap<String, String>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression: two sequential `ensure_content_store()` + write + read cycles
|
||||
/// in the same test body must not see each other's content. Before the fix,
|
||||
/// `ensure_content_store()` was a no-op on the second call (OnceLock gating),
|
||||
/// so the second cycle could read items written in the first cycle.
|
||||
#[test]
|
||||
fn sequential_ensure_content_store_resets_state() {
|
||||
// ── Cycle 1 ──────────────────────────────────────────────────────────
|
||||
ensure_content_store();
|
||||
write_content(ContentKey::Story("1111_cycle1"), "cycle-one body");
|
||||
assert_eq!(
|
||||
read_content(ContentKey::Story("1111_cycle1")).as_deref(),
|
||||
Some("cycle-one body"),
|
||||
"cycle 1: item must be readable after write"
|
||||
);
|
||||
|
||||
// ── Cycle 2: reset, write a different item ────────────────────────────
|
||||
ensure_content_store();
|
||||
// Cycle-1 item must no longer be visible.
|
||||
assert!(
|
||||
read_content(ContentKey::Story("1111_cycle1")).is_none(),
|
||||
"cycle 2: store must be empty; cycle-1 content must not bleed through"
|
||||
);
|
||||
write_content(ContentKey::Story("1111_cycle2"), "cycle-two body");
|
||||
assert_eq!(
|
||||
read_content(ContentKey::Story("1111_cycle2")).as_deref(),
|
||||
Some("cycle-two body"),
|
||||
"cycle 2: own item must be readable"
|
||||
);
|
||||
// And cycle-1 key must still be absent.
|
||||
assert!(
|
||||
read_content(ContentKey::Story("1111_cycle1")).is_none(),
|
||||
"cycle 2: cycle-1 content must remain absent after cycle-2 write"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
|
||||
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
|
||||
/// runtime substring matching, enforces the separation.
|
||||
|
||||
@@ -72,6 +72,12 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta:
|
||||
.and_then(|d| serde_json::to_string(d).ok());
|
||||
|
||||
// Update in-memory content store.
|
||||
// In test builds, the caller (test setup) is responsible for calling
|
||||
// ensure_content_store() once before writing — calling it here would
|
||||
// reset the store on every write, losing items from prior writes in the
|
||||
// same test. In production, the lazy-init call is safe because nothing
|
||||
// resets the store between writes.
|
||||
#[cfg(not(test))]
|
||||
ensure_content_store();
|
||||
write_content(ContentKey::Story(story_id), content);
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
Arc::clone(&state_arc.active_project),
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
Arc::clone(&state_arc.projects),
|
||||
port,
|
||||
Some(state_arc.event_tx.clone()),
|
||||
Arc::clone(&state_arc.perm_rx),
|
||||
|
||||
@@ -1175,6 +1175,7 @@ async fn ws_only_sled_handles_tools_list_and_tools_call() {
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
@@ -1244,6 +1245,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("alpha-tok".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
projects.insert(
|
||||
@@ -1251,6 +1253,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() {
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("beta-tok".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
|
||||
@@ -86,6 +86,7 @@ mod tests {
|
||||
use crate::http::test_helpers::test_ctx;
|
||||
|
||||
fn setup_git_repo_in(dir: &std::path::Path) {
|
||||
crate::db::ensure_content_store();
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
|
||||
@@ -115,6 +115,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_refactor_accepts_single_criterion() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_refactor(
|
||||
@@ -146,6 +147,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_refactor(
|
||||
|
||||
@@ -118,6 +118,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_spike_creates_file() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
@@ -147,6 +148,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_spike_creates_file_without_description() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
@@ -202,6 +204,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_spike_accepts_single_criterion() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_spike(
|
||||
@@ -233,6 +236,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_spike(
|
||||
|
||||
@@ -256,6 +256,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_accepts_single_criterion() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_story(
|
||||
@@ -283,6 +284,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_story(
|
||||
@@ -299,6 +301,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_description_is_written_to_file() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
@@ -368,6 +371,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_html_sanitised_in_name() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// HTML in name is sanitised (not rejected)
|
||||
|
||||
@@ -124,6 +124,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_and_list_upcoming() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// No git repo needed: spike 61 — create_story just writes the file;
|
||||
// the filesystem watcher handles the commit asynchronously.
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::spike::create_spike_file;
|
||||
use std::fs;
|
||||
|
||||
fn setup_git_repo(root: &std::path::Path) {
|
||||
crate::db::ensure_content_store();
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
@@ -166,6 +167,7 @@ fn extract_bug_name_from_content_parses_heading() {
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_writes_correct_content() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
@@ -257,6 +259,7 @@ fn create_bug_file_rejects_empty_acceptance_criteria() {
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_writes_correct_content() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let spike_id = create_spike_file(
|
||||
@@ -294,6 +297,7 @@ fn create_spike_file_writes_correct_content() {
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_uses_description_when_provided() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let description = "What is the best approach for watching filesystem events?";
|
||||
|
||||
@@ -319,6 +323,7 @@ fn create_spike_file_uses_description_when_provided() {
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_uses_placeholder_when_no_description() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let spike_id = create_spike_file(
|
||||
tmp.path(),
|
||||
@@ -350,6 +355,7 @@ fn create_spike_file_rejects_empty_name() {
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = "Spike: compare \"fast\" vs slow encoders";
|
||||
let result = create_spike_file(
|
||||
@@ -423,6 +429,7 @@ fn create_bug_file_with_depends_on_persists_to_crdt() {
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_without_depends_on_omits_field() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
@@ -474,6 +481,7 @@ fn create_refactor_file_with_depends_on_persists_to_crdt() {
|
||||
|
||||
#[test]
|
||||
fn create_refactor_file_without_depends_on_omits_field() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
None,
|
||||
vec![],
|
||||
std::collections::BTreeMap::new(),
|
||||
None,
|
||||
timer_store_for_bot,
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,13 @@ pub struct ProjectEntry {
|
||||
/// `[sled_tokens]` table for projects that set this field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
/// Host-local port for SSH access into the project container.
|
||||
///
|
||||
/// Set by `new project` (story 1108). The container's SSH server is bound
|
||||
/// to `127.0.0.1:<ssh_port>:22` so the user can connect with
|
||||
/// `ssh huskies@127.0.0.1 -p <ssh_port> -i ~/.huskies/<name>/id_ed25519`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl ProjectEntry {
|
||||
@@ -36,6 +43,7 @@ impl ProjectEntry {
|
||||
Self {
|
||||
url: Some(url.into()),
|
||||
auth_token: None,
|
||||
ssh_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +213,7 @@ auth_token = "secret"
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
@@ -238,6 +247,7 @@ auth_token = "secret"
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("tok".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
|
||||
@@ -256,6 +266,7 @@ auth_token = "secret"
|
||||
let e = ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("tok".into()),
|
||||
ssh_port: None,
|
||||
};
|
||||
assert!(!e.has_url());
|
||||
}
|
||||
@@ -297,6 +308,7 @@ auth_token = "secret"
|
||||
let entry = ProjectEntry {
|
||||
url: Some("http://a:3001".into()),
|
||||
auth_token: Some("mysecret".into()),
|
||||
ssh_port: None,
|
||||
};
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("myproj".into(), entry);
|
||||
@@ -315,4 +327,43 @@ auth_token = "secret"
|
||||
Some("mysecret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_project_entry_with_ssh_port() {
|
||||
let entry = ProjectEntry {
|
||||
url: Some("http://127.0.0.1:3101".into()),
|
||||
auth_token: None,
|
||||
ssh_port: Some(2201),
|
||||
};
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("myproj".into(), entry);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
};
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201));
|
||||
// ssh_port must appear in the serialised TOML.
|
||||
assert!(
|
||||
toml_str.contains("ssh_port = 2201"),
|
||||
"ssh_port missing from TOML: {toml_str}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_port_none_is_omitted_from_toml() {
|
||||
let entry = ProjectEntry::with_url("http://127.0.0.1:3101");
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("p".into(), entry);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
};
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
assert!(
|
||||
!toml_str.contains("ssh_port"),
|
||||
"ssh_port should be omitted when None: {toml_str}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,11 +500,13 @@ pub type ActiveProject = std::sync::Arc<tokio::sync::RwLock<String>>;
|
||||
/// Returns `(abort_handle, shutdown_tx)`. The caller **must** hold
|
||||
/// `shutdown_tx` for the bot's lifetime and send `Some(ShutdownReason)` on it
|
||||
/// before process exit so the bot can announce "going offline" to its rooms.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_gateway_bot(
|
||||
config_dir: &Path,
|
||||
active_project: ActiveProject,
|
||||
gateway_projects: Vec<String>,
|
||||
gateway_project_urls: BTreeMap<String, String>,
|
||||
gateway_projects_store: std::sync::Arc<tokio::sync::RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
port: u16,
|
||||
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
|
||||
perm_rx: std::sync::Arc<
|
||||
@@ -578,6 +580,7 @@ pub fn spawn_gateway_bot(
|
||||
Some(active_project),
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
Some(gateway_projects_store),
|
||||
timer_store,
|
||||
gateway_event_rx,
|
||||
);
|
||||
@@ -602,11 +605,14 @@ mod tests {
|
||||
let (_perm_tx, perm_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<crate::http::context::PermissionForward>();
|
||||
let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx));
|
||||
let projects_store =
|
||||
std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
|
||||
let (handle, shutdown_tx) = spawn_gateway_bot(
|
||||
tmp.path(),
|
||||
active,
|
||||
vec!["proj".to_string()],
|
||||
std::collections::BTreeMap::new(),
|
||||
projects_store,
|
||||
3001,
|
||||
Some(event_tx),
|
||||
perm_rx,
|
||||
|
||||
@@ -661,6 +661,7 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) ->
|
||||
Arc::clone(&state.active_project),
|
||||
gateway_projects,
|
||||
gateway_project_urls,
|
||||
Arc::clone(&state.projects),
|
||||
state.port,
|
||||
Some(state.event_tx.clone()),
|
||||
Arc::clone(&state.perm_rx),
|
||||
@@ -746,6 +747,7 @@ mod tests {
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("tok".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
@@ -884,6 +886,7 @@ mod tests {
|
||||
ProjectEntry {
|
||||
url: Some("http://huskies:3001".into()),
|
||||
auth_token: Some("secret-token".into()),
|
||||
ssh_port: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
|
||||
Reference in New Issue
Block a user