Compare commits
6 Commits
feat/fleet
...
feat/p0-li
| Author | SHA1 | Date | |
|---|---|---|---|
| 010bd1181c | |||
| e834bbb83c | |||
| 7498fcb20d | |||
| 42d081613f | |||
| b5c1381e45 | |||
| 6dfd78f643 |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Mosaic Stack
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -17,6 +17,9 @@ Product-owned defaults:
|
|||||||
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
|
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
|
||||||
- `packages/mosaic/framework/tools/tmux/send-message.sh`
|
- `packages/mosaic/framework/tools/tmux/send-message.sh`
|
||||||
|
|
||||||
|
These files are published through `packages/mosaic/package.json`, whose `files`
|
||||||
|
allowlist includes `framework` along with `dist`.
|
||||||
|
|
||||||
Site-owned local roster:
|
Site-owned local roster:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -66,6 +69,14 @@ These commands read the roster and target the configured tmux socket. The
|
|||||||
generated systemd agent services use `start-agent-session.sh`; message delivery
|
generated systemd agent services use `start-agent-session.sh`; message delivery
|
||||||
uses the tmux send tools with `-L mosaic-factory`.
|
uses the tmux send tools with `-L mosaic-factory`.
|
||||||
|
|
||||||
|
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
|
||||||
|
otherwise. The CLI always passes a deterministic source label to
|
||||||
|
`agent-send.sh` with `-S`, defaulting to `<hostname>:operator`, so it does not
|
||||||
|
query the target tmux socket and accidentally identify as an active agent pane.
|
||||||
|
Use `--source-label <label>` or `--source <label>` only when deliberately
|
||||||
|
impersonating a known handoff lane. The lower-level inter-agent wrapper
|
||||||
|
`agent-send.sh -S <label>` remains the explicit source override for scripts.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Use these checks before expanding the roster:
|
Use these checks before expanding the roster:
|
||||||
@@ -83,6 +94,27 @@ Expected results:
|
|||||||
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
||||||
fleet start/stop operations.
|
fleet start/stop operations.
|
||||||
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
||||||
|
- `systemctl --user status ...` may show `active (exited)` for oneshot units;
|
||||||
|
that means the unit ran, not that an agent pane is live. Treat tmux
|
||||||
|
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
|
||||||
|
|
||||||
|
## Release Preflight
|
||||||
|
|
||||||
|
Run this checklist before cutting or dogfooding a fleet release:
|
||||||
|
|
||||||
|
- Real AI dogfood: send at least one task through `mosaic agent send`, then
|
||||||
|
confirm the agent accepted/responded using pane, process, or log evidence.
|
||||||
|
- Restart/stop/idempotency: run `mosaic fleet start`, `restart`, `stop`, and a
|
||||||
|
repeated `start` against the named socket; verify the default tmux server is
|
||||||
|
unchanged.
|
||||||
|
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
|
||||||
|
with `tmux -L mosaic-factory ls` or exact `has-session` checks.
|
||||||
|
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
|
||||||
|
confirm `framework/fleet`, `framework/systemd/user`,
|
||||||
|
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
|
||||||
|
- Mosaic update test: install or upgrade from the packed artifact in a temporary
|
||||||
|
Mosaic home and confirm `mosaic update` or the release upgrade path does not
|
||||||
|
remove local roster/config files.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
|
|||||||
35
docs/scratchpads/2026-06-20-fleet-release-hardening.md
Normal file
35
docs/scratchpads/2026-06-20-fleet-release-hardening.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Fleet release hardening
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Harden the Mosaic local fleet release path for operator sends, tmux/systemd verification, package contents, and dogfood release documentation.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not edit `docs/TASKS.md`.
|
||||||
|
- Do not change production deployment refs.
|
||||||
|
- Keep fleet transport generic and named-socket safe.
|
||||||
|
- Preserve strict roster validation.
|
||||||
|
- Add tests first or alongside fixes.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add regression tests for deterministic `mosaic agent send` source labels.
|
||||||
|
2. Strengthen fleet status/verify/package/install-systemd coverage.
|
||||||
|
3. Implement focused CLI/source-label changes.
|
||||||
|
4. Update local canary documentation with dogfood preflight.
|
||||||
|
5. Run formatting, targeted tests, typecheck, lint, and package dry-run evidence.
|
||||||
|
|
||||||
|
## Evidence Log
|
||||||
|
|
||||||
|
- Started from existing `docs/PRD.md`; durable local fleet canary is in v0.1.0 scope.
|
||||||
|
- Loaded `mosaic-fleet-operations` skill; key constraints are isolated tmux sockets, no default tmux positive tests, and `active (exited)` is not liveness.
|
||||||
|
- TDD red: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts` initially failed because `node_modules` was absent; after `pnpm install`, the new source-label tests failed on missing `-S`, missing helper, and unknown `--source-label`.
|
||||||
|
- Green implementation: `mosaic agent send` now passes `-S <hostname>:operator` by default and accepts `--source-label` / `--source` overrides.
|
||||||
|
- Test coverage added for tmux-based fleet verify liveness, package `files` allowlist containing `framework`, and explicit operator source-label command construction.
|
||||||
|
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/guides/fleet-local-canary.md docs/scratchpads/2026-06-20-fleet-release-hardening.md`.
|
||||||
|
- Targeted tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts` passed with 49 tests.
|
||||||
|
- Typecheck: `pnpm typecheck` passed.
|
||||||
|
- Lint: `pnpm lint` passed.
|
||||||
|
- Package dry-run: `npm pack --dry-run --json` from `packages/mosaic` included `framework/fleet`, `framework/systemd/user`, `framework/tools/fleet/start-agent-session.sh`, and `framework/tools/tmux/{agent-send.sh,send-message.sh}`.
|
||||||
|
- Review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` approved the supplied diff with no findings; the review tool noted its read-only sandbox could not inspect files directly.
|
||||||
@@ -23,5 +23,6 @@
|
|||||||
"turbo": "^2.0.0",
|
"turbo": "^2.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
21
packages/mosaic/framework/LICENSE
Normal file
21
packages/mosaic/framework/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Mosaic Stack
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
# Run `load_credentials --help` for details.
|
# Run `load_credentials --help` for details.
|
||||||
|
|
||||||
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||||
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
|
for _cand in "$HOME/.config/mosaic/credentials.json"; do
|
||||||
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||||
done
|
done
|
||||||
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
|
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_mosaic_require_jq() {
|
_mosaic_require_jq() {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ gitea_url_matches_host() {
|
|||||||
|
|
||||||
get_gitea_service_for_host() {
|
get_gitea_service_for_host() {
|
||||||
local host="$1"
|
local host="$1"
|
||||||
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
|
||||||
|
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.mosaicstack.dev)
|
git.mosaicstack.dev)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ source "$MOSAIC_HOME/tools/_lib/credentials.sh"
|
|||||||
FORMAT="table"
|
FORMAT="table"
|
||||||
SINGLE_SERVICE=""
|
SINGLE_SERVICE=""
|
||||||
QUIET=false
|
QUIET=false
|
||||||
CRED_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
CRED_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
|
||||||
|
|
||||||
while getopts "f:s:qh" opt; do
|
while getopts "f:s:qh" opt; do
|
||||||
case $opt in
|
case $opt in
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ FILE_PATH="${FILE_PATH/#\~/$HOME}"
|
|||||||
# Block writes to Claude Code auto-memory files
|
# Block writes to Claude Code auto-memory files
|
||||||
if [[ "$FILE_PATH" =~ /.claude/projects/.+/memory/.*\.md$ ]]; then
|
if [[ "$FILE_PATH" =~ /.claude/projects/.+/memory/.*\.md$ ]]; then
|
||||||
echo "BLOCKED: Do not write agent learnings to ~/.claude/projects/*/memory/ — this is a runtime-specific silo."
|
echo "BLOCKED: Do not write agent learnings to ~/.claude/projects/*/memory/ — this is a runtime-specific silo."
|
||||||
echo "Use OpenBrain instead: MCP 'capture' tool or REST POST https://brain.woltje.com/v1/thoughts"
|
if [[ -n "${OPENBRAIN_URL:-}" ]]; then
|
||||||
|
echo "Use OpenBrain instead: MCP 'capture' tool or REST POST ${OPENBRAIN_URL%/}/v1/thoughts"
|
||||||
|
else
|
||||||
|
echo "Use OpenBrain instead: the 'capture' MCP tool (set OPENBRAIN_URL for the REST endpoint)."
|
||||||
|
fi
|
||||||
echo "File blocked: $FILE_PATH"
|
echo "File blocked: $FILE_PATH"
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.31",
|
"version": "0.0.34",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
@@ -63,5 +63,6 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"framework"
|
"framework"
|
||||||
]
|
],
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
buildAgentSendCommand,
|
buildAgentSendCommand,
|
||||||
buildFleetServiceCommand,
|
buildFleetServiceCommand,
|
||||||
generateAgentEnv,
|
generateAgentEnv,
|
||||||
|
getDefaultOperatorSourceLabel,
|
||||||
getRosterAgent,
|
getRosterAgent,
|
||||||
loadFleetRoster,
|
loadFleetRoster,
|
||||||
|
mergeAgentEnv,
|
||||||
registerFleetCommand,
|
registerFleetCommand,
|
||||||
resolveFleetPaths,
|
resolveFleetPaths,
|
||||||
type CommandRunner,
|
type CommandRunner,
|
||||||
@@ -120,6 +122,37 @@ describe('fleet roster parsing', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
|
||||||
|
const generated = [
|
||||||
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
|
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||||
|
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const existing = [
|
||||||
|
'MOSAIC_AGENT_NAME=old-name',
|
||||||
|
'MOSAIC_AGENT_RUNTIME=old-runtime',
|
||||||
|
'MOSAIC_AGENT_WORKDIR=/srv/old',
|
||||||
|
'MOSAIC_TMUX_SOCKET=old-socket',
|
||||||
|
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||||
|
'# site note',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(mergeAgentEnv(generated, existing)).toBe(
|
||||||
|
[
|
||||||
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
|
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||||
|
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||||||
|
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||||
|
'# site note',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||||||
cleanup = await tempDir();
|
cleanup = await tempDir();
|
||||||
const rosterPath = join(cleanup, 'roster.yaml');
|
const rosterPath = join(cleanup, 'roster.yaml');
|
||||||
@@ -229,10 +262,14 @@ describe('fleet command construction', () => {
|
|||||||
|
|
||||||
it('builds socket-scoped agent send commands', () => {
|
it('builds socket-scoped agent send commands', () => {
|
||||||
const paths = resolveFleetPaths('/home/test/.config/mosaic');
|
const paths = resolveFleetPaths('/home/test/.config/mosaic');
|
||||||
expect(buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory')).toEqual([
|
expect(
|
||||||
|
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'),
|
||||||
|
).toEqual([
|
||||||
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
|
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
|
||||||
'-L',
|
'-L',
|
||||||
'mosaic-factory',
|
'mosaic-factory',
|
||||||
|
'-S',
|
||||||
|
'operator:mosaic-cli',
|
||||||
'-s',
|
'-s',
|
||||||
'coder0',
|
'coder0',
|
||||||
'-m',
|
'-m',
|
||||||
@@ -255,6 +292,36 @@ describe('fleet command construction', () => {
|
|||||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
|
||||||
|
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('writes init output to the explicit roster path', async () => {
|
it('writes init output to the explicit roster path', async () => {
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||||||
@@ -536,6 +603,104 @@ describe('fleet command construction', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a deterministic operator source label for agent sends', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(home, 'fleet', 'roster.yaml'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'json-agent', runtime: 'pi' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'agent',
|
||||||
|
'send',
|
||||||
|
'json-agent',
|
||||||
|
'--message',
|
||||||
|
'status check',
|
||||||
|
]);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
[
|
||||||
|
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
||||||
|
'-L',
|
||||||
|
'mosaic-factory',
|
||||||
|
'-S',
|
||||||
|
getDefaultOperatorSourceLabel(),
|
||||||
|
'-s',
|
||||||
|
'json-agent',
|
||||||
|
'-m',
|
||||||
|
'status check',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows agent sends to override the source label explicitly', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(home, 'fleet', 'roster.yaml'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'coder0', runtime: 'codex' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'agent',
|
||||||
|
'send',
|
||||||
|
'coder0',
|
||||||
|
'--message',
|
||||||
|
'handoff',
|
||||||
|
'--source-label',
|
||||||
|
'lead:manual',
|
||||||
|
]);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
[
|
||||||
|
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
||||||
|
'-L',
|
||||||
|
'mosaic-factory',
|
||||||
|
'-S',
|
||||||
|
'lead:manual',
|
||||||
|
'-s',
|
||||||
|
'coder0',
|
||||||
|
'-m',
|
||||||
|
'handoff',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects agent status typos before invoking the runner', async () => {
|
it('rejects agent status typos before invoking the runner', async () => {
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
@@ -560,4 +725,14 @@ describe('fleet command construction', () => {
|
|||||||
await rm(home, { recursive: true, force: true });
|
await rm(home, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps fleet framework assets in the published package file list', async () => {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
|
||||||
|
) as {
|
||||||
|
files?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
@@ -148,6 +148,29 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
|
||||||
|
if (!existingEnv?.trim()) {
|
||||||
|
return generatedEnv;
|
||||||
|
}
|
||||||
|
const generatedKeys = new Set(
|
||||||
|
generatedEnv
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1])
|
||||||
|
.filter((key): key is string => key !== undefined),
|
||||||
|
);
|
||||||
|
const preservedLines = existingEnv.split('\n').filter((line) => {
|
||||||
|
if (!line.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
|
||||||
|
return key === undefined || !generatedKeys.has(key);
|
||||||
|
});
|
||||||
|
if (preservedLines.length === 0) {
|
||||||
|
return generatedEnv;
|
||||||
|
}
|
||||||
|
return [generatedEnv.trimEnd(), ...preservedLines, ''].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
||||||
const service = agentName ? `mosaic-agent@${agentName}.service` : 'mosaic-tmux-holder.service';
|
const service = agentName ? `mosaic-agent@${agentName}.service` : 'mosaic-tmux-holder.service';
|
||||||
return ['systemctl', '--user', action, service];
|
return ['systemctl', '--user', action, service];
|
||||||
@@ -158,11 +181,14 @@ export function buildAgentSendCommand(
|
|||||||
agentName: string,
|
agentName: string,
|
||||||
message: string,
|
message: string,
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
socketName = DEFAULT_SOCKET_NAME,
|
||||||
|
sourceLabel = getDefaultOperatorSourceLabel(),
|
||||||
): string[] {
|
): string[] {
|
||||||
return [
|
return [
|
||||||
join(paths.tmuxToolsDir, 'agent-send.sh'),
|
join(paths.tmuxToolsDir, 'agent-send.sh'),
|
||||||
'-L',
|
'-L',
|
||||||
socketName,
|
socketName,
|
||||||
|
'-S',
|
||||||
|
sourceLabel,
|
||||||
'-s',
|
'-s',
|
||||||
agentName,
|
agentName,
|
||||||
'-m',
|
'-m',
|
||||||
@@ -170,6 +196,11 @@ export function buildAgentSendCommand(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultOperatorSourceLabel(): string {
|
||||||
|
const shortHostname = hostname().split('.')[0] || 'localhost';
|
||||||
|
return `${shortHostname}:operator`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAgentResetCommand(
|
export function buildAgentResetCommand(
|
||||||
paths: FleetPaths,
|
paths: FleetPaths,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
@@ -384,15 +415,22 @@ export function registerFleetAgentCommands(
|
|||||||
.command('send <agent>')
|
.command('send <agent>')
|
||||||
.description('Send a message to a local fleet agent')
|
.description('Send a message to a local fleet agent')
|
||||||
.requiredOption('--message <text>', 'Message text')
|
.requiredOption('--message <text>', 'Message text')
|
||||||
.action(async (agent: string, opts: { message: string }) => {
|
.option('--source-label <label>', 'Source label for the message preamble')
|
||||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
.option('--source <label>', 'Alias for --source-label')
|
||||||
getRosterAgent(roster, agent);
|
.action(
|
||||||
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
|
async (agent: string, opts: { message: string; sourceLabel?: string; source?: string }) => {
|
||||||
await runChecked(
|
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||||
runner,
|
getRosterAgent(roster, agent);
|
||||||
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName),
|
const paths = resolveFleetPaths(
|
||||||
);
|
resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome),
|
||||||
});
|
);
|
||||||
|
const sourceLabel = opts.sourceLabel ?? opts.source ?? getDefaultOperatorSourceLabel();
|
||||||
|
await runChecked(
|
||||||
|
runner,
|
||||||
|
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName, sourceLabel),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
agentCommand
|
agentCommand
|
||||||
.command('reset <agent>')
|
.command('reset <agent>')
|
||||||
@@ -440,18 +478,19 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||||
|
|
||||||
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
|
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
|
||||||
|
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||||
|
const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath];
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
join(activePaths.fleetToolsDir, 'start-agent-session.sh'),
|
startAgentSessionPath,
|
||||||
);
|
|
||||||
await copyFile(
|
|
||||||
join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'),
|
|
||||||
join(activePaths.tmuxToolsDir, 'send-message.sh'),
|
|
||||||
);
|
|
||||||
await copyFile(
|
|
||||||
join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'),
|
|
||||||
join(activePaths.tmuxToolsDir, 'agent-send.sh'),
|
|
||||||
);
|
);
|
||||||
|
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
|
||||||
|
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
|
||||||
|
for (const toolPath of executableToolPaths) {
|
||||||
|
await chmod(toolPath, 0o755);
|
||||||
|
}
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
|
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
|
||||||
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
|
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
|
||||||
@@ -462,10 +501,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const agent of roster.agents) {
|
for (const agent of roster.agents) {
|
||||||
await writeFile(
|
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||||
join(activePaths.agentEnvDir, `${agent.name}.env`),
|
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||||
generateAgentEnv(roster, agent),
|
await writeFile(envPath, mergeAgentEnv(generateAgentEnv(roster, agent), existingEnv));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||||
|
|||||||
Reference in New Issue
Block a user