Compare commits

..

9 Commits

Author SHA1 Message Date
Jarvis
7210b7391a fix(ci): gitignore vite/vitest *.timestamp-*.mjs to stop turbo traversal race
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The push/ci lint step intermittently failed with:

  x Package traversal error: .../packages/macp/vitest.config.ts.timestamp-
    <n>.mjs: IO error ... No such file or directory (os error 2)

vite/vitest/esbuild write a transient *.timestamp-*.mjs next to a TS
config while loading it, then unlink it. The files were untracked but not
ignored, so turbo's package traversal hashed them and raced the unlink.
Ignoring them excludes them from turbo's input set and removes the race.

Same class of fix as the pglite timeout/OOM change in this PR: transient
test tooling artifacts destabilising CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:37:33 -05:00
Jarvis
80570f7040 fix(db): stop pglite migration tests flaking CI on timeout + WASM OOM
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline was successful
packages/db's migrate.test.ts spins up a real PGlite (WASM Postgres)
instance per test and applies the full drizzle migration set. Each case
takes ~3-5s locally and longer on CI, where turbo runs ~20 packages'
suites concurrently. Two failure modes resulted, bouncing between the
push/ci and pr/ci pipelines on identical SHAs:

  FAIL src/migrate.test.ts > runPgliteMigrations > ...
    Error: Test timed out in 5000ms.
    -> memory access out of bounds  (wasm:/wasm/...)

1. The 5s vitest default timeout expires mid-migration -> phantom
   'Test timed out in 5000ms'. Raise testTimeout/hookTimeout to 120s so
   legitimately-slow migrations finish.
2. Each PGlite WASM heap is multi-hundred-MB (RSS ~705MB for this file
   alone); parallel forks multiply the peak and tip the runner into the
   WASM OOM. Pin the package to a single fork so only one instance is
   resident at a time.

Also register packages/db/vitest.config.ts in eslint's allowDefaultProject
(alongside the gateway/storage vitest configs) so the typed lint can parse
the now-non-trivial config.

Verified: full db suite green 3x locally with the new config; each run
~13s, no timeouts, no OOM. eslint clean on both files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:29:41 -05:00
e6b53ea103 fix(tools): default AGENT_WORK_ROOT to $HOME/mosaic/agent-work (#641)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
2026-06-23 13:40:13 +00:00
4da87640e8 feat(tmux): agent-send.sh --class triage tag for the comms daemon (#552)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 03:25:16 +00:00
a38a491403 chore(release): mosaic CLI 0.0.41 (#640)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 02:21:04 +00:00
78d67c6261 chore(ci): bump ci-base image node 22 → 24-alpine (#639)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-23 00:59:39 +00:00
94e5cd7a81 ci: eliminate cold pnpm install via pre-baked CI base image (Phase 1) (#635)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-22 22:50:21 +00:00
4e84f8e850 feat(fleet): comms-block emitter + FLEET-LAUNCH runbook (#633) (#638)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 22:23:50 +00:00
cf8ceb3095 CI: add pre-baked ci-base image (producer) [Phase 1a] (#637)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-22 22:20:48 +00:00
14 changed files with 302 additions and 27 deletions

7
.gitignore vendored
View File

@@ -15,3 +15,10 @@ infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
# loading it, then unlink. They are untracked but were not ignored, so turbo's
# package traversal hashed them and intermittently failed CI with "Package
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs

4
.npmrc
View File

@@ -1 +1,5 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store

40
.woodpecker/ci-image.yml Normal file
View File

@@ -0,0 +1,40 @@
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
# for their install step.
#
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
# code push must not trigger a 25-min image build. `path` applies to push/PR
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
# always ships a fresh base.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'pnpm-lock.yaml'
- 'Dockerfile.ci'
steps:
build-ci-base:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
# Lockfile-hash tag: an immutable identity for the exact dep set baked
# into this image. `:latest` is the mutable pointer pipelines consume.
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS

View File

@@ -1,5 +1,9 @@
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
variables:
- &node_image 'node:22-alpine'
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
when:
@@ -15,8 +19,9 @@ steps:
image: *node_image
commands:
- corepack enable
- apk add --no-cache python3 make g++
- pnpm install --frozen-lockfile
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
@@ -64,8 +69,7 @@ steps:
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# Install postgresql-client for pg_isready
- apk add --no-cache postgresql-client
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
ready=0

View File

@@ -2,7 +2,9 @@
# Runs only on main branch push/tag
variables:
- &node_image 'node:22-alpine'
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
@@ -31,7 +33,8 @@ steps:
image: *node_image
commands:
- corepack enable
- pnpm install --frozen-lockfile
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
build:
image: *node_image

45
Dockerfile.ci Normal file
View File

@@ -0,0 +1,45 @@
# Pre-baked CI base image for Woodpecker pipelines.
#
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
# already fetched at build time. `pnpm fetch` only populates the store from the
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
# which is exactly why the musl toolchain stays baked into this image. A
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
# tarballs from local hard-links (no network) and compiles natives against the
# already-present toolchain, in tens of seconds instead of ~731s.
#
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
# (see .woodpecker/ci-image.yml).
#
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
# carries zero cache variables. node:26 stays held until it reaches LTS
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
WORKDIR /app
# Pin the store location so the pipeline can point `store-dir` at the same path.
ENV PNPM_HOME=/root/.local/share/pnpm
RUN pnpm config set store-dir /root/.local/share/pnpm/store
# Warm the store. `pnpm fetch` populates the content-addressable store with the
# dependency tarballs directly from the lockfile (no package.json / workspace
# needed), so a baked store stays valid until the lockfile changes. Note:
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
# in the pipeline, against the toolchain baked above.
COPY pnpm-lock.yaml ./
RUN pnpm fetch --frozen-lockfile

View File

@@ -28,6 +28,7 @@ export default tseslint.config(
'apps/web/e2e/helpers/*.ts',
'apps/web/playwright.config.ts',
'apps/gateway/vitest.config.ts',
'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts',
'packages/mosaic/__tests__/*.ts',
'tools/federation-harness/*.ts',

View File

@@ -4,5 +4,22 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
// The migration suite spins up a real PGlite (WASM Postgres) instance per
// test and applies the full drizzle migration set. Each case legitimately
// takes ~5s locally and considerably longer on CI, where turbo runs many
// packages' test suites concurrently. The 5s vitest default then expires
// mid-migration and the run fails as a phantom "Test timed out in 5000ms"
// (often surfacing the underlying WASM `memory access out of bounds` when
// the heap is starved). Give migrations real headroom.
testTimeout: 120_000,
hookTimeout: 120_000,
// Each PGlite instance carries a multi-hundred-MB WASM heap. Running test
// files in parallel forks multiplies that peak and is what tips the CI
// runner into the WASM OOM. A single fork keeps only one instance resident
// at a time — slightly slower, but deterministic.
pool: 'forks',
poolOptions: {
forks: { singleFork: true },
},
},
});

View File

@@ -128,8 +128,8 @@ PY
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-api-response.XXXXXX")
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload='{"Do":"squash"}'
token=$(get_gitea_token "$host" || true)
@@ -214,8 +214,8 @@ case "$PLATFORM" in
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-tea-error.XXXXXX")
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then

View File

@@ -4,7 +4,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_ROOT="${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
MOCK_BIN="$SANDBOX/bin"
REPO_DIR="$SANDBOX/repo"

View File

@@ -2,12 +2,20 @@
when:
- event: [push, pull_request, manual]
# Dependencies are installed ONCE in the `install` step and every downstream
# step depends on it, reusing the populated node_modules from the shared
# workspace volume. Do NOT re-run `npm ci` per step — that pays the full cold
# install (network fetch + native rebuilds) N times and is the dominant cost
# in a pipeline.
#
# For best results, replace `&node_image` with a pre-baked CI base image that
# ships your toolchain (python3/make/g++ for native modules) and a warm npm
# cache, then keep `--prefer-offline` so installs resolve from the cache. See
# the Mosaic Stack repo's Dockerfile.ci + .woodpecker/ci-image.yml for the
# baked-image pattern.
variables:
- &node_image 'node:20-alpine'
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
- &install_deps |
corepack enable
npm ci --ignore-scripts
steps:
# Secret scanning (runs in parallel with install, no deps)
@@ -17,15 +25,18 @@ steps:
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
depends_on: []
# Single cached install. Every other step depends on this and reuses the
# node_modules it produces in the shared workspace.
install:
image: *node_image
commands:
- *install_deps
- corepack enable
- npm ci --ignore-scripts --prefer-offline
depends_on: []
security-audit:
image: *node_image
commands:
- *install_deps
- npm audit --audit-level=high
depends_on:
- install
@@ -35,7 +46,6 @@ steps:
environment:
SKIP_ENV_VALIDATION: 'true'
commands:
- *install_deps
- npm run lint
depends_on:
- install
@@ -45,7 +55,6 @@ steps:
environment:
SKIP_ENV_VALIDATION: 'true'
commands:
- *install_deps
- npm run type-check
depends_on:
- install
@@ -55,7 +64,6 @@ steps:
environment:
SKIP_ENV_VALIDATION: 'true'
commands:
- *install_deps
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
depends_on:
- install
@@ -66,7 +74,6 @@ steps:
SKIP_ENV_VALIDATION: 'true'
NODE_ENV: 'production'
commands:
- *install_deps
- npm run build
depends_on:
- lint

View File

@@ -12,6 +12,10 @@
# ambiguity about lanes or origin. Recipients replying should FLIP the
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
#
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
# field instead of re-deriving intent from the body.
#
# WHY A WRAPPER
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
# a trailing Enter is often swallowed and the message sits as an unsubmitted
@@ -26,6 +30,7 @@
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
#
# OPTIONS
@@ -36,27 +41,61 @@
# Default: local hostname, or (remote) resolved via one ssh.
# -m MESSAGE message text (single- or multi-line)
# -f FILE read message from FILE instead of -m
# -C CLASS triage class for a comms daemon. One of:
# terminal-log log-only; never needs the agent's attention
# actionable carries a decision/blocker/gate — deliver
# human from a human operator — deliver
# reaction an emoji/ack reaction
# Long form: --class CLASS (or --class=CLASS). When SET, the
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
# [<src> -> <dst> class=terminal-log] <message>
# When OMITTED, NO token is emitted and the preamble is
# byte-for-byte identical to the classic format. Consumers MUST
# treat an absent class as 'actionable' (fail-safe: agent sees it).
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
# -r N Enter-flush attempts passed through (default 2)
# -v verbose: print pane tail after delivery
# -h help
#
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction))?\] (.*)$
# group 1 = src label group 2 = dst host:session
# group 3 = class (absent => actionable) group 4 = message body
#
# EXIT CODES (passed through from send-message.sh)
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
set -uo pipefail
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SENDER="$SELF_DIR/send-message.sh"
# Sender is overridable via env purely for testing (inject a capture stub). The
# default is the canonical send-message.sh beside this script; production callers
# never set AGENT_SEND_SENDER, so behavior is unchanged.
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
# Translate the long option --class[=value] into "-C value" so getopts (which is
# short-option-only) can parse it. Every other argument passes through untouched,
# so callers that never use --class hit the exact original getopts path.
args=()
while [ $# -gt 0 ]; do
case "$1" in
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
args+=(-C "$2"); shift 2 ;;
--class=*) args+=(-C "${1#*=}"); shift ;;
*) args+=("$1"); shift ;;
esac
done
set -- ${args[@]+"${args[@]}"}
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
SRC_LABEL=""; RETRIES=2; VERBOSE=0
usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; }
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
while getopts "L:s:H:n:m:f:S:r:vh" o; do
while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
case "$o" in
L) SOCKET_NAME=$OPTARG ;;
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
C) CLASS=$OPTARG ;;
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
esac
done
@@ -64,6 +103,17 @@ done
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
# Validate the triage class only when one was given. An absent class emits NO
# token (preamble byte-identical to the classic format); the consumer defaults
# absent => actionable.
CLASS_TOKEN=""
if [ -n "$CLASS" ]; then
case "$CLASS" in
terminal-log|actionable|human|reaction) CLASS_TOKEN=" class=${CLASS}" ;;
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction)" >&2; exit 3 ;;
esac
fi
# Message body from -f / -m / stdin.
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
@@ -90,7 +140,7 @@ if [ -z "$DST_HOST" ]; then
fi
fi
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}]"
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
FULL="${PREAMBLE} ${MSG}"
B64=$(printf '%s' "$FULL" | base64 -w0)

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
#
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
# base64 payload and prints the FULL message (preamble + body) so we can assert
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
# the preamble is deterministic across machines.
#
# Guarantees locked here:
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
# 3. --class=<c> (equals form) parses identically to the space form.
# 4. -C <c> short form parses identically.
# 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp)
trap 'rm -f "$STUB"' EXIT
cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash
set -uo pipefail
b64=""
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d
STUB_EOF
chmod +x "$STUB"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
# Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
got=$(run -s mos -m "hello world")
want='[a:src -> dsthost:mos] hello world'
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
# 2. --class space form emits the token.
got=$(run -s mos --class terminal-log -m "ACK")
want='[a:src -> dsthost:mos class=terminal-log] ACK'
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
# 3. --class=value equals form.
got=$(run -s mos --class=actionable -m "decide X")
want='[a:src -> dsthost:mos class=actionable] decide X'
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
# 4. -C short form.
got=$(run -s mos -C human -m "from a person")
want='[a:src -> dsthost:mos class=human] from a person'
[ "$got" = "$want" ] && ok "-C human (short form)" \
|| no "-C human (short form)" "got=[$got] want=[$want]"
# 5. invalid class => exit 3, no send.
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
else
rc=$?
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
fi
# 6. --class with no value => exit 3.
if run -s mos -m "x" --class 2>/dev/null; then
no "--class with no value rejected" "expected non-zero exit, got 0"
else
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
fi
# 7. consumer grammar parses every class + classic line.
for c in terminal-log actionable human reaction; do
line=$(run -s mos --class "$c" -m "body $c")
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
done
classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaicstack/mosaic",
"version": "0.0.40",
"version": "0.0.41",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",