mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a619fc1aa2 | |||
| 3d2f3e58ca | |||
| 11afc64ba4 | |||
| 0ee75d393c | |||
| 72b9cc7ed0 | |||
| 5fcf234165 | |||
| 9b1236505f | |||
| 878cd68c1b | |||
| fab1ebf2d6 | |||
| 3f9e89d345 | |||
| 14810a5090 | |||
| 2cfa86e570 | |||
| 36cbf17e10 | |||
| 4459ab2e54 | |||
| 9e6238d28f | |||
| d1bda5d15b | |||
| 7eddc7d8c9 | |||
| 991ef986f8 | |||
| 0f2557e2bc | |||
| 4e6552ed55 | |||
| 978b998ee6 | |||
| 83951d7c01 | |||
| 76ef097521 | |||
| 1c85fd6e50 | |||
| 42275ede1f | |||
| 53e1989529 | |||
| 6f2142d7c7 | |||
| 79a0226962 | |||
| 0b31695e92 | |||
| 421f8707d2 | |||
| 67ccd9e74c | |||
| f69af07c57 | |||
| 93a302b5db | |||
| eef285ba3b | |||
| a806534199 | |||
| 0ac8073e34 | |||
| 539a2b3c63 | |||
| fccaadf24c | |||
| 3329270c67 | |||
| f16ea0c783 | |||
| 1c024bc976 | |||
| 6c26f3ef08 | |||
| ab6ab6936c | |||
| 501afb4beb | |||
| 9040dbb86e | |||
| d8748e3a45 | |||
| 41a720dd59 | |||
| 6ae83f48ac | |||
| dc34ceb83d |
@@ -111,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
|
||||
@@ -119,8 +119,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
|
||||
@@ -33,7 +33,7 @@ Run `/update-nanoclaw` in Claude Code.
|
||||
|
||||
**Validation**: runs `pnpm run build` and `pnpm test`. If container files changed, also runs the container typecheck and `./container/build.sh`.
|
||||
|
||||
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
|
||||
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update and diffs `versions.json` for moved component pins. Each entry carries its migration path — a skill to run or a `docs/` page to follow (per CONTRIBUTING.md, "Breaking Changes") — and the skill walks you through them.
|
||||
|
||||
## Rollback
|
||||
|
||||
@@ -221,24 +221,31 @@ After validation succeeds, check if the update introduced any breaking changes.
|
||||
Determine which CHANGELOG entries are new by diffing against the backup tag:
|
||||
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`
|
||||
|
||||
Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is:
|
||||
Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry, and per CONTRIBUTING.md ("Breaking Changes") it references its migration path in one of two forms:
|
||||
```
|
||||
[BREAKING] <description>. Run `/<skill-name>` to <action>.
|
||||
[BREAKING] <description>. **Migration:** follow [docs/<page>.md](docs/<page>.md) ...
|
||||
```
|
||||
|
||||
If no `[BREAKING]` lines are found:
|
||||
Also diff the component version pins:
|
||||
- `git diff <backup-tag-from-step-1>..HEAD -- versions.json`
|
||||
|
||||
Each changed pin is a breaking component update (e.g. `onecli-gateway` moving means the OneCLI gateway must be upgraded). Its migration path is the `[BREAKING]` CHANGELOG entry covering it; if no new entry mentions it, search `docs/` for the pin name (convention: `docs/<component>-upgrades.md`) and treat that doc as the migration path.
|
||||
|
||||
If no `[BREAKING]` lines are found and `versions.json` did not change:
|
||||
- Skip this step silently. Proceed to Step 7 (skill updates check).
|
||||
|
||||
If one or more `[BREAKING]` lines are found:
|
||||
Otherwise:
|
||||
- Display a warning header to the user: "This update includes breaking changes that may require action:"
|
||||
- For each breaking change, display the full description.
|
||||
- Collect all skill names referenced in the breaking change entries (the `/<skill-name>` part).
|
||||
- Use AskUserQuestion to ask the user which migration skills they want to run now. Options:
|
||||
- For each breaking change, display the full description (for a moved pin without its own entry: the component name, old → new version, and the doc that covers it).
|
||||
- Use AskUserQuestion to ask the user which migrations to run now. Options:
|
||||
- One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel")
|
||||
- One option per referenced doc (e.g., "Upgrade the OneCLI gateway (docs/onecli-upgrades.md)")
|
||||
- "Skip — I'll handle these manually"
|
||||
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
|
||||
- Set `multiSelect: true` so the user can pick multiple migrations if there are several breaking changes.
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
- For each doc the user selects, read the doc and execute it top to bottom — these docs are written to be executed verbatim by a coding agent (detect → fix → verify → rollback). Stop and report if a verify step fails.
|
||||
- After all selected migrations complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`; the `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **Slash commands now interrupt an in-flight turn.** A runner-handled command (`/clear`, `/compact`, `/cost`, …) arriving mid-turn aborts the active stream and runs immediately instead of waiting out the turn.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
|
||||
|
||||
@@ -33,7 +33,7 @@ user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
|
||||
|
||||
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
messaging_groups (one chat/channel on one platform; unknown_sender_policy)
|
||||
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
|
||||
|
||||
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
|
||||
```
|
||||
@@ -83,6 +83,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
|
||||
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
|
||||
**Not accepted:** Features, capabilities, compatibility, enhancements. These should be skills.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Breaking changes are allowed; **silent** ones are not. NanoClaw does not migrate user installs at runtime — the user's coding agent is the migrator, so every breaking change must ship a migration path that agent can execute without a human reverse-engineering the diff:
|
||||
|
||||
1. **Every `[BREAKING]` CHANGELOG entry must reference its migration path** — either a skill to run (`Run /<skill-name> to <action>`) or a `docs/` page covering **detect / why / fix / verify / rollback** (see [docs/onecli-upgrades.md](docs/onecli-upgrades.md) for the shape). `/update-nanoclaw` surfaces these entries after every update and walks the user through them.
|
||||
2. **If the change moves an external component's sanctioned version** (gateway, pinned CLI binary, …), update its pin in [`versions.json`](versions.json). The changelog stays human-narrative; `versions.json` is the machine-checkable signal — `/update-nanoclaw` diffs it across the update and routes the user to the linked doc for any pin that moved.
|
||||
|
||||
## Skills
|
||||
|
||||
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
|
||||
|
||||
@@ -196,6 +196,14 @@ Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?"
|
||||
|
||||
If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.
|
||||
|
||||
**How do I uninstall NanoClaw?**
|
||||
|
||||
```bash
|
||||
bash nanoclaw.sh --uninstall
|
||||
```
|
||||
|
||||
Every install is tagged with a per-checkout id, so the uninstaller removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself.
|
||||
|
||||
**What changes will be accepted into the codebase?**
|
||||
|
||||
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.
|
||||
|
||||
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
// Providers barrel — each enabled provider self-registers on import.
|
||||
// Provider skills append imports to providers/index.ts.
|
||||
import './providers/index.js';
|
||||
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
|
||||
// the runner creates a persistent memory/ tree in its host-backed workspace at
|
||||
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
|
||||
// and keeps its native memory untouched.
|
||||
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { getContinuation, setContinuation } from './db/session-state.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import type { ProviderExchange } from './providers/types.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -304,6 +305,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
signal,
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
@@ -324,6 +326,86 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('poll loop — exchange hook (onExchangeComplete)', () => {
|
||||
// A provider that declares the per-exchange hook. The hook call is the
|
||||
// wiring under test — these tests go red if the poll-loop seam is severed.
|
||||
// What the provider DOES with an exchange (e.g. write markdown into
|
||||
// conversations/) ships with the provider, not the runner.
|
||||
class HookedMockProvider extends MockProvider {
|
||||
readonly exchanges: ProviderExchange[] = [];
|
||||
onExchangeComplete(exchange: ProviderExchange): void {
|
||||
this.exchanges.push(exchange);
|
||||
}
|
||||
}
|
||||
|
||||
it('reports each exchange to a provider that declares the hook', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'please archive this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new HookedMockProvider({}, () => '<message to="discord-test">archived answer</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => provider.exchanges.length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.exchanges.length).toBe(1);
|
||||
const exchange = provider.exchanges[0];
|
||||
expect(exchange.prompt).toContain('please archive this');
|
||||
expect(exchange.result).toContain('archived answer');
|
||||
expect(exchange.continuation).toStartWith('mock-session-');
|
||||
expect(exchange.status).toBe('completed');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('does not report the internal wrapping-retry nudge as a user prompt', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'wrap this later' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
let calls = 0;
|
||||
const provider = new HookedMockProvider({}, () => {
|
||||
calls += 1;
|
||||
// First result is unwrapped (triggers the retry nudge), second is wrapped.
|
||||
return calls === 1 ? 'unwrapped text' : '<message to="discord-test">wrapped now</message>';
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
|
||||
|
||||
await waitFor(() => provider.exchanges.length >= 2, 3000);
|
||||
controller.abort();
|
||||
|
||||
// Both exchanges attribute themselves to the real user prompt, never the nudge.
|
||||
for (const exchange of provider.exchanges) {
|
||||
expect(exchange.prompt).not.toContain('Your response was not delivered');
|
||||
expect(exchange.prompt).toContain('wrap this later');
|
||||
}
|
||||
expect(provider.exchanges.map((e) => e.status)).toEqual(['undelivered', 'completed']);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('a throwing hook never breaks delivery', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'still deliver this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
class ThrowingHookProvider extends MockProvider {
|
||||
onExchangeComplete(): void {
|
||||
throw new Error('hook exploded');
|
||||
}
|
||||
}
|
||||
const provider = new ThrowingHookProvider({}, () => '<message to="discord-test">delivered anyway</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].content).toContain('delivered anyway');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — provider error recovery', () => {
|
||||
it('writes error to outbound and continues loop on provider throw', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
@@ -462,3 +544,76 @@ class InvalidSessionProvider {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('poll loop — slash command during active query', () => {
|
||||
it('aborts the active query when /clear arrives as a follow-up', async () => {
|
||||
insertMessage('m-active', { sender: 'Alice', text: 'long running request' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new BlockingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 3000);
|
||||
|
||||
await waitFor(() => provider.queries === 1, 2000);
|
||||
insertMessage('m-clear-active', { sender: 'Alice', text: '/clear' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
await waitFor(() => provider.aborts === 1, 2000);
|
||||
await waitFor(
|
||||
() => getUndeliveredMessages().some((msg) => JSON.parse(msg.content).text === 'Session cleared.'),
|
||||
2000,
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.ends).toBe(0);
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
expect(getPendingMessages()).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider whose query never completes until ended/aborted — for testing how
|
||||
* the loop interrupts an active stream.
|
||||
*/
|
||||
class BlockingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
queries = 0;
|
||||
aborts = 0;
|
||||
ends = 0;
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query() {
|
||||
const owner = this;
|
||||
this.queries += 1;
|
||||
let wake: (() => void) | null = null;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
|
||||
return {
|
||||
push() {},
|
||||
end: () => {
|
||||
owner.ends += 1;
|
||||
ended = true;
|
||||
wake?.();
|
||||
},
|
||||
abort: () => {
|
||||
owner.aborts += 1;
|
||||
aborted = true;
|
||||
wake?.();
|
||||
},
|
||||
events: (async function* () {
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'init' as const, continuation: 'blocking-session' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
|
||||
describe('ensureMemoryScaffold', () => {
|
||||
it('deterministically creates the memory tree', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent and never clobbers the agent edits', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
const indexFile = path.join(base, 'memory', 'index.md');
|
||||
fs.writeFileSync(indexFile, '# my own index\n');
|
||||
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Create the agent's persistent memory scaffold, container-side, at boot.
|
||||
*
|
||||
* The runner owns its own workspace: it writes the memory tree straight into
|
||||
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
|
||||
* ephemeral container). No host-side step, nothing mounted in.
|
||||
*
|
||||
* The default `definition.md` / `index.md` live as real markdown templates next
|
||||
* to this module (under `memory-templates/`) — not as strings in code — so the
|
||||
* doctrine is editable as markdown and the agent receives an unescaped copy.
|
||||
* They ship in the mounted `/app/src` tree, so no image change is needed.
|
||||
*
|
||||
* Idempotent — only writes what's missing, so the agent's own edits and
|
||||
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
|
||||
* the runner makes no assumption about which harness is running — a provider
|
||||
* opts in via `usesMemoryScaffold`.
|
||||
*/
|
||||
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
|
||||
|
||||
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
|
||||
const memoryDir = path.join(baseDir, 'memory');
|
||||
const systemDir = path.join(memoryDir, 'system');
|
||||
|
||||
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
|
||||
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
|
||||
}
|
||||
|
||||
function copyTemplateIfMissing(template: string, dest: string): void {
|
||||
if (fs.existsSync(dest)) return;
|
||||
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
|
||||
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
|
||||
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
|
||||
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
|
||||
// test goes red. main() can't be driven in-process (it reads
|
||||
// /workspace/agent/container.json and enters the poll loop), so the guard is
|
||||
// structural: gate + import must both be present in the real entry point.
|
||||
describe('memory scaffold boot wiring', () => {
|
||||
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
|
||||
|
||||
it('gates the scaffold on the provider capability in main()', () => {
|
||||
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
|
||||
});
|
||||
|
||||
it('imports ensureMemoryScaffold from the seam module', () => {
|
||||
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Memory System
|
||||
|
||||
This editable file defines how your persistent memory works. It is a starting
|
||||
point, not a contract — reorganize it as the work demands. If the user or another
|
||||
memory system replaces this definition, follow the replacement.
|
||||
|
||||
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
|
||||
Treat indexes as core data: keep them accurate and concise.
|
||||
Every folder of durable memory has its own `index.md` describing its contents.
|
||||
When an index grows past roughly 20 entries, group related items into subfolders,
|
||||
and give each new subfolder its own `index.md` linked from the parent.
|
||||
|
||||
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
|
||||
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
|
||||
Use entity folders for things that matter: projects, people, places, organizations, decisions.
|
||||
|
||||
When the user shares something that should survive future turns, store it in the
|
||||
smallest useful file; prefer updating an existing file over creating duplicates.
|
||||
Write concise, source-aware notes; include dates when timing matters.
|
||||
If a fact is corrected, update the memory and keep only useful history.
|
||||
When you add, move, or remove memory, update the nearest index.
|
||||
Before answering from memory, read the relevant index or file instead of guessing;
|
||||
if memory is missing or uncertain, say so and verify when it matters.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Memory Index
|
||||
|
||||
- [Memory system definition](system/definition.md)
|
||||
- [Memories](memories/) - durable facts, people, projects, decisions
|
||||
- [Data](data/) - structured reference data
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const ACTIVE_POLL_INTERVAL_MS = 500;
|
||||
@@ -63,6 +63,12 @@ export interface PollLoopConfig {
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
/**
|
||||
* Optional stop signal. In production the loop runs until the container
|
||||
* dies; tests pass a signal so an abandoned loop actually exits instead of
|
||||
* polling forever and stealing messages from the next test's DB.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +113,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
let pollCount = 0;
|
||||
let isFirstPoll = true;
|
||||
while (true) {
|
||||
if (config.signal?.aborted) return;
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
|
||||
isFirstPoll = false;
|
||||
@@ -232,7 +239,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
setCurrentInReplyTo(routing.inReplyTo);
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
const result = await processQuery(
|
||||
query,
|
||||
routing,
|
||||
processingIds,
|
||||
config.providerName,
|
||||
config.provider.onExchangeComplete?.bind(config.provider),
|
||||
prompt,
|
||||
continuation,
|
||||
);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setContinuation(config.providerName, continuation);
|
||||
@@ -313,10 +328,18 @@ async function processQuery(
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
onExchangeComplete: ((exchange: ProviderExchange) => void) | undefined,
|
||||
initialPrompt: string,
|
||||
initialContinuation: string | undefined,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let unwrappedNudged = false;
|
||||
// Prompt queue for the exchange hook — each result event consumes the
|
||||
// oldest unanswered prompt, except a wrapping-retry result, which answers
|
||||
// the same prompt again. Unused (and unmaintained) when the provider
|
||||
// doesn't implement `onExchangeComplete`.
|
||||
const archivePrompts: string[] = [initialPrompt];
|
||||
|
||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||
// We do NOT force-end the stream on silence — keeping the query open avoids
|
||||
@@ -342,13 +365,16 @@ async function processQuery(
|
||||
// resume id (fixed at sdkQuery() time); admin/passthrough commands
|
||||
// (/compact, /cost, …) only dispatch when they're the first input
|
||||
// of a query — pushed mid-stream they arrive as plain text and
|
||||
// the SDK never runs them. End the stream and leave the rows
|
||||
// pending; the outer loop handles them on next iteration via the
|
||||
// canonical command path + formatMessagesWithCommands.
|
||||
// the SDK never runs them. Abort the active stream and leave the
|
||||
// rows pending; the outer loop handles them on next iteration via
|
||||
// the canonical command path + formatMessagesWithCommands. Abort,
|
||||
// not end: end() lets an in-flight turn run to completion, which
|
||||
// can block the command (e.g. /clear during a long task) for as
|
||||
// long as the turn takes.
|
||||
if (pending.some((m) => isRunnerCommand(m))) {
|
||||
log('Pending slash command — ending stream so outer loop can process');
|
||||
log('Pending slash command — aborting active stream so outer loop can process');
|
||||
endedForCommand = true;
|
||||
query.end();
|
||||
query.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,6 +419,7 @@ async function processQuery(
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
unwrappedNudged = false;
|
||||
query.push(prompt);
|
||||
archivePrompts.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
// Without this catch the rejection escapes the void IIFE and Node
|
||||
@@ -456,7 +483,14 @@ async function processQuery(
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
if (hasUnwrapped && !unwrappedNudged) {
|
||||
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: hasUnwrapped ? 'undelivered' : 'completed',
|
||||
});
|
||||
if (willRetryWrapping) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
@@ -467,9 +501,23 @@ async function processQuery(
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
}
|
||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||
// queued so the retry archives against it, not the nudge text.
|
||||
if (!willRetryWrapping) archivePrompts.shift();
|
||||
} else {
|
||||
archivePrompts.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: `Error: ${errMsg}`,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: 'error',
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
done = true;
|
||||
clearInterval(pollHandle);
|
||||
@@ -478,6 +526,18 @@ async function processQuery(
|
||||
return { continuation: queryContinuation };
|
||||
}
|
||||
|
||||
function notifyExchangeComplete(
|
||||
hook: ((exchange: ProviderExchange) => void) | undefined,
|
||||
exchange: ProviderExchange,
|
||||
): void {
|
||||
if (!hook) return;
|
||||
try {
|
||||
hook(exchange);
|
||||
} catch (err) {
|
||||
log(`onExchangeComplete failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
switch (event.type) {
|
||||
case 'init':
|
||||
|
||||
@@ -6,6 +6,25 @@ export interface AgentProvider {
|
||||
*/
|
||||
readonly supportsNativeSlashCommands: boolean;
|
||||
|
||||
/**
|
||||
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
|
||||
* agent's workspace at boot. Providers with their own native memory (e.g.
|
||||
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
|
||||
* provider, never gated on a provider name.
|
||||
*/
|
||||
readonly usesMemoryScaffold?: boolean;
|
||||
|
||||
/**
|
||||
* Optional. Called by the poll-loop after each completed exchange (a
|
||||
* result, a wrapping retry, or an error). Providers whose harness keeps no
|
||||
* on-disk transcript implement this to persist exchanges themselves (e.g.
|
||||
* markdown into the agent's `conversations/` dir); providers that persist
|
||||
* and archive their own transcript (e.g. the Claude Agent SDK's `.jsonl`)
|
||||
* omit it. Best-effort: the loop catches and logs anything it throws. The
|
||||
* implementation lives with the provider, never in the runner.
|
||||
*/
|
||||
onExchangeComplete?(exchange: ProviderExchange): void;
|
||||
|
||||
/** Start a new query. Returns a handle for streaming input and output. */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
|
||||
@@ -31,6 +50,16 @@ export interface AgentProvider {
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
/** One prompt/result round-trip, as reported to `onExchangeComplete`. */
|
||||
export interface ProviderExchange {
|
||||
/** The user prompt this exchange answers (never an internal retry nudge). */
|
||||
prompt: string;
|
||||
result: string | null;
|
||||
/** Continuation/thread id in effect for the exchange, if any. */
|
||||
continuation?: string;
|
||||
status: 'completed' | 'undelivered' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to provider constructors. Fields are common to most
|
||||
* providers; individual providers may ignore any they don't need.
|
||||
|
||||
@@ -668,15 +668,19 @@ CREATE TABLE agent_groups (
|
||||
);
|
||||
|
||||
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
|
||||
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
|
||||
-- (the "default instance"), so single-instance installs never see it.
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email'
|
||||
platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.)
|
||||
instance TEXT NOT NULL, -- adapter-instance name; default = channel_type
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
|
||||
-- Users (messaging platform identities, namespaced "<channel_type>:<handle>")
|
||||
|
||||
+6
-3
@@ -27,21 +27,24 @@ CREATE TABLE agent_groups (
|
||||
|
||||
### 1.2 `messaging_groups`
|
||||
|
||||
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.).
|
||||
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.) per adapter instance.
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
```
|
||||
|
||||
- `instance`: adapter-instance name — N adapters of one platform (e.g. three Slack apps in one workspace) each own their rows. The default instance IS the channel type: migration 016 backfills `instance = channel_type` and `createMessagingGroup` stamps the same default, so single-instance installs never see the dimension. Inbound lookups are exact-on-instance (an unknown named instance auto-creates its own row); outbound lookups resolve default-instance-first.
|
||||
- `unknown_sender_policy`: `strict` (drop), `request_approval` (ask admin), `public` (allow).
|
||||
- **Readers:** `src/router.ts`, `src/delivery.ts`, `src/session-manager.ts`
|
||||
- **Writers:** `src/db/messaging-groups.ts`, channel setup flows
|
||||
@@ -134,7 +137,7 @@ CREATE TABLE user_dms (
|
||||
);
|
||||
```
|
||||
|
||||
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`.
|
||||
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`. Cold DMs resolve via the channel's default adapter instance — `PRIMARY KEY (user_id, channel_type)` is per-platform, not per-instance.
|
||||
|
||||
### 1.8 `sessions`
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Upgrading the OneCLI gateway
|
||||
|
||||
NanoClaw talks to the OneCLI gateway (credential vault + egress proxy) through `@onecli-sh/sdk`. The gateway is an external component with its own release line, so NanoClaw pins the **sanctioned gateway version** in [`versions.json`](../versions.json) under `onecli-gateway`. When an update moves that pin, the gateway must be upgraded — this doc is the migration path. It is written to be handed to a coding agent verbatim: detect → upgrade → verify → rollback.
|
||||
|
||||
There is deliberately **no runtime version check, and setup does not migrate the gateway for you**: the gateway is a separate out-of-band component, and the migrator is your coding agent running `/update-nanoclaw` — it diffs `versions.json` across the update and routes you here when the `onecli-gateway` pin moved. (Setup detects a pre-`/v1` gateway and points at this doc, but never upgrades it.) Run the steps below verbatim.
|
||||
|
||||
## 1. Detect
|
||||
|
||||
Find out what is running and what is required:
|
||||
|
||||
```bash
|
||||
cat versions.json # the sanctioned pin
|
||||
curl -s http://127.0.0.1:10254/api/health # reports the running gateway version
|
||||
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:10254/v1/health
|
||||
```
|
||||
|
||||
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's in `.env` as `ONECLI_URL` / `NANOCLAW_ONECLI_API_HOST`).
|
||||
|
||||
Why gateways fall behind: the OneCLI installer's docker-compose tracks the `latest` image tag, but Docker never re-pulls a tag — the server freezes at whatever `latest` meant on install day.
|
||||
|
||||
## 2. Upgrade
|
||||
|
||||
The gateway runs as a Docker service in `~/.onecli`. Upgrade just that container to the pinned `onecli-gateway` version — vault data lives in named Docker volumes and survives. This upgrades only the gateway; the CLI binary is pinned separately (see below).
|
||||
|
||||
**Local gateway (the common case):**
|
||||
|
||||
```bash
|
||||
cd ~/.onecli && ONECLI_VERSION=<onecli-gateway pin from versions.json> docker compose pull onecli && docker compose up -d
|
||||
```
|
||||
|
||||
**Remote gateway** — run the same command on the gateway's host (NanoClaw can't reach it over SSH).
|
||||
|
||||
## 3. Verify
|
||||
|
||||
Host-side health is necessary but **not sufficient**:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:10254/v1/health # must return {"status":"ok",...}
|
||||
```
|
||||
|
||||
**Verify the bind interface (container reachability).** Agent containers reach the gateway over the docker bridge (`host.docker.internal` → e.g. `172.17.0.1`), so a server bound only to `127.0.0.1` boots clean host-side while every credentialed call from containers dies at the proxy:
|
||||
|
||||
```bash
|
||||
docker run --rm --add-host=host.docker.internal:host-gateway \
|
||||
curlimages/curl -s -o /dev/null -w '%{http_code}' http://host.docker.internal:10254/v1/health
|
||||
```
|
||||
|
||||
This must print `200`. If it can't connect while the host-side check passed, set the bind address in `~/.onecli/.env` to the docker-bridge IP (or `0.0.0.0` on a host with a closed firewall) and `cd ~/.onecli && docker compose up -d`. Symptom if skipped: host log clean, agents fail all API calls.
|
||||
|
||||
Finally, restart the NanoClaw service (per-install names — derive with `setup/lib/install-slug.sh`):
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
# Linux
|
||||
source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## 4. Rollback
|
||||
|
||||
```bash
|
||||
cd ~/.onecli && ONECLI_VERSION=<old-version> docker compose up -d
|
||||
```
|
||||
|
||||
If the NanoClaw update itself is being rolled back, also pin `@onecli-sh/sdk` back to its previous version in `package.json` and run `pnpm install`. Vault data is unaffected in both directions.
|
||||
|
||||
## The CLI binary (`onecli-cli` pin)
|
||||
|
||||
The `onecli` host CLI is pinned the same way, under `onecli-cli` in `versions.json`. Setup installs exactly that version by direct release download — it never resolves "latest". When an update moves this pin, replace the binary with the pinned release:
|
||||
|
||||
```bash
|
||||
onecli --version # detect: what is installed
|
||||
V=<onecli-cli pin from versions.json>
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') # amd64 | arm64
|
||||
curl -fsSL -o /tmp/onecli.tgz \
|
||||
"https://github.com/onecli/onecli-cli/releases/download/v${V}/onecli_${V}_${OS}_${ARCH}.tar.gz"
|
||||
tar -xzf /tmp/onecli.tgz -C /tmp
|
||||
install -m 0755 /tmp/onecli "$(command -v onecli || echo ~/.local/bin/onecli)"
|
||||
onecli --version # verify: must match versions.json
|
||||
```
|
||||
|
||||
To roll back, run the same block after reverting `versions.json` (or checking out the previous NanoClaw version). The CLI is stateless — vault data lives in the gateway, so swapping the binary in either direction loses nothing.
|
||||
+1
-1
@@ -187,7 +187,7 @@ leaking the token to disk outweighs the debugging value.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. |
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. |
|
||||
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
|
||||
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
|
||||
+38
@@ -25,6 +25,44 @@ set -euo pipefail
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# ─── --uninstall: short-circuit before any setup work ──────────────────
|
||||
# Never install dependencies just to uninstall. With the TS toolchain
|
||||
# present, hand straight off to setup:auto (the flow lives in
|
||||
# setup/uninstall/); without it, print manual cleanup guidance. Runs
|
||||
# before diagnostics.sh is sourced so a pure uninstall doesn't emit
|
||||
# setup_launched, and before all pre-flights/bootstrap.
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--uninstall" ]; then
|
||||
# exec tsx directly rather than `pnpm run -- …`: pnpm passes the `--`
|
||||
# separator through to the script, where the flag parser treats
|
||||
# everything after it as positional args and the flags get dropped.
|
||||
# Gate on node (tsx's shebang interpreter) — pnpm isn't used here.
|
||||
if command -v node >/dev/null 2>&1 && [ -x "$PROJECT_ROOT/node_modules/.bin/tsx" ]; then
|
||||
exec "$PROJECT_ROOT/node_modules/.bin/tsx" "$PROJECT_ROOT/setup/auto.ts" "$@"
|
||||
fi
|
||||
export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
UNINSTALL_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
||||
echo "Can't run the uninstaller: dependencies are missing (node_modules/)."
|
||||
echo "Either re-run 'bash nanoclaw.sh' once to restore them, or clean up manually:"
|
||||
echo ""
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
echo " launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist"
|
||||
echo " rm -f ~/Library/LaunchAgents/$(launchd_label).plist"
|
||||
else
|
||||
echo " systemctl --user disable --now $(systemd_unit).service"
|
||||
echo " rm -f ~/.config/systemd/user/$(systemd_unit).service && systemctl --user daemon-reload"
|
||||
fi
|
||||
echo " $UNINSTALL_RUNTIME ps -aq --filter label=nanoclaw-install=$(_nanoclaw_install_slug) | xargs -r $UNINSTALL_RUNTIME rm -f"
|
||||
echo " $UNINSTALL_RUNTIME rmi $(container_image_base):latest"
|
||||
echo " rm -f ~/.local/bin/ncl # only if it points at this folder"
|
||||
echo ""
|
||||
echo "Then back up $PROJECT_ROOT/.env if you need the keys, and delete the folder."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
LOGS_DIR="$PROJECT_ROOT/logs"
|
||||
STEPS_DIR="$LOGS_DIR/setup-steps"
|
||||
PROGRESS_LOG="$LOGS_DIR/setup.log"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.4",
|
||||
"version": "2.1.13",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.5.0",
|
||||
"@onecli-sh/sdk": "2.2.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"cron-parser": "5.5.0",
|
||||
|
||||
Generated
+5
-5
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@onecli-sh/sdk':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1
|
||||
better-sqlite3:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
@@ -303,8 +303,8 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@onecli-sh/sdk@0.5.0':
|
||||
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
|
||||
'@onecli-sh/sdk@2.2.1':
|
||||
resolution: {integrity: sha512-q2mCW4ZsARlLEoTxz/P0NQ4MiCh7Z2n28pxkSc7srS+tozyw40PdTnWYW7NI8hfSYplZTx5856Adq1iPi4KN3Q==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@oxc-project/types@0.124.0':
|
||||
@@ -1665,7 +1665,7 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@onecli-sh/sdk@0.5.0': {}
|
||||
'@onecli-sh/sdk@2.2.1': {}
|
||||
|
||||
'@oxc-project/types@0.124.0': {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="185k tokens, 92% of context window">
|
||||
<title>185k tokens, 92% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="192k tokens, 96% of context window">
|
||||
<title>192k tokens, 96% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">185k</text>
|
||||
<text x="71" y="14">185k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">192k</text>
|
||||
<text x="71" y="14">192k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -48,6 +48,8 @@ import {
|
||||
} from './lib/setup-config-parse.js';
|
||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { runUninstallFlow } from './uninstall/flow.js';
|
||||
import { detectExistingInstall } from './uninstall/scan.js';
|
||||
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
|
||||
import { pollHealth } from './onecli.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
@@ -88,6 +90,17 @@ async function main(): Promise<void> {
|
||||
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||||
applyToEnv(configValues);
|
||||
|
||||
// --uninstall routes to the uninstall flow before any setup side effects —
|
||||
// in particular before initProgressionLog(), so an uninstall never resets
|
||||
// logs/setup.log on its way to (possibly) deleting logs/ entirely.
|
||||
if (configValues.uninstall === true) {
|
||||
await runUninstallFlow({
|
||||
dryRun: configValues.dryRun === true,
|
||||
yes: configValues.yes === true,
|
||||
invokedFrom: 'flag',
|
||||
});
|
||||
}
|
||||
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
phEmit('auto_started');
|
||||
@@ -121,6 +134,37 @@ async function main(): Promise<void> {
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// Offer removal when setup lands on an existing install. Skipped on every
|
||||
// resume path — both the fail() retry and the sg-docker re-exec pass
|
||||
// NANOCLAW_SKIP (and the latter sets NANOCLAW_REEXEC_SG) — so the prompt
|
||||
// appears at most once per fresh run.
|
||||
const isResume = process.env.NANOCLAW_REEXEC_SG === '1' || skip.size > 0;
|
||||
if (!isResume && detectExistingInstall(process.cwd())) {
|
||||
const action = ensureAnswer(
|
||||
await brightSelect<'keep' | 'uninstall'>({
|
||||
message: 'NanoClaw is already installed in this folder. What would you like to do?',
|
||||
options: [
|
||||
{
|
||||
value: 'keep',
|
||||
label: 'Keep it & continue setup',
|
||||
hint: 'recommended — re-running setup is safe',
|
||||
},
|
||||
{
|
||||
value: 'uninstall',
|
||||
label: 'Uninstall NanoClaw & exit',
|
||||
hint: 'removes service, data, and agent files — asks before each step',
|
||||
},
|
||||
],
|
||||
initialValue: 'keep',
|
||||
}),
|
||||
) as 'keep' | 'uninstall';
|
||||
setupLog.userInput('existing_install', action);
|
||||
phEmit('existing_install_detected', { action });
|
||||
if (action === 'uninstall') {
|
||||
await runUninstallFlow({ dryRun: false, yes: false, invokedFrom: 'setup-detection' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('environment')) {
|
||||
const res = await runQuietStep('environment', {
|
||||
running: 'Checking your system…',
|
||||
|
||||
+54
-47
@@ -11,9 +11,17 @@
|
||||
* 1. Build a handoff prompt from the caller's context: channel, current
|
||||
* step, completed steps, collected values (secrets redacted), relevant
|
||||
* files to read.
|
||||
* 2. Spawn `claude --append-system-prompt "<context>"
|
||||
* --permission-mode acceptEdits` with `stdio: 'inherit'` so Claude owns
|
||||
* the terminal.
|
||||
* 2. Spawn `claude "<prompt>" --permission-mode auto` with
|
||||
* `stdio: 'inherit'` so Claude owns the terminal. The positional prompt
|
||||
* is auto-submitted as the first user message, so Claude starts
|
||||
* orienting immediately instead of sitting at an empty prompt — and the
|
||||
* context stays visible in the transcript and survives `--resume`,
|
||||
* which an --append-system-prompt would not.
|
||||
* 2a. All handoffs in one setup run share a single session: the first
|
||||
* spawn pins a generated UUID via `--session-id`, later spawns pass
|
||||
* `--resume <uuid>` so Claude keeps the context of earlier handoffs.
|
||||
* (stdio is inherited, so we can't *read* the session id Claude picks —
|
||||
* pinning our own is the only way to find the session again.)
|
||||
* 3. When Claude exits (user types /exit, Ctrl-D, or closes the session),
|
||||
* control returns to the setup driver. The driver can then re-offer the
|
||||
* same step (e.g., "How did that go?" select).
|
||||
@@ -23,6 +31,7 @@
|
||||
* attempting to parse it as a real answer.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
@@ -61,8 +70,8 @@ export interface HandoffContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn interactive Claude with context pre-loaded as a system-prompt
|
||||
* append. Returns when Claude exits.
|
||||
* Spawn interactive Claude with the handoff context as an auto-submitted
|
||||
* first prompt. Returns when Claude exits.
|
||||
*
|
||||
* Silently no-ops (returns `false`) if `claude` isn't on PATH — setup runs
|
||||
* where the binary is guaranteed to exist (we install it in the auth step),
|
||||
@@ -78,8 +87,6 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
return false;
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(ctx);
|
||||
|
||||
note(
|
||||
[
|
||||
"I'm handing you off to Claude in interactive mode.",
|
||||
@@ -90,18 +97,39 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return spawnInteractiveClaude(buildHandoffPrompt(ctx));
|
||||
}
|
||||
|
||||
// One session shared by every interactive handoff in this setup-driver
|
||||
// process. We pin the id ourselves (--session-id) on the first spawn because
|
||||
// stdio is inherited and Claude's own id is never visible to us; subsequent
|
||||
// spawns --resume it so Claude remembers earlier handoffs. Separate from
|
||||
// claude-assist's non-interactive session — the two formats don't mix.
|
||||
const handoffSessionId = randomUUID();
|
||||
let handoffSessionStarted = false;
|
||||
|
||||
/**
|
||||
* Spawn interactive Claude with the handoff context auto-submitted as the
|
||||
* first user message. Resolves when Claude exits and control returns to
|
||||
* the setup driver.
|
||||
*/
|
||||
function spawnInteractiveClaude(prompt: string): Promise<boolean> {
|
||||
const sessionArgs = handoffSessionStarted
|
||||
? ['--resume', handoffSessionId]
|
||||
: ['--session-id', handoffSessionId];
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
prompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
'auto',
|
||||
...sessionArgs,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
handoffSessionStarted = true;
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
@@ -164,20 +192,20 @@ function isClaudeUsable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(ctx: HandoffContext): string {
|
||||
function buildHandoffPrompt(ctx: HandoffContext): string {
|
||||
const lines: string[] = [
|
||||
`The user is running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel.`,
|
||||
`They got stuck at the step: "${ctx.step}" (${ctx.stepDescription}) and asked for help.`,
|
||||
`I'm running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel`,
|
||||
`and got stuck at the step: "${ctx.step}" (${ctx.stepDescription}).`,
|
||||
'',
|
||||
"Your job: help them complete this specific step and get back to setup.",
|
||||
"You can read files, run commands (with acceptEdits permissions), search the web,",
|
||||
"and explain concepts. Be concise. When they're ready to resume, tell them to type",
|
||||
"/exit and they'll return to the setup flow at the same step.",
|
||||
'Help me complete this specific step and get back to setup.',
|
||||
'You can read files, run commands, search the web,',
|
||||
"and explain concepts. Be concise. When I'm ready to resume, remind me to type",
|
||||
"/exit and I'll return to the setup flow at the same step.",
|
||||
'',
|
||||
];
|
||||
|
||||
if (ctx.completedSteps && ctx.completedSteps.length > 0) {
|
||||
lines.push('Steps they have already completed:');
|
||||
lines.push("Steps I've already completed:");
|
||||
for (const s of ctx.completedSteps) lines.push(` ✓ ${s}`);
|
||||
lines.push('');
|
||||
}
|
||||
@@ -243,8 +271,6 @@ async function offerFailureHandoff(
|
||||
);
|
||||
if (!want) return false;
|
||||
|
||||
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
|
||||
|
||||
note(
|
||||
[
|
||||
"Launching Claude to help debug this failure.",
|
||||
@@ -255,29 +281,10 @@ async function offerFailureHandoff(
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
p.log.error("Couldn't launch Claude. Continuing without handoff.");
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
return spawnInteractiveClaude(buildFailurePrompt(ctx, projectRoot));
|
||||
}
|
||||
|
||||
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
function buildFailurePrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||
const references = [
|
||||
...BIG_PICTURE_FILES,
|
||||
@@ -289,20 +296,20 @@ function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): stri
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
const lines: string[] = [
|
||||
"The user is running NanoClaw's interactive setup flow and hit a failure.",
|
||||
"I'm running NanoClaw's interactive setup flow and hit a failure.",
|
||||
'',
|
||||
`Failed step: ${ctx.stepName}`,
|
||||
`Error: ${ctx.msg}`,
|
||||
];
|
||||
|
||||
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
|
||||
if (ctx.hint) lines.push(`Hint shown to me: ${ctx.hint}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Your job: help them diagnose and fix this issue. Read the referenced files',
|
||||
'and logs to understand what went wrong, then help them fix it. You can read',
|
||||
'files, run commands, check logs, and explain what happened. Be concise.',
|
||||
"When they're ready to resume setup, tell them to type /exit.",
|
||||
'Help me diagnose and fix this issue. Read the referenced files and logs',
|
||||
'to understand what went wrong, then help me fix it. You can read files,',
|
||||
'run commands, check logs, and explain what happened. Be concise.',
|
||||
"When I'm ready to resume setup, remind me to type /exit.",
|
||||
'',
|
||||
'Relevant files (read as needed with the Read tool):',
|
||||
);
|
||||
|
||||
@@ -16,7 +16,13 @@ const INSTALL_ID_PATH = path.join('data', 'install-id');
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
export function installId(): string {
|
||||
/**
|
||||
* `persist: false` reads an existing id but never creates `data/install-id`
|
||||
* — required by the uninstall path, which must not mutate the filesystem
|
||||
* before (or instead of) removing it. Events in one process still join:
|
||||
* the generated id is cached.
|
||||
*/
|
||||
export function installId(persist = true): string {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const existing = fs.readFileSync(INSTALL_ID_PATH, 'utf-8').trim();
|
||||
@@ -28,11 +34,13 @@ export function installId(): string {
|
||||
// fall through to create
|
||||
}
|
||||
const id = randomUUID().toLowerCase();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true });
|
||||
fs.writeFileSync(INSTALL_ID_PATH, id);
|
||||
} catch {
|
||||
// best-effort; still return the id so the event fires
|
||||
if (persist) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true });
|
||||
fs.writeFileSync(INSTALL_ID_PATH, id);
|
||||
} catch {
|
||||
// best-effort; still return the id so the event fires
|
||||
}
|
||||
}
|
||||
cached = id;
|
||||
return id;
|
||||
@@ -41,6 +49,7 @@ export function installId(): string {
|
||||
export function emit(
|
||||
event: string,
|
||||
props: Record<string, string | number | boolean | undefined> = {},
|
||||
opts: { persistId?: boolean } = {},
|
||||
): void {
|
||||
if (process.env.NANOCLAW_NO_DIAGNOSTICS === '1') return;
|
||||
|
||||
@@ -53,7 +62,7 @@ export function emit(
|
||||
const body = JSON.stringify({
|
||||
api_key: POSTHOG_KEY,
|
||||
event,
|
||||
distinct_id: installId(),
|
||||
distinct_id: installId(opts.persistId !== false),
|
||||
properties: cleaned,
|
||||
});
|
||||
|
||||
|
||||
@@ -132,6 +132,32 @@ export const CONFIG: Entry[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
|
||||
// Uninstall route — handled in auto.ts before any setup work begins.
|
||||
{
|
||||
key: 'uninstall',
|
||||
label: 'Uninstall',
|
||||
help: 'Remove this NanoClaw copy (service, containers, data, vault agents). Asks per group.',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: 'dryRun',
|
||||
label: 'Uninstall dry run',
|
||||
help: 'With --uninstall: preview what would be removed without changing anything.',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: 'yes',
|
||||
label: 'Uninstall without prompts',
|
||||
help: 'With --uninstall: delete everything found without asking (orphan vault agents are still kept).',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── name derivation ───────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* versions.json is the machine-checkable source for sanctioned component
|
||||
* versions: setup steps read it, /update-nanoclaw diffs it across updates.
|
||||
* These tests go red if the file, the pin, or the onecli-step wiring is
|
||||
* deleted — the pin moving back to a hardcoded constant is the regression
|
||||
* this guards against.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { readVersionPin } from './version-pins.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('readVersionPin', () => {
|
||||
it('resolves the onecli-gateway pin from the real versions.json', () => {
|
||||
expect(readVersionPin('onecli-gateway')).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('resolves the onecli-cli pin from the real versions.json', () => {
|
||||
expect(readVersionPin('onecli-cli')).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('throws for a component with no pin', () => {
|
||||
expect(() => readVersionPin('no-such-component')).toThrow(/no pin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onecli step wiring', () => {
|
||||
it('reads its gateway pin from versions.json, not a hardcoded constant', () => {
|
||||
const source = fs.readFileSync(path.join(here, '..', 'onecli.ts'), 'utf-8');
|
||||
expect(source).toContain("readVersionPin('onecli-gateway')");
|
||||
expect(source).not.toMatch(/ONECLI_GATEWAY_VERSION = '\d/);
|
||||
});
|
||||
|
||||
it('reads its CLI pin from versions.json and never resolves "latest"', () => {
|
||||
const source = fs.readFileSync(path.join(here, '..', 'onecli.ts'), 'utf-8');
|
||||
expect(source).toContain("readVersionPin('onecli-cli')");
|
||||
expect(source).not.toMatch(/ONECLI_CLI(?:_FALLBACK)?_VERSION = '\d/);
|
||||
// The upstream installer and the /releases/latest redirect probe both
|
||||
// chase "latest" — reintroducing either bypasses the sanctioned pin.
|
||||
expect(source).not.toContain('onecli.sh/cli/install');
|
||||
expect(source).not.toContain('/releases/latest');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Sanctioned version pins for external components (`versions.json` at the
|
||||
* repo root) — the single machine-checkable source. Setup steps read their
|
||||
* pin here; `/update-nanoclaw` diffs the file across an update and routes
|
||||
* the user to the migration doc for any pin that moved (see CONTRIBUTING.md,
|
||||
* "Breaking changes").
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const VERSIONS_FILE = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'versions.json',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the pinned version for a component, e.g.
|
||||
* `readVersionPin('onecli-gateway')`. Throws when the file or the pin is
|
||||
* missing — a missing pin is an install-tree defect, not a runtime condition.
|
||||
*/
|
||||
export function readVersionPin(component: string): string {
|
||||
const pins: unknown = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8'));
|
||||
const value = (pins as Record<string, unknown>)[component];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`versions.json has no pin for "${component}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The step DETECTS gateway /v1 compatibility and warns (pointing at
|
||||
* docs/onecli-upgrades.md) — it does not migrate the gateway; that's the
|
||||
* agent's job via /update-nanoclaw. The verify helper must distinguish
|
||||
* incompatible (pre-/v1 server: warn) from unreachable (transient: nothing to
|
||||
* say) so the warning only fires on a real pre-/v1 server.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { verifyGatewayV1 } from './onecli.js';
|
||||
|
||||
function fakeFetch(behavior: 'ok' | '404' | 'down'): typeof fetch {
|
||||
return (async () => {
|
||||
if (behavior === 'down') throw new Error('ECONNREFUSED');
|
||||
return { ok: behavior === 'ok' } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('verifyGatewayV1', () => {
|
||||
it('ok when /v1/health answers', async () => {
|
||||
expect(await verifyGatewayV1('http://x', fakeFetch('ok'))).toBe('ok');
|
||||
});
|
||||
it('incompatible when the server answers HTTP without /v1', async () => {
|
||||
expect(await verifyGatewayV1('http://x', fakeFetch('404'))).toBe('incompatible');
|
||||
});
|
||||
it('unreachable on connection failure', async () => {
|
||||
expect(await verifyGatewayV1('http://x', fakeFetch('down'))).toBe('unreachable');
|
||||
});
|
||||
});
|
||||
+61
-54
@@ -17,6 +17,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { readVersionPin } from './lib/version-pins.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
|
||||
@@ -102,20 +103,18 @@ function writeEnvOnecliUrl(url: string): void {
|
||||
writeEnvVar('ONECLI_URL', url);
|
||||
}
|
||||
|
||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||
// and the redirect-based version probe fail. Bump deliberately when a
|
||||
// new CLI release ships.
|
||||
const ONECLI_GATEWAY_VERSION = '1.23.0';
|
||||
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
|
||||
// The SANCTIONED gateway version: fresh installs pin to it. Upgrading an
|
||||
// existing gateway is NOT done here — the gateway is a separate out-of-band
|
||||
// component, and the migrator is the user's coding agent following
|
||||
// docs/onecli-upgrades.md during /update-nanoclaw. The pin lives in
|
||||
// versions.json ("onecli-gateway") so that flow can diff it across updates and
|
||||
// route the agent to the doc; bump it there deliberately on a new release.
|
||||
const ONECLI_GATEWAY_VERSION = readVersionPin('onecli-gateway');
|
||||
// The CLI binary follows the same convention: installed at its pin
|
||||
// ("onecli-cli" in versions.json), never at whatever "latest" means today.
|
||||
const ONECLI_CLI_VERSION = readVersionPin('onecli-cli');
|
||||
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
|
||||
|
||||
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
|
||||
const fallback = installOnecliCliDirect();
|
||||
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
|
||||
}
|
||||
|
||||
// Remove containers in the "onecli" compose project whose service name isn't
|
||||
// in the v2 set. Pre-v2 OneCLI used service "app" (container onecli-app-1);
|
||||
// v2 uses "onecli". Compose flags the old container as an orphan but won't
|
||||
@@ -161,24 +160,10 @@ function installOnecli(): { stdout: string; ok: boolean } {
|
||||
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
|
||||
}
|
||||
|
||||
// CLI install. The upstream script calls the GitHub releases API
|
||||
// (api.github.com) to resolve the latest tag — which 403s anonymous
|
||||
// callers after 60 requests/hour per IP. Try upstream first; on failure
|
||||
// resolve the version ourselves (via HTTP redirect, which isn't
|
||||
// API-throttled) and download the release archive directly.
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
stdout += upstream.stdout;
|
||||
if (upstream.ok) return { stdout, ok: true };
|
||||
|
||||
log.warn('Upstream CLI installer failed — falling back to direct download', {
|
||||
stderr: upstream.stderr,
|
||||
});
|
||||
stdout += (upstream.stderr ?? '') + '\n';
|
||||
|
||||
const fallback = installOnecliCliDirect();
|
||||
stdout += fallback.stdout;
|
||||
if (!fallback.ok) {
|
||||
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
|
||||
const cli = installOnecliCliDirect();
|
||||
stdout += cli.stdout;
|
||||
if (!cli.ok) {
|
||||
log.error('OneCLI CLI install failed');
|
||||
return { stdout, ok: false };
|
||||
}
|
||||
return { stdout, ok: true };
|
||||
@@ -198,11 +183,11 @@ function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
|
||||
* releases API. Resolves the version via the HTTP redirect from
|
||||
* /releases/latest → /releases/tag/vX.Y.Z, then downloads the archive
|
||||
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
|
||||
* probe also fails.
|
||||
* Install the OneCLI CLI at the sanctioned pin by downloading the release
|
||||
* archive straight from GitHub. Deliberately no "latest" resolution — the
|
||||
* upstream installer script always chases the newest release, which would
|
||||
* drift from the pin. PATH setup is not lost by skipping it:
|
||||
* ensureShellProfilePath() in run() covers it.
|
||||
*/
|
||||
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
const lines: string[] = [];
|
||||
@@ -221,24 +206,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const redirect = execSync(
|
||||
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
const m = redirect.match(/\/tag\/v?([^/]+)$/);
|
||||
if (m) version = m[1];
|
||||
} catch {
|
||||
// redirect probe failed — we'll pin the fallback
|
||||
}
|
||||
if (!version) {
|
||||
version = ONECLI_CLI_FALLBACK_VERSION;
|
||||
append(`Version probe failed; installing pinned fallback ${version}.`);
|
||||
} else {
|
||||
append(`Resolved onecli CLI ${version} via release redirect.`);
|
||||
}
|
||||
|
||||
const version = ONECLI_CLI_VERSION;
|
||||
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
|
||||
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
|
||||
@@ -275,6 +243,39 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /v1 API compatibility check. @onecli-sh/sdk 2.x requires the server's /v1
|
||||
* API; servers older than the cutover answer 404 on every SDK call (permanent,
|
||||
* but presents as transient per-spawn failures). This is detect-only — setup
|
||||
* does not migrate the gateway. The upgrade is an out-of-band action on a
|
||||
* separate component that the agent runs via docs/onecli-upgrades.md during
|
||||
* /update-nanoclaw, so this step only surfaces the condition and points there.
|
||||
*/
|
||||
export async function verifyGatewayV1(
|
||||
url: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<'ok' | 'incompatible' | 'unreachable'> {
|
||||
try {
|
||||
const res = await fetchImpl(`${url}/v1/health`, { signal: AbortSignal.timeout(5000) });
|
||||
return res.ok ? 'ok' : 'incompatible';
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect-and-warn helper: returns a status HINT (and logs) when the gateway is
|
||||
* pre-/v1, else null. Never fails the step or auto-upgrades — the agent owns
|
||||
* the upgrade via docs/onecli-upgrades.md.
|
||||
*/
|
||||
function gatewayV1Hint(result: 'ok' | 'incompatible' | 'unreachable'): string | null {
|
||||
if (result !== 'incompatible') return null;
|
||||
log.warn('OneCLI gateway lacks the /v1 API @onecli-sh/sdk 2.x requires', {
|
||||
pin: ONECLI_GATEWAY_VERSION,
|
||||
});
|
||||
return 'OneCLI gateway lacks the /v1 API @onecli-sh/sdk 2.x requires — upgrade it: docs/onecli-upgrades.md';
|
||||
}
|
||||
|
||||
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -300,7 +301,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
// Remote-mode: install only the CLI, point it at the remote gateway, and
|
||||
// record the URL in .env. No local gateway is started.
|
||||
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
||||
const res = installOnecliCliOnly();
|
||||
const res = installOnecliCliDirect();
|
||||
if (!res.ok || !onecliVersion()) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
@@ -339,12 +340,14 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Wrote ONECLI_API_KEY to .env');
|
||||
}
|
||||
const healthy = await pollHealth(remoteUrl, 5000);
|
||||
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(remoteUrl)) : null;
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REMOTE: true,
|
||||
ONECLI_URL: remoteUrl,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
@@ -378,12 +381,14 @@ export async function run(args: string[]): Promise<void> {
|
||||
writeEnvOnecliUrl(url);
|
||||
log.info('Reusing existing OneCLI', { url });
|
||||
const healthy = await pollHealth(url, 5000);
|
||||
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(url)) : null;
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REUSED: true,
|
||||
ONECLI_URL: url,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
@@ -436,6 +441,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Wrote ONECLI_URL to .env', { url });
|
||||
|
||||
const healthy = await pollHealth(url, 15000);
|
||||
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(url)) : null;
|
||||
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
@@ -446,6 +452,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
// The next step (auth) will surface a genuinely broken gateway via
|
||||
// `onecli secrets list`, so don't trigger rescue attempts from here.
|
||||
STATUS: 'success',
|
||||
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
|
||||
...(healthy
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Uninstall flow — clack UI orchestration over scan/plan/remove.
|
||||
*
|
||||
* Self-deletion constraint: this flow runs on tsx out of the node_modules
|
||||
* it deletes. All imports are static (loaded before any deletion), dist/
|
||||
* and node_modules/ are removed last (the runtime tail), and once execution
|
||||
* starts nothing here writes to logs/ (which would recreate it) or does a
|
||||
* dynamic import. After the runtime tail, the only output is console.log.
|
||||
*
|
||||
* Removes ONLY what belongs to this checkout (per-checkout install slug).
|
||||
* Each non-empty group shows a WHAT/WHERE table and asks a default-No
|
||||
* confirm. Nothing is deleted until every decision has been made, so
|
||||
* Ctrl-C anywhere in the confirm phase leaves the install untouched.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { emit as phEmit } from '../lib/diagnostics.js';
|
||||
import { note } from '../lib/theme.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import {
|
||||
resolveOnecliDeletions,
|
||||
type RunCommand,
|
||||
type VaultAgent,
|
||||
} from './onecli-agents.js';
|
||||
import { buildRemovalPlan, type Decisions } from './plan.js';
|
||||
import { executePlan, type ExecDeps } from './remove.js';
|
||||
import { scanInstall, tilde, type Inventory } from './scan.js';
|
||||
|
||||
const GROUPS = {
|
||||
service: {
|
||||
title: '1) App & background service',
|
||||
desc: 'Runs NanoClaw in the background. Removing this stops the assistant. None of your data lives here.',
|
||||
prompt: 'Delete the app & background service shown above?',
|
||||
},
|
||||
data: {
|
||||
title: '2) App data, logs & secrets',
|
||||
desc: 'Message database, conversation history, logs, build files, and your .env (API keys / tokens). Removing this erases stored conversations and saved credentials.',
|
||||
prompt: 'Delete app data, logs & secrets shown above? (erases conversations + API keys)',
|
||||
},
|
||||
user: {
|
||||
title: "3) Your agents' memory & files",
|
||||
desc: 'Notes and memory your agents created (groups/) and any migrated data (store/). Content you made — it cannot be recovered after deletion.',
|
||||
prompt: "Delete your agents' memory & files shown above? (cannot be undone)",
|
||||
},
|
||||
onecli: {
|
||||
title: '4) OneCLI credential agents',
|
||||
desc: 'Per-agent entries this copy registered in the OneCLI vault. The OneCLI app, your credentials, and the gateway are NOT touched.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const runCommand: RunCommand = (cmd, args) => {
|
||||
const res = spawnSync(cmd, args, { encoding: 'utf-8' });
|
||||
return { status: res.status, stdout: res.stdout ?? '' };
|
||||
};
|
||||
|
||||
export async function runUninstallFlow(opts: {
|
||||
dryRun: boolean;
|
||||
yes: boolean;
|
||||
invokedFrom: 'flag' | 'setup-detection';
|
||||
}): Promise<never> {
|
||||
const { dryRun, yes } = opts;
|
||||
|
||||
if (!process.stdin.isTTY && !yes && !dryRun) {
|
||||
console.error(
|
||||
'Uninstall needs an interactive terminal. Re-run with --yes to delete everything found without prompts, or --dry-run to preview.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const home = os.homedir();
|
||||
|
||||
p.intro(k.bold(`Uninstall NanoClaw`));
|
||||
// persistId: false — the emit must not create data/install-id, which would
|
||||
// both break --dry-run's "changes nothing" promise and resurrect a data/
|
||||
// row in the very inventory we are about to scan.
|
||||
phEmit('uninstall_started', { invokedFrom: opts.invokedFrom, dryRun, yes }, { persistId: false });
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Checking what exists for this copy…');
|
||||
const inv = scanInstall({
|
||||
projectRoot,
|
||||
home,
|
||||
platform: process.platform,
|
||||
runCommand,
|
||||
});
|
||||
spinner.stop(`Scanned copy ${inv.slug} at ${tilde(projectRoot, home)}.`);
|
||||
|
||||
const svcRows = serviceRows(inv, home);
|
||||
const dataRows = [...inv.data, ...inv.runtime].map(({ what, where }) => ({ what, where }));
|
||||
const userRows = inv.user.map(({ what, where }) => ({ what, where }));
|
||||
const totalFound =
|
||||
svcRows.length +
|
||||
dataRows.length +
|
||||
userRows.length +
|
||||
inv.onecli.mine.length +
|
||||
inv.onecli.orphans.length;
|
||||
|
||||
if (totalFound === 0) {
|
||||
p.outro(
|
||||
`✓ Nothing to uninstall — this copy (${inv.slug}) is already clean.\n` +
|
||||
k.dim(' (No service, containers, image, data, or OneCLI agents found for this folder.)'),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
p.log.message(
|
||||
k.cyan('PREVIEW ONLY — this shows what would be deleted and changes nothing.'),
|
||||
);
|
||||
if (svcRows.length > 0) note(groupBody(GROUPS.service.desc, svcRows), GROUPS.service.title);
|
||||
if (dataRows.length > 0) note(groupBody(GROUPS.data.desc, dataRows), GROUPS.data.title);
|
||||
if (userRows.length > 0) note(groupBody(GROUPS.user.desc, userRows), GROUPS.user.title);
|
||||
if (inv.onecli.mine.length > 0 || inv.onecli.orphans.length > 0) {
|
||||
const lines = [GROUPS.onecli.desc, ''];
|
||||
lines.push('Would be deleted (after confirmation):');
|
||||
for (const a of inv.onecli.mine) lines.push(` ● ${a.name} — ${a.identifier}`);
|
||||
if (inv.onecli.mine.length === 0) lines.push(' (none)');
|
||||
lines.push('Left in place — may belong to another copy:');
|
||||
for (const a of inv.onecli.orphans) lines.push(` ○ ${a.name} — ${a.identifier}`);
|
||||
if (inv.onecli.orphans.length === 0) lines.push(' (none)');
|
||||
note(lines.join('\n'), GROUPS.onecli.title);
|
||||
}
|
||||
const empty = emptyGroupTitles(svcRows.length, dataRows.length, userRows.length, inv);
|
||||
if (empty.length > 0) p.log.message(k.dim(`Nothing found for: ${empty.join(', ')}`));
|
||||
for (const n of inv.notes) p.log.message(k.dim(`• ${n}`));
|
||||
p.outro('Preview complete. Nothing was changed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (yes) {
|
||||
p.log.warn('--yes given: deleting everything found below without asking.');
|
||||
} else {
|
||||
p.log.message(
|
||||
k.dim(
|
||||
'You will be asked about each group that has something. Default is to keep\n(just press Enter). Type "y" to delete a group.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── confirm phase — nothing is deleted until every decision is made ──
|
||||
|
||||
let serviceYes = false;
|
||||
if (svcRows.length > 0) {
|
||||
note(groupBody(GROUPS.service.desc, svcRows), GROUPS.service.title);
|
||||
serviceYes = await confirmGroup(GROUPS.service.prompt, yes);
|
||||
}
|
||||
|
||||
let dataYes = false;
|
||||
if (dataRows.length > 0) {
|
||||
note(groupBody(GROUPS.data.desc, dataRows), GROUPS.data.title);
|
||||
dataYes = await confirmGroup(GROUPS.data.prompt, yes);
|
||||
}
|
||||
|
||||
let userYes = false;
|
||||
if (userRows.length > 0) {
|
||||
note(groupBody(GROUPS.user.desc, userRows), GROUPS.user.title);
|
||||
userYes = await confirmGroup(GROUPS.user.prompt, yes);
|
||||
}
|
||||
|
||||
const keptNotes: string[] = [];
|
||||
if (!serviceYes && svcRows.length > 0) keptNotes.push(`${GROUPS.service.title}: kept by your choice.`);
|
||||
if (!dataYes && dataRows.length > 0) keptNotes.push(`${GROUPS.data.title}: kept by your choice.`);
|
||||
if (!userYes && userRows.length > 0) keptNotes.push(`${GROUPS.user.title}: kept by your choice.`);
|
||||
|
||||
const onecliDelete = await decideOnecli(inv, yes, keptNotes);
|
||||
|
||||
// Record the decisions before execution can delete logs/ — but only into
|
||||
// an existing logs/ (userInput would otherwise mkdir it back into
|
||||
// existence, leaving a fresh logs/setup.log behind after the uninstall).
|
||||
if (fs.existsSync(path.join(projectRoot, 'logs'))) {
|
||||
setupLog.userInput(
|
||||
'uninstall_decisions',
|
||||
JSON.stringify({
|
||||
service: serviceYes,
|
||||
data: dataYes,
|
||||
user: userYes,
|
||||
onecliAgentsDeleted: onecliDelete.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const decisions: Decisions = {
|
||||
service: serviceYes,
|
||||
data: dataYes,
|
||||
user: userYes,
|
||||
onecliDelete,
|
||||
};
|
||||
const actions = buildRemovalPlan(inv, decisions);
|
||||
|
||||
if (actions.length === 0) {
|
||||
printLeftAlone([...inv.notes, ...keptNotes]);
|
||||
p.outro('Nothing selected — nothing was changed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
phEmit(
|
||||
'uninstall_executed',
|
||||
{
|
||||
invokedFrom: opts.invokedFrom,
|
||||
service: serviceYes,
|
||||
data: dataYes,
|
||||
user: userYes,
|
||||
onecliAgentsDeleted: onecliDelete.length,
|
||||
},
|
||||
{ persistId: false },
|
||||
);
|
||||
|
||||
// The runtime tail (dist/, node_modules/) runs after every other action
|
||||
// AND after the summary — nothing but console.log may happen once the
|
||||
// modules we're running from are gone.
|
||||
const head = actions.filter((a) => a.kind !== 'delete-runtime-path');
|
||||
const tail = actions.filter((a) => a.kind === 'delete-runtime-path');
|
||||
|
||||
const deps: ExecDeps = {
|
||||
runCommand,
|
||||
log: (line) => p.log.message(line),
|
||||
isRoot: process.getuid?.() === 0,
|
||||
};
|
||||
const { notes: execNotes } = executePlan(head, deps);
|
||||
|
||||
printLeftAlone([...inv.notes, ...keptNotes, ...execNotes]);
|
||||
|
||||
const { notes: tailNotes } = executePlan(tail, {
|
||||
...deps,
|
||||
log: (line) => console.log(` ${line}`),
|
||||
});
|
||||
for (const n of tailNotes) console.log(` • ${n}`);
|
||||
console.log(`\n✓ Done. NanoClaw copy ${inv.slug} has been uninstalled.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/** Unwrap a confirm result; Ctrl-C / Esc cancels the whole uninstall — nothing deleted. */
|
||||
function answered<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel('Uninstall cancelled. Nothing was deleted.');
|
||||
process.exit(0);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
async function confirmGroup(prompt: string, yes: boolean): Promise<boolean> {
|
||||
if (yes) return true;
|
||||
return answered(await p.confirm({ message: prompt, initialValue: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group 4 has two sub-decisions the single-prompt loop can't express:
|
||||
* MINE is one yes/no; ORPHANS get a separate default-No prompt with an
|
||||
* explicit cross-copy warning. --yes deletes MINE but never ORPHANS
|
||||
* (enforced in resolveOnecliDeletions); anything kept is reported with
|
||||
* the exact manual delete command (by vault uuid).
|
||||
*/
|
||||
async function decideOnecli(
|
||||
inv: Inventory,
|
||||
yes: boolean,
|
||||
keptNotes: string[],
|
||||
): Promise<VaultAgent[]> {
|
||||
const { mine, orphans } = inv.onecli;
|
||||
if (mine.length === 0 && orphans.length === 0) return [];
|
||||
|
||||
const rows = [
|
||||
...mine.map((a) => ({ what: 'OneCLI agent', where: `${a.name} — ${a.identifier}` })),
|
||||
...orphans.map((a) => ({ what: 'OneCLI agent (orphan)', where: `${a.name} — ${a.identifier}` })),
|
||||
];
|
||||
note(groupBody(GROUPS.onecli.desc, rows), GROUPS.onecli.title);
|
||||
|
||||
let deleteMine = false;
|
||||
if (mine.length > 0 && !yes) {
|
||||
deleteMine = answered(
|
||||
await p.confirm({
|
||||
message: `Delete this copy's ${mine.length} OneCLI agent(s)?`,
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!deleteMine) keptNotes.push('OneCLI agents (this copy): kept by your choice.');
|
||||
}
|
||||
|
||||
let deleteOrphans = false;
|
||||
if (orphans.length > 0) {
|
||||
if (yes) {
|
||||
p.log.warn(
|
||||
`${orphans.length} other NanoClaw-style agent(s) in the vault are not linked to this copy;\n--yes does NOT delete them (they may belong to another copy).`,
|
||||
);
|
||||
} else {
|
||||
p.log.warn(
|
||||
`Found ${orphans.length} other NanoClaw-style agent(s) in the vault not linked to this copy —\nthey may belong to ANOTHER NanoClaw copy on this machine.`,
|
||||
);
|
||||
deleteOrphans = answered(
|
||||
await p.confirm({ message: 'Delete them too?', initialValue: false }),
|
||||
);
|
||||
}
|
||||
if (yes || !deleteOrphans) {
|
||||
keptNotes.push(
|
||||
`OneCLI orphan agents (${orphans.length}): left in place — remove manually if they're yours:`,
|
||||
);
|
||||
for (const a of orphans) {
|
||||
keptNotes.push(` onecli agents delete --id ${a.uuid} # ${a.name} — ${a.identifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: yes,
|
||||
deleteMine,
|
||||
deleteOrphans,
|
||||
});
|
||||
}
|
||||
|
||||
function serviceRows(inv: Inventory, home: string): { what: string; where: string }[] {
|
||||
const s = inv.service;
|
||||
const rows: { what: string; where: string }[] = [];
|
||||
if (s.launchdPlist) rows.push({ what: 'Background service', where: tilde(s.launchdPlist, home) });
|
||||
if (s.systemdUserUnit) rows.push({ what: 'Background service', where: tilde(s.systemdUserUnit, home) });
|
||||
if (s.systemdSystemUnit) rows.push({ what: 'Background service (system)', where: s.systemdSystemUnit });
|
||||
if (s.pidFile) rows.push({ what: 'Running process', where: 'nanoclaw.pid' });
|
||||
if (s.containerIds.length > 0) {
|
||||
rows.push({ what: 'Running containers', where: `${s.containerIds.length} container(s)` });
|
||||
}
|
||||
if (s.image) rows.push({ what: 'Container image', where: s.image });
|
||||
if (s.nclSymlink) rows.push({ what: 'Command-line tool (ncl)', where: tilde(s.nclSymlink, home) });
|
||||
return rows;
|
||||
}
|
||||
|
||||
function groupBody(desc: string, rows: { what: string; where: string }[]): string {
|
||||
const width = Math.max(...rows.map((r) => r.what.length), 'WHAT'.length);
|
||||
const lines = [desc, '', `${'WHAT'.padEnd(width + 2)}WHERE`];
|
||||
for (const r of rows) lines.push(`${r.what.padEnd(width + 2)}${r.where}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function emptyGroupTitles(
|
||||
svcCount: number,
|
||||
dataCount: number,
|
||||
userCount: number,
|
||||
inv: Inventory,
|
||||
): string[] {
|
||||
const empty: string[] = [];
|
||||
if (svcCount === 0) empty.push(GROUPS.service.title);
|
||||
if (dataCount === 0) empty.push(GROUPS.data.title);
|
||||
if (userCount === 0) empty.push(GROUPS.user.title);
|
||||
if (inv.onecli.mine.length === 0 && inv.onecli.orphans.length === 0) {
|
||||
empty.push(GROUPS.onecli.title);
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
function printLeftAlone(notes: string[]): void {
|
||||
const lines = [
|
||||
'• OneCLI app, vault & credentials: ~/.local/share/onecli, ~/.local/bin/onecli',
|
||||
'• Host-wide config: ~/.config/nanoclaw/ (mount/sender allowlists)',
|
||||
'• PATH line in ~/.bashrc and ~/.zshrc',
|
||||
'• Other NanoClaw copies on this machine',
|
||||
...notes.map((n) => `• ${n}`),
|
||||
];
|
||||
note(lines.join('\n'), 'Left alone (shared / not ours)');
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import {
|
||||
listVaultAgents,
|
||||
readAgentGroupIds,
|
||||
resolveOnecliDeletions,
|
||||
splitVaultAgents,
|
||||
type VaultAgent,
|
||||
} from './onecli-agents.js';
|
||||
|
||||
const agent = (uuid: string, identifier: string, name = identifier): VaultAgent => ({
|
||||
uuid,
|
||||
identifier,
|
||||
name,
|
||||
});
|
||||
|
||||
describe('listVaultAgents', () => {
|
||||
it('parses non-default agents from onecli JSON output', () => {
|
||||
const payload = JSON.stringify({
|
||||
data: [
|
||||
{ id: 'u-1', identifier: 'ag-main', name: 'Main', isDefault: false },
|
||||
{ id: 'u-2', identifier: 'default', name: 'Default', isDefault: false },
|
||||
{ id: 'u-3', identifier: 'ag-dev', name: 'Dev', isDefault: true },
|
||||
],
|
||||
});
|
||||
const result = listVaultAgents(() => ({ status: 0, stdout: payload }));
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.agents).toEqual([agent('u-1', 'ag-main', 'Main')]);
|
||||
});
|
||||
|
||||
it('reports unavailable when the command fails', () => {
|
||||
expect(listVaultAgents(() => ({ status: 1, stdout: '' })).available).toBe(false);
|
||||
});
|
||||
|
||||
it('reports unavailable when the command cannot be spawned', () => {
|
||||
const result = listVaultAgents(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports unavailable on unparseable output', () => {
|
||||
expect(listVaultAgents(() => ({ status: 0, stdout: 'not json' })).available).toBe(false);
|
||||
expect(listVaultAgents(() => ({ status: 0, stdout: '{"nope":1}' })).available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAgentGroupIds', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-uninstall-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads ids from a real DB', () => {
|
||||
const dbPath = path.join(tempDir, 'v2.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec('CREATE TABLE agent_groups (id TEXT PRIMARY KEY)');
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-one');
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-two');
|
||||
db.close();
|
||||
|
||||
const result = readAgentGroupIds(dbPath);
|
||||
expect(result.known).toBe(true);
|
||||
expect(result.ids).toEqual(new Set(['ag-one', 'ag-two']));
|
||||
});
|
||||
|
||||
it('returns known:false for a missing file', () => {
|
||||
const result = readAgentGroupIds(path.join(tempDir, 'missing.db'));
|
||||
expect(result.known).toBe(false);
|
||||
expect(result.ids.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns known:false for a corrupt file', () => {
|
||||
const dbPath = path.join(tempDir, 'corrupt.db');
|
||||
fs.writeFileSync(dbPath, 'this is not a sqlite database at all');
|
||||
const result = readAgentGroupIds(dbPath);
|
||||
expect(result.known).toBe(false);
|
||||
expect(result.ids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitVaultAgents', () => {
|
||||
it('splits mine vs ag-* orphans and ignores foreign identifiers', () => {
|
||||
const agents = [
|
||||
agent('u-1', 'ag-mine'),
|
||||
agent('u-2', 'ag-other'),
|
||||
agent('u-3', 'some-tool'),
|
||||
];
|
||||
const { mine, orphans } = splitVaultAgents(agents, new Set(['ag-mine']), true);
|
||||
expect(mine).toEqual([agent('u-1', 'ag-mine')]);
|
||||
expect(orphans).toEqual([agent('u-2', 'ag-other')]);
|
||||
});
|
||||
|
||||
it('forces all ag-* agents into orphans when ids are unknown', () => {
|
||||
const agents = [agent('u-1', 'ag-mine'), agent('u-2', 'ag-other')];
|
||||
// ids set even contains ag-mine — known:false must override.
|
||||
const { mine, orphans } = splitVaultAgents(agents, new Set(['ag-mine']), false);
|
||||
expect(mine).toEqual([]);
|
||||
expect(orphans).toEqual(agents);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOnecliDeletions', () => {
|
||||
const mine = [agent('u-1', 'ag-mine')];
|
||||
const orphans = [agent('u-2', 'ag-other')];
|
||||
|
||||
it('never deletes orphans under --yes, even if asked to', () => {
|
||||
const deletions = resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: true,
|
||||
deleteMine: false,
|
||||
deleteOrphans: true,
|
||||
});
|
||||
expect(deletions).toEqual(mine);
|
||||
});
|
||||
|
||||
it('deletes orphans only on explicit interactive consent', () => {
|
||||
expect(
|
||||
resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: false,
|
||||
deleteMine: true,
|
||||
deleteOrphans: true,
|
||||
}),
|
||||
).toEqual([...mine, ...orphans]);
|
||||
|
||||
expect(
|
||||
resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: false,
|
||||
deleteMine: false,
|
||||
deleteOrphans: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* OneCLI vault-agent inventory for the uninstaller.
|
||||
*
|
||||
* Vault agents split into two sets: MINE (identifier matches an agent-group
|
||||
* id in this copy's data/v2.db) and ORPHANS (NanoClaw-style `ag-*`
|
||||
* identifiers not in our DB — possibly another copy's). Deletion is always
|
||||
* by the vault's internal uuid: the agent-group id is NOT a valid
|
||||
* `onecli agents delete --id` value (see src/container-runner.ts).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface VaultAgent {
|
||||
/** Internal vault uuid — the only valid `onecli agents delete --id` value. */
|
||||
uuid: string;
|
||||
/** What the agent was registered under, e.g. a NanoClaw agent-group id (`ag-*`). */
|
||||
identifier: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RunCommand = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
) => { status: number | null; stdout: string };
|
||||
|
||||
/**
|
||||
* List non-default vault agents via `onecli agents list`. `available: false`
|
||||
* means the vault couldn't be read at all (binary missing, command failed,
|
||||
* or unparseable output) — distinct from an empty vault.
|
||||
*/
|
||||
export function listVaultAgents(run: RunCommand): {
|
||||
available: boolean;
|
||||
agents: VaultAgent[];
|
||||
} {
|
||||
let result: { status: number | null; stdout: string };
|
||||
try {
|
||||
result = run('onecli', ['agents', 'list']);
|
||||
} catch {
|
||||
return { available: false, agents: [] };
|
||||
}
|
||||
if (result.status !== 0) return { available: false, agents: [] };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
return { available: false, agents: [] };
|
||||
}
|
||||
|
||||
const data =
|
||||
parsed !== null && typeof parsed === 'object' && 'data' in parsed
|
||||
? (parsed as { data: unknown }).data
|
||||
: null;
|
||||
if (!Array.isArray(data)) return { available: false, agents: [] };
|
||||
|
||||
const agents: VaultAgent[] = [];
|
||||
for (const entry of data) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const a = entry as Record<string, unknown>;
|
||||
if (a.isDefault === true) continue;
|
||||
const identifier = typeof a.identifier === 'string' ? a.identifier : '';
|
||||
const uuid = typeof a.id === 'string' ? a.id : '';
|
||||
if (!identifier || identifier === 'default' || !uuid) continue;
|
||||
agents.push({
|
||||
uuid,
|
||||
identifier,
|
||||
name: typeof a.name === 'string' ? a.name : '',
|
||||
});
|
||||
}
|
||||
return { available: true, agents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this copy's agent-group ids from data/v2.db (readonly).
|
||||
*
|
||||
* `known: false` distinguishes "we couldn't read the DB at all" from "this
|
||||
* copy has zero agent groups" — without it every ag-* vault agent would be
|
||||
* mislabeled an orphan and --yes would silently leave this copy's agents
|
||||
* behind.
|
||||
*/
|
||||
export function readAgentGroupIds(dbPath: string): {
|
||||
ids: Set<string>;
|
||||
known: boolean;
|
||||
} {
|
||||
if (!fs.existsSync(dbPath)) return { ids: new Set(), known: false };
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare('SELECT id FROM agent_groups').all() as {
|
||||
id: string;
|
||||
}[];
|
||||
return { ids: new Set(rows.map((r) => r.id)), known: true };
|
||||
} catch {
|
||||
return { ids: new Set(), known: false };
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split vault agents into MINE (identifier ∈ ids) and ORPHANS (ag-* not in
|
||||
* ids). Non-NanoClaw identifiers are ignored entirely. With `known: false`
|
||||
* nothing can be MINE, so every ag-* agent lands in ORPHANS — the caller is
|
||||
* responsible for warning that the labels are unreliable.
|
||||
*/
|
||||
export function splitVaultAgents(
|
||||
agents: VaultAgent[],
|
||||
ids: Set<string>,
|
||||
known: boolean,
|
||||
): { mine: VaultAgent[]; orphans: VaultAgent[] } {
|
||||
const mine: VaultAgent[] = [];
|
||||
const orphans: VaultAgent[] = [];
|
||||
for (const agent of agents) {
|
||||
if (known && ids.has(agent.identifier)) {
|
||||
mine.push(agent);
|
||||
} else if (agent.identifier.startsWith('ag-')) {
|
||||
orphans.push(agent);
|
||||
}
|
||||
}
|
||||
return { mine, orphans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the vault-agent delete set from the user's answers. Under --yes
|
||||
* (`assumeYes`) MINE is always deleted but ORPHANS never are — deleting
|
||||
* what may be another copy's agents requires explicit human intent.
|
||||
*/
|
||||
export function resolveOnecliDeletions(input: {
|
||||
mine: VaultAgent[];
|
||||
orphans: VaultAgent[];
|
||||
assumeYes: boolean;
|
||||
deleteMine: boolean;
|
||||
deleteOrphans: boolean;
|
||||
}): VaultAgent[] {
|
||||
const out: VaultAgent[] = [];
|
||||
if (input.assumeYes || input.deleteMine) out.push(...input.mine);
|
||||
if (!input.assumeYes && input.deleteOrphans) out.push(...input.orphans);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { VaultAgent } from './onecli-agents.js';
|
||||
import { buildRemovalPlan, type Decisions, type RemovalAction } from './plan.js';
|
||||
import type { Inventory, PathItem } from './scan.js';
|
||||
|
||||
const item = (p: string, what: string): PathItem => ({ what, where: p, path: p });
|
||||
|
||||
const agent = (uuid: string, identifier: string): VaultAgent => ({
|
||||
uuid,
|
||||
identifier,
|
||||
name: identifier,
|
||||
});
|
||||
|
||||
function inventory(overrides: Partial<Inventory> = {}): Inventory {
|
||||
return {
|
||||
slug: 'abcd1234',
|
||||
projectRoot: '/proj',
|
||||
containerRuntime: 'docker',
|
||||
service: {
|
||||
launchdPlist: '/home/u/Library/LaunchAgents/com.nanoclaw-v2-abcd1234.plist',
|
||||
containerIds: ['c1', 'c2'],
|
||||
image: 'nanoclaw-agent-v2-abcd1234:latest',
|
||||
nclSymlink: '/home/u/.local/bin/ncl',
|
||||
},
|
||||
data: [
|
||||
item('/proj/data', 'Database & conversations'),
|
||||
item('/proj/logs', 'Logs'),
|
||||
item('/proj/.env', 'Secrets / API keys (.env)'),
|
||||
item('/proj/start-nanoclaw.sh', 'Start script'),
|
||||
],
|
||||
runtime: [
|
||||
// node_modules deliberately FIRST — the planner must still order it last.
|
||||
item('/proj/node_modules', 'Installed dependencies'),
|
||||
item('/proj/dist', 'Build output'),
|
||||
],
|
||||
user: [item('/proj/groups', 'Agent memory & files'), item('/proj/store', 'Migrated data store')],
|
||||
onecli: { mine: [], orphans: [], idsKnown: true },
|
||||
notes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const allYes = (onecliDelete: VaultAgent[] = []): Decisions => ({
|
||||
service: true,
|
||||
data: true,
|
||||
user: true,
|
||||
onecliDelete,
|
||||
});
|
||||
|
||||
const kinds = (actions: RemovalAction[]) => actions.map((a) => a.kind);
|
||||
|
||||
describe('buildRemovalPlan ordering invariants', () => {
|
||||
it('removes .env only via the atomic backup action, never a bare delete', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes());
|
||||
expect(actions.filter((a) => a.kind === 'backup-env')).toHaveLength(1);
|
||||
expect(
|
||||
actions.some((a) => a.kind === 'delete-path' && a.item.path === '/proj/.env'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('puts the runtime tail strictly last, with node_modules final', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes([agent('u-1', 'ag-mine')]));
|
||||
const tail = actions.slice(-2);
|
||||
expect(tail.map((a) => a.kind)).toEqual(['delete-runtime-path', 'delete-runtime-path']);
|
||||
expect(tail.map((a) => (a.kind === 'delete-runtime-path' ? a.item.path : ''))).toEqual([
|
||||
'/proj/dist',
|
||||
'/proj/node_modules',
|
||||
]);
|
||||
// No non-tail action after the first runtime delete.
|
||||
const firstTailIdx = actions.findIndex((a) => a.kind === 'delete-runtime-path');
|
||||
expect(
|
||||
actions.slice(firstTailIdx).every((a) => a.kind === 'delete-runtime-path'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes OneCLI agents before the data group (which removes data/v2.db)', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes([agent('u-1', 'ag-mine')]));
|
||||
const onecliIdx = actions.findIndex((a) => a.kind === 'delete-onecli-agent');
|
||||
const dataIdx = actions.findIndex(
|
||||
(a) => a.kind === 'delete-path' && a.item.path === '/proj/data',
|
||||
);
|
||||
expect(onecliIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(dataIdx).toBeGreaterThan(onecliIdx);
|
||||
});
|
||||
|
||||
it('runs service teardown before container removal so the host cannot respawn them', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes());
|
||||
const unloadIdx = actions.findIndex((a) => a.kind === 'unload-service');
|
||||
const pkillIdx = actions.findIndex((a) => a.kind === 'pkill-host');
|
||||
const rmContainersIdx = actions.findIndex((a) => a.kind === 'rm-containers');
|
||||
expect(unloadIdx).toBeLessThan(rmContainersIdx);
|
||||
expect(pkillIdx).toBeLessThan(rmContainersIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemovalPlan declined groups', () => {
|
||||
it('declined data yields no data deletes and no runtime tail', () => {
|
||||
const actions = buildRemovalPlan(inventory(), {
|
||||
service: true,
|
||||
data: false,
|
||||
user: true,
|
||||
onecliDelete: [],
|
||||
});
|
||||
expect(kinds(actions)).not.toContain('backup-env');
|
||||
expect(kinds(actions)).not.toContain('delete-runtime-path');
|
||||
expect(
|
||||
actions.some((a) => a.kind === 'delete-path' && a.item.path.startsWith('/proj/data')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('all declined yields an empty plan', () => {
|
||||
const actions = buildRemovalPlan(inventory(), {
|
||||
service: false,
|
||||
data: false,
|
||||
user: false,
|
||||
onecliDelete: [],
|
||||
});
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('declined service yields no service actions', () => {
|
||||
const actions = buildRemovalPlan(inventory(), {
|
||||
service: false,
|
||||
data: true,
|
||||
user: false,
|
||||
onecliDelete: [],
|
||||
});
|
||||
for (const kind of ['unload-service', 'pkill-host', 'rm-containers', 'rmi', 'rm-ncl-symlink']) {
|
||||
expect(kinds(actions)).not.toContain(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemovalPlan conditional actions', () => {
|
||||
it('skips backup-env when there is no .env', () => {
|
||||
const inv = inventory({ data: [item('/proj/data', 'Database & conversations')] });
|
||||
expect(kinds(buildRemovalPlan(inv, allYes()))).not.toContain('backup-env');
|
||||
});
|
||||
|
||||
it('always re-sweeps containers and processes with a confirmed service group', () => {
|
||||
const inv = inventory({ service: { containerIds: [] } });
|
||||
const actions = buildRemovalPlan(inv, allYes());
|
||||
const actionKinds = kinds(actions);
|
||||
expect(actionKinds).not.toContain('rmi');
|
||||
expect(actionKinds).not.toContain('unload-service');
|
||||
// pkill and rm-containers run unconditionally — a manually started host
|
||||
// has no plist/unit, and the live host may have spawned containers the
|
||||
// scan never saw. Removal re-lists by install label, not scan-time ids.
|
||||
expect(actionKinds).toContain('pkill-host');
|
||||
const rm = actions.find((a) => a.kind === 'rm-containers');
|
||||
expect(rm && rm.kind === 'rm-containers' ? rm.labelFilter : '').toBe(
|
||||
'nanoclaw-install=abcd1234',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Pure removal planner: inventory + per-group decisions → ordered actions.
|
||||
*
|
||||
* The order is load-bearing:
|
||||
* 1. Service / processes / containers / image / symlink — stop the host
|
||||
* first so it can't respawn containers mid-removal.
|
||||
* 2. OneCLI agent deletions — before the data group, which removes the
|
||||
* data/v2.db the mine/orphan split was computed from.
|
||||
* 3. Data group, with the .env backup strictly before its deletion.
|
||||
* 4. User group (groups/, store/).
|
||||
* 5. Runtime tail: dist/ then node_modules/ — ALWAYS last. The uninstaller
|
||||
* runs on tsx out of node_modules; nothing may load after this.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import type { VaultAgent } from './onecli-agents.js';
|
||||
import type { Inventory, PathItem } from './scan.js';
|
||||
|
||||
export interface Decisions {
|
||||
service: boolean;
|
||||
data: boolean;
|
||||
user: boolean;
|
||||
onecliDelete: VaultAgent[];
|
||||
}
|
||||
|
||||
export type RemovalAction =
|
||||
| {
|
||||
kind: 'unload-service';
|
||||
flavor: 'launchd' | 'systemd-user' | 'systemd-system';
|
||||
unitPath: string;
|
||||
/** systemd unit name without .service (unused for launchd). */
|
||||
unitName: string;
|
||||
}
|
||||
| { kind: 'kill-pid'; pidFile: string }
|
||||
| { kind: 'pkill-host'; pattern: string }
|
||||
/**
|
||||
* Containers are re-listed by label at removal time, not removed from
|
||||
* scan-time ids — the host stays alive through the whole confirm phase
|
||||
* and can spawn new containers after the scan.
|
||||
*/
|
||||
| { kind: 'rm-containers'; runtime: string; labelFilter: string }
|
||||
| { kind: 'rmi'; runtime: string; image: string }
|
||||
| { kind: 'rm-ncl-symlink'; linkPath: string }
|
||||
| { kind: 'delete-onecli-agent'; agent: VaultAgent }
|
||||
/**
|
||||
* Backs up AND removes .env as one atomic action: a failed backup must
|
||||
* never be followed by the deletion (the backup is the user's only copy
|
||||
* of their API keys). .env is deliberately excluded from `delete-path`.
|
||||
*/
|
||||
| { kind: 'backup-env'; envPath: string }
|
||||
| { kind: 'delete-path'; item: PathItem }
|
||||
| { kind: 'delete-runtime-path'; item: PathItem };
|
||||
|
||||
export function buildRemovalPlan(inv: Inventory, d: Decisions): RemovalAction[] {
|
||||
const actions: RemovalAction[] = [];
|
||||
|
||||
if (d.service) {
|
||||
const s = inv.service;
|
||||
if (s.launchdPlist) {
|
||||
actions.push({
|
||||
kind: 'unload-service',
|
||||
flavor: 'launchd',
|
||||
unitPath: s.launchdPlist,
|
||||
unitName: path.basename(s.launchdPlist, '.plist'),
|
||||
});
|
||||
}
|
||||
if (s.systemdUserUnit) {
|
||||
actions.push({
|
||||
kind: 'unload-service',
|
||||
flavor: 'systemd-user',
|
||||
unitPath: s.systemdUserUnit,
|
||||
unitName: path.basename(s.systemdUserUnit, '.service'),
|
||||
});
|
||||
}
|
||||
if (s.systemdSystemUnit) {
|
||||
actions.push({
|
||||
kind: 'unload-service',
|
||||
flavor: 'systemd-system',
|
||||
unitPath: s.systemdSystemUnit,
|
||||
unitName: path.basename(s.systemdSystemUnit, '.service'),
|
||||
});
|
||||
}
|
||||
if (s.pidFile) actions.push({ kind: 'kill-pid', pidFile: s.pidFile });
|
||||
actions.push({
|
||||
kind: 'pkill-host',
|
||||
pattern: `${inv.projectRoot}/dist/index.js`,
|
||||
});
|
||||
// Unconditional (like pkill): the scan may have found zero containers
|
||||
// while the still-running host spawned one since.
|
||||
actions.push({
|
||||
kind: 'rm-containers',
|
||||
runtime: inv.containerRuntime,
|
||||
labelFilter: `nanoclaw-install=${inv.slug}`,
|
||||
});
|
||||
if (s.image) {
|
||||
actions.push({ kind: 'rmi', runtime: inv.containerRuntime, image: s.image });
|
||||
}
|
||||
if (s.nclSymlink) {
|
||||
actions.push({ kind: 'rm-ncl-symlink', linkPath: s.nclSymlink });
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of d.onecliDelete) {
|
||||
actions.push({ kind: 'delete-onecli-agent', agent });
|
||||
}
|
||||
|
||||
if (d.data) {
|
||||
const env = inv.data.find((i) => path.basename(i.path) === '.env');
|
||||
if (env) actions.push({ kind: 'backup-env', envPath: env.path });
|
||||
for (const item of inv.data) {
|
||||
if (item === env) continue; // removed by backup-env, never a bare delete
|
||||
actions.push({ kind: 'delete-path', item });
|
||||
}
|
||||
}
|
||||
|
||||
if (d.user) {
|
||||
for (const item of inv.user) actions.push({ kind: 'delete-path', item });
|
||||
}
|
||||
|
||||
if (d.data) {
|
||||
const tail = [...inv.runtime].sort(
|
||||
(a, b) =>
|
||||
Number(path.basename(a.path) === 'node_modules') -
|
||||
Number(path.basename(b.path) === 'node_modules'),
|
||||
);
|
||||
for (const item of tail) actions.push({ kind: 'delete-runtime-path', item });
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { RunCommand } from './onecli-agents.js';
|
||||
import type { RemovalAction } from './plan.js';
|
||||
import { backupEnv, executePlan, type ExecDeps } from './remove.js';
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-remove-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function deps(overrides: Partial<ExecDeps> = {}): ExecDeps {
|
||||
return {
|
||||
runCommand: () => ({ status: 0, stdout: '' }),
|
||||
log: () => {},
|
||||
isRoot: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('backupEnv', () => {
|
||||
it('backs up to .env.bak', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=secret');
|
||||
|
||||
const backup = backupEnv(envPath);
|
||||
|
||||
expect(backup).toBe(path.join(tempDir, '.env.bak'));
|
||||
expect(fs.readFileSync(backup, 'utf-8')).toBe('KEY=secret');
|
||||
});
|
||||
|
||||
it('falls back to a timestamped name when .env.bak exists', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=new');
|
||||
fs.writeFileSync(path.join(tempDir, '.env.bak'), 'KEY=old');
|
||||
|
||||
const backup = backupEnv(envPath);
|
||||
|
||||
expect(path.basename(backup)).toMatch(/^\.env\.bak\.\d{8}-\d{6}$/);
|
||||
expect(fs.readFileSync(backup, 'utf-8')).toBe('KEY=new');
|
||||
// The earlier backup is never clobbered.
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env.bak'), 'utf-8')).toBe('KEY=old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executePlan', () => {
|
||||
it('deletes paths recursively', () => {
|
||||
const dir = path.join(tempDir, 'data');
|
||||
fs.mkdirSync(path.join(dir, 'nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'nested', 'f.txt'), 'x');
|
||||
|
||||
const { notes } = executePlan(
|
||||
[{ kind: 'delete-path', item: { what: 'Data', where: dir, path: dir } }],
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('continues past a failing action and records a note', () => {
|
||||
const dir = path.join(tempDir, 'logs');
|
||||
fs.mkdirSync(dir);
|
||||
const actions: RemovalAction[] = [
|
||||
{
|
||||
kind: 'unload-service',
|
||||
flavor: 'launchd',
|
||||
unitPath: path.join(tempDir, 'svc.plist'),
|
||||
unitName: 'com.nanoclaw-v2-test',
|
||||
},
|
||||
{ kind: 'delete-path', item: { what: 'Logs', where: dir, path: dir } },
|
||||
];
|
||||
const failing: RunCommand = () => {
|
||||
throw new Error('launchctl exploded');
|
||||
};
|
||||
|
||||
const { notes } = executePlan(actions, deps({ runCommand: failing }));
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toContain('unload-service');
|
||||
expect(notes[0]).toContain('launchctl exploded');
|
||||
// Later actions still ran.
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a system unit in place without root and notes the sudo command', () => {
|
||||
const unitPath = path.join(tempDir, 'nanoclaw-v2-test.service');
|
||||
fs.writeFileSync(unitPath, '[Unit]');
|
||||
const calls: string[] = [];
|
||||
const recorder: RunCommand = (cmd) => {
|
||||
calls.push(cmd);
|
||||
return { status: 0, stdout: '' };
|
||||
};
|
||||
|
||||
const { notes } = executePlan(
|
||||
[
|
||||
{
|
||||
kind: 'unload-service',
|
||||
flavor: 'systemd-system',
|
||||
unitPath,
|
||||
unitName: 'nanoclaw-v2-test',
|
||||
},
|
||||
],
|
||||
deps({ runCommand: recorder, isRoot: false }),
|
||||
);
|
||||
|
||||
expect(fs.existsSync(unitPath)).toBe(true);
|
||||
expect(calls).toEqual([]);
|
||||
expect(notes.some((n) => n.includes('re-run with sudo'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes a failed image removal with the retry command', () => {
|
||||
const { notes } = executePlan(
|
||||
[{ kind: 'rmi', runtime: 'docker', image: 'img:latest' }],
|
||||
deps({ runCommand: () => ({ status: 1, stdout: '' }) }),
|
||||
);
|
||||
expect(notes.some((n) => n.includes('docker rmi img:latest'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes .env only after a successful backup', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=secret');
|
||||
|
||||
const { notes } = executePlan([{ kind: 'backup-env', envPath }], deps());
|
||||
|
||||
expect(fs.existsSync(envPath)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env.bak'), 'utf-8')).toBe('KEY=secret');
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps .env when the backup fails', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=secret');
|
||||
fs.chmodSync(tempDir, 0o555); // backup destination unwritable
|
||||
|
||||
try {
|
||||
const { notes } = executePlan([{ kind: 'backup-env', envPath }], deps());
|
||||
expect(fs.existsSync(envPath)).toBe(true);
|
||||
expect(notes.some((n) => n.includes('backup-env'))).toBe(true);
|
||||
} finally {
|
||||
fs.chmodSync(tempDir, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-lists containers by label at removal time instead of using scan-time ids', () => {
|
||||
const calls: string[][] = [];
|
||||
const docker: RunCommand = (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
if (args[0] === 'ps') return { status: 0, stdout: 'fresh1\nfresh2\n' };
|
||||
return { status: 0, stdout: '' };
|
||||
};
|
||||
|
||||
executePlan(
|
||||
[{ kind: 'rm-containers', runtime: 'docker', labelFilter: 'nanoclaw-install=abcd1234' }],
|
||||
deps({ runCommand: docker }),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
['docker', 'ps', '-aq', '--filter', 'label=nanoclaw-install=abcd1234'],
|
||||
['docker', 'rm', '-f', 'fresh1', 'fresh2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('notes a manual command when the container runtime is unavailable', () => {
|
||||
const { notes } = executePlan(
|
||||
[{ kind: 'rm-containers', runtime: 'docker', labelFilter: 'nanoclaw-install=x' }],
|
||||
deps({ runCommand: () => ({ status: null, stdout: '' }) }),
|
||||
);
|
||||
expect(notes.some((n) => n.includes('xargs -r docker rm -f'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes a manual delete when onecli itself cannot be run', () => {
|
||||
const { notes } = executePlan(
|
||||
[
|
||||
{
|
||||
kind: 'delete-onecli-agent',
|
||||
agent: { uuid: 'u-123', identifier: 'ag-mine', name: 'Mine' },
|
||||
},
|
||||
],
|
||||
deps({ runCommand: () => ({ status: null, stdout: '' }) }),
|
||||
);
|
||||
expect(notes.some((n) => n.includes('onecli agents delete --id u-123'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes OneCLI agents by vault uuid, never by identifier', () => {
|
||||
const calls: string[][] = [];
|
||||
const recorder: RunCommand = (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
return { status: 0, stdout: '' };
|
||||
};
|
||||
|
||||
executePlan(
|
||||
[
|
||||
{
|
||||
kind: 'delete-onecli-agent',
|
||||
agent: { uuid: 'u-123', identifier: 'ag-mine', name: 'Mine' },
|
||||
},
|
||||
],
|
||||
deps({ runCommand: recorder }),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([['onecli', 'agents', 'delete', '--id', 'u-123']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Removal-plan executor. Each action runs in its own try/catch: a failure
|
||||
* becomes a summary note and execution continues (re-running the
|
||||
* uninstaller is idempotent — the next scan only finds what's left).
|
||||
*
|
||||
* Must stay safe to run after logs/ and node_modules/ are gone: only static
|
||||
* imports, no dynamic import(), no setup-log writes. Output goes through
|
||||
* the injected `log` callback.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type { RunCommand } from './onecli-agents.js';
|
||||
import type { RemovalAction } from './plan.js';
|
||||
|
||||
export interface ExecDeps {
|
||||
runCommand: RunCommand;
|
||||
log: (line: string) => void;
|
||||
/** True when running as root — required to remove a system-level unit. */
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
export function executePlan(
|
||||
actions: RemovalAction[],
|
||||
deps: ExecDeps,
|
||||
): { notes: string[] } {
|
||||
const notes: string[] = [];
|
||||
for (const action of actions) {
|
||||
try {
|
||||
runAction(action, deps, notes);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
notes.push(
|
||||
`${action.kind}: failed (${msg}) — re-run the uninstaller to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy .env aside before deletion. Never clobbers an existing backup —
|
||||
* falls back to a timestamped name on collision. Returns the backup path.
|
||||
*/
|
||||
export function backupEnv(envPath: string): string {
|
||||
const dir = path.dirname(envPath);
|
||||
let backup = path.join(dir, '.env.bak');
|
||||
if (fs.existsSync(backup)) {
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace('T', '-')
|
||||
.slice(0, 15);
|
||||
backup = path.join(dir, `.env.bak.${stamp}`);
|
||||
}
|
||||
fs.copyFileSync(envPath, backup);
|
||||
return backup;
|
||||
}
|
||||
|
||||
function runAction(action: RemovalAction, deps: ExecDeps, notes: string[]): void {
|
||||
const { runCommand, log } = deps;
|
||||
switch (action.kind) {
|
||||
case 'unload-service':
|
||||
switch (action.flavor) {
|
||||
case 'launchd':
|
||||
runCommand('launchctl', ['unload', action.unitPath]);
|
||||
fs.rmSync(action.unitPath, { force: true });
|
||||
log('✓ background service removed');
|
||||
break;
|
||||
case 'systemd-user':
|
||||
runCommand('systemctl', [
|
||||
'--user',
|
||||
'disable',
|
||||
'--now',
|
||||
`${action.unitName}.service`,
|
||||
]);
|
||||
fs.rmSync(action.unitPath, { force: true });
|
||||
runCommand('systemctl', ['--user', 'daemon-reload']);
|
||||
log('✓ background service removed');
|
||||
break;
|
||||
case 'systemd-system':
|
||||
if (!deps.isRoot) {
|
||||
log('! system service needs root — left in place');
|
||||
notes.push(
|
||||
`System service ${action.unitPath} — re-run with sudo to remove.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
runCommand('systemctl', ['disable', '--now', `${action.unitName}.service`]);
|
||||
fs.rmSync(action.unitPath, { force: true });
|
||||
runCommand('systemctl', ['daemon-reload']);
|
||||
log('✓ system service removed');
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'kill-pid': {
|
||||
let pid = NaN;
|
||||
try {
|
||||
pid = Number(fs.readFileSync(action.pidFile, 'utf-8').trim());
|
||||
} catch {
|
||||
// pidfile already gone
|
||||
}
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(pid);
|
||||
log('✓ stopped host process');
|
||||
} catch {
|
||||
// not running
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pkill-host':
|
||||
// Exit 1 = no matching process — not a failure.
|
||||
runCommand('pkill', ['-f', action.pattern]);
|
||||
break;
|
||||
case 'rm-containers': {
|
||||
// Re-list at removal time: the host was alive during the confirm
|
||||
// phase and may have spawned containers the scan never saw.
|
||||
const ps = runCommand(action.runtime, [
|
||||
'ps',
|
||||
'-aq',
|
||||
'--filter',
|
||||
`label=${action.labelFilter}`,
|
||||
]);
|
||||
if (ps.status !== 0) {
|
||||
notes.push(
|
||||
`Containers: '${action.runtime}' unavailable — remove later with: ` +
|
||||
`${action.runtime} ps -aq --filter label=${action.labelFilter} | xargs -r ${action.runtime} rm -f`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const ids = ps.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) break;
|
||||
runCommand(action.runtime, ['rm', '-f', ...ids]);
|
||||
log(`✓ removed ${ids.length} container(s)`);
|
||||
break;
|
||||
}
|
||||
case 'rmi': {
|
||||
const res = runCommand(action.runtime, ['rmi', action.image]);
|
||||
if (res.status === 0) {
|
||||
log('✓ removed container image');
|
||||
} else {
|
||||
log('! could not remove image (in use?)');
|
||||
notes.push(
|
||||
`Image ${action.image}: not removed — retry with: ${action.runtime} rmi ${action.image}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rm-ncl-symlink':
|
||||
fs.rmSync(action.linkPath, { force: true });
|
||||
log('✓ removed ncl command');
|
||||
break;
|
||||
case 'delete-onecli-agent': {
|
||||
const res = runCommand('onecli', [
|
||||
'agents',
|
||||
'delete',
|
||||
'--id',
|
||||
action.agent.uuid,
|
||||
]);
|
||||
if (res.status === 0) {
|
||||
log(`✓ deleted OneCLI agent ${action.agent.name} (${action.agent.identifier})`);
|
||||
} else if (res.status === null) {
|
||||
// spawn failure (binary gone since the scan), not a missing agent
|
||||
log(`! couldn't run onecli for ${action.agent.identifier}`);
|
||||
notes.push(
|
||||
`OneCLI agent ${action.agent.name} (${action.agent.identifier}): couldn't run onecli — ` +
|
||||
`delete manually with: onecli agents delete --id ${action.agent.uuid}`,
|
||||
);
|
||||
} else {
|
||||
log(`! OneCLI agent ${action.agent.identifier} already gone`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'backup-env': {
|
||||
// Backup and removal are one action so a failed backup (which throws
|
||||
// into executePlan's catch) can never be followed by the deletion.
|
||||
const backup = backupEnv(action.envPath);
|
||||
fs.rmSync(action.envPath, { force: true });
|
||||
log(`✓ removed .env (backup at ${backup})`);
|
||||
break;
|
||||
}
|
||||
case 'delete-path':
|
||||
case 'delete-runtime-path':
|
||||
fs.rmSync(action.item.path, { recursive: true, force: true });
|
||||
log(`✓ removed ${action.item.what}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import type { RunCommand } from './onecli-agents.js';
|
||||
import { detectExistingInstall, scanInstall, type ScanDeps } from './scan.js';
|
||||
|
||||
let root: string;
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-scan-root-'));
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-scan-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Fake runCommand: unhandled commands fail (binary missing / daemon down). */
|
||||
function fakeRun(
|
||||
handlers: Record<string, (args: string[]) => { status: number | null; stdout: string }>,
|
||||
): RunCommand {
|
||||
return (cmd, args) => (handlers[cmd] ?? (() => ({ status: 1, stdout: '' })))(args);
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<ScanDeps> = {}): ScanDeps {
|
||||
return {
|
||||
projectRoot: root,
|
||||
home,
|
||||
platform: 'darwin',
|
||||
runCommand: fakeRun({}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const dockerUp = (containerIds: string[], hasImage: boolean) =>
|
||||
fakeRun({
|
||||
docker: (args) => {
|
||||
if (args[0] === 'ps') return { status: 0, stdout: containerIds.join('\n') + '\n' };
|
||||
if (args[0] === 'image') return { status: hasImage ? 0 : 1, stdout: '' };
|
||||
return { status: 1, stdout: '' };
|
||||
},
|
||||
});
|
||||
|
||||
describe('scanInstall path groups', () => {
|
||||
it('puts dist and node_modules in runtime, not data', () => {
|
||||
for (const dir of ['data', 'logs', 'dist', 'node_modules', 'groups', 'store']) {
|
||||
fs.mkdirSync(path.join(root, dir));
|
||||
}
|
||||
fs.writeFileSync(path.join(root, '.env'), 'KEY=v');
|
||||
fs.writeFileSync(path.join(root, 'start-nanoclaw.sh'), '#!/bin/bash');
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
|
||||
expect(inv.data.map((i) => path.basename(i.path))).toEqual([
|
||||
'data',
|
||||
'logs',
|
||||
'.env',
|
||||
'start-nanoclaw.sh',
|
||||
]);
|
||||
expect(inv.runtime.map((i) => path.basename(i.path))).toEqual([
|
||||
'dist',
|
||||
'node_modules',
|
||||
]);
|
||||
expect(inv.user.map((i) => path.basename(i.path))).toEqual(['groups', 'store']);
|
||||
});
|
||||
|
||||
it('finds nothing in an empty checkout', () => {
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.data).toEqual([]);
|
||||
expect(inv.runtime).toEqual([]);
|
||||
expect(inv.user).toEqual([]);
|
||||
expect(inv.service.containerIds).toEqual([]);
|
||||
expect(inv.service.image).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanInstall service artifacts', () => {
|
||||
it('detects the launchd plist on macOS', () => {
|
||||
const plist = path.join(
|
||||
home,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
`${getLaunchdLabel(root)}.plist`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
||||
fs.writeFileSync(plist, '<plist/>');
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.launchdPlist).toBe(plist);
|
||||
expect(inv.service.systemdUserUnit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects systemd user unit and pidfile on Linux', () => {
|
||||
const unit = path.join(
|
||||
home,
|
||||
'.config',
|
||||
'systemd',
|
||||
'user',
|
||||
`${getSystemdUnit(root)}.service`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(unit), { recursive: true });
|
||||
fs.writeFileSync(unit, '[Unit]');
|
||||
fs.writeFileSync(path.join(root, 'nanoclaw.pid'), '12345');
|
||||
|
||||
const inv = scanInstall(deps({ platform: 'linux' }));
|
||||
expect(inv.service.systemdUserUnit).toBe(unit);
|
||||
expect(inv.service.pidFile).toBe(path.join(root, 'nanoclaw.pid'));
|
||||
expect(inv.service.launchdPlist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures container ids and image when docker is up', () => {
|
||||
const inv = scanInstall(deps({ runCommand: dockerUp(['abc123', 'def456'], true) }));
|
||||
expect(inv.service.containerIds).toEqual(['abc123', 'def456']);
|
||||
expect(inv.service.image).toMatch(/^nanoclaw-agent-v2-[0-9a-f]{8}:latest$/);
|
||||
expect(inv.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('degrades with a manual-cleanup note when docker is unavailable', () => {
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.containerIds).toEqual([]);
|
||||
expect(inv.service.image).toBeUndefined();
|
||||
expect(inv.notes.some((n) => n.includes("'docker' unavailable"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanInstall ncl symlink', () => {
|
||||
const link = () => path.join(home, '.local', 'bin', 'ncl');
|
||||
|
||||
it('includes the symlink only when it targets this checkout', () => {
|
||||
fs.mkdirSync(path.dirname(link()), { recursive: true });
|
||||
fs.symlinkSync(path.join(root, 'bin', 'ncl'), link());
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.nclSymlink).toBe(link());
|
||||
});
|
||||
|
||||
it('leaves a symlink pointing at another copy, with a note', () => {
|
||||
fs.mkdirSync(path.dirname(link()), { recursive: true });
|
||||
fs.symlinkSync('/some/other/copy/bin/ncl', link());
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.nclSymlink).toBeUndefined();
|
||||
expect(inv.notes.some((n) => n.includes('points to another NanoClaw copy'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanInstall OneCLI agents', () => {
|
||||
const vault = JSON.stringify({
|
||||
data: [
|
||||
{ id: 'u-1', identifier: 'ag-mine', name: 'Mine', isDefault: false },
|
||||
{ id: 'u-2', identifier: 'ag-other', name: 'Other', isDefault: false },
|
||||
],
|
||||
});
|
||||
const onecliUp = fakeRun({ onecli: () => ({ status: 0, stdout: vault }) });
|
||||
|
||||
it('splits mine vs orphans against the central DB', () => {
|
||||
fs.mkdirSync(path.join(root, 'data'));
|
||||
const db = new Database(path.join(root, 'data', 'v2.db'));
|
||||
db.exec('CREATE TABLE agent_groups (id TEXT PRIMARY KEY)');
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-mine');
|
||||
db.close();
|
||||
|
||||
const inv = scanInstall(deps({ runCommand: onecliUp }));
|
||||
expect(inv.onecli.idsKnown).toBe(true);
|
||||
expect(inv.onecli.mine.map((a) => a.identifier)).toEqual(['ag-mine']);
|
||||
expect(inv.onecli.orphans.map((a) => a.identifier)).toEqual(['ag-other']);
|
||||
});
|
||||
|
||||
it('flags orphan labels as unreliable when the DB is unreadable', () => {
|
||||
const inv = scanInstall(deps({ runCommand: onecliUp }));
|
||||
expect(inv.onecli.idsKnown).toBe(false);
|
||||
expect(inv.onecli.mine).toEqual([]);
|
||||
expect(inv.onecli.orphans.map((a) => a.identifier)).toEqual(['ag-mine', 'ag-other']);
|
||||
expect(inv.notes.some((n) => n.includes("Couldn't read agent_groups"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectExistingInstall', () => {
|
||||
it('is false for an empty checkout', () => {
|
||||
expect(detectExistingInstall(root)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when the central DB exists', () => {
|
||||
fs.mkdirSync(path.join(root, 'data'));
|
||||
const db = new Database(path.join(root, 'data', 'v2.db'));
|
||||
db.close();
|
||||
expect(detectExistingInstall(root)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Uninstall inventory scan — find every artifact this checkout created.
|
||||
*
|
||||
* Everything NanoClaw creates is tagged with the per-checkout install slug
|
||||
* (sha1(projectRoot)[:8]), so several copies can coexist on one machine.
|
||||
* The scan reports ONLY things belonging to the given project root; shared
|
||||
* tools (the OneCLI app/vault, shell PATH lines, host-wide config) are
|
||||
* never inventoried.
|
||||
*
|
||||
* External commands (docker, onecli) go through the injected `runCommand`
|
||||
* so tests can fake them; filesystem checks are real — tests use temp dirs.
|
||||
* A missing/down docker daemon degrades to an empty result plus a note with
|
||||
* manual cleanup commands; it never throws.
|
||||
*
|
||||
* Deliberately does NOT import src/config.ts (import-time side effects).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
getContainerImageBase,
|
||||
getInstallSlug,
|
||||
getLaunchdLabel,
|
||||
getSystemdUnit,
|
||||
} from '../../src/install-slug.js';
|
||||
import {
|
||||
listVaultAgents,
|
||||
readAgentGroupIds,
|
||||
splitVaultAgents,
|
||||
type RunCommand,
|
||||
type VaultAgent,
|
||||
} from './onecli-agents.js';
|
||||
|
||||
export interface PathItem {
|
||||
/** Human label, e.g. "Database & conversations". */
|
||||
what: string;
|
||||
/** Display location (tilde-abbreviated). */
|
||||
where: string;
|
||||
/** Absolute path to remove. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ServiceInventory {
|
||||
launchdPlist?: string;
|
||||
systemdUserUnit?: string;
|
||||
systemdSystemUnit?: string;
|
||||
pidFile?: string;
|
||||
containerIds: string[];
|
||||
image?: string;
|
||||
nclSymlink?: string;
|
||||
}
|
||||
|
||||
export interface OnecliInventory {
|
||||
mine: VaultAgent[];
|
||||
orphans: VaultAgent[];
|
||||
/** False when agent_groups couldn't be read — orphan labels are then unreliable. */
|
||||
idsKnown: boolean;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
slug: string;
|
||||
projectRoot: string;
|
||||
containerRuntime: string;
|
||||
service: ServiceInventory;
|
||||
/** Group 2: app data, logs & secrets. */
|
||||
data: PathItem[];
|
||||
/**
|
||||
* dist/ + node_modules/ — displayed with the data group but removed dead
|
||||
* last: the uninstaller itself runs on tsx out of node_modules.
|
||||
*/
|
||||
runtime: PathItem[];
|
||||
/** Group 3: groups/ and store/ — user content, unrecoverable. */
|
||||
user: PathItem[];
|
||||
onecli: OnecliInventory;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface ScanDeps {
|
||||
projectRoot: string;
|
||||
home: string;
|
||||
platform: NodeJS.Platform;
|
||||
runCommand: RunCommand;
|
||||
}
|
||||
|
||||
export function tilde(p: string, home: string): string {
|
||||
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
||||
}
|
||||
|
||||
export function scanInstall(deps: ScanDeps): Inventory {
|
||||
const { projectRoot, home, runCommand } = deps;
|
||||
const slug = getInstallSlug(projectRoot);
|
||||
const containerRuntime = process.env.CONTAINER_RUNTIME ?? 'docker';
|
||||
const notes: string[] = [];
|
||||
|
||||
const service = scanService(deps, slug, containerRuntime, notes);
|
||||
|
||||
const data = existingItems(projectRoot, home, [
|
||||
{ rel: 'data', what: 'Database & conversations' },
|
||||
{ rel: 'logs', what: 'Logs' },
|
||||
{ rel: '.env', what: 'Secrets / API keys (.env)', where: 'backed up before removal' },
|
||||
{ rel: 'start-nanoclaw.sh', what: 'Start script', where: 'start-nanoclaw.sh' },
|
||||
{ rel: 'nanoclaw.pid', what: 'PID file', where: 'nanoclaw.pid' },
|
||||
]);
|
||||
|
||||
const runtime = existingItems(projectRoot, home, [
|
||||
{ rel: 'dist', what: 'Build output' },
|
||||
{ rel: 'node_modules', what: 'Installed dependencies' },
|
||||
]);
|
||||
|
||||
const user = existingItems(projectRoot, home, [
|
||||
{ rel: 'groups', what: 'Agent memory & files' },
|
||||
{ rel: 'store', what: 'Migrated data store' },
|
||||
]);
|
||||
|
||||
const onecli = scanOnecli(projectRoot, runCommand, notes);
|
||||
|
||||
return {
|
||||
slug,
|
||||
projectRoot,
|
||||
containerRuntime,
|
||||
service,
|
||||
data,
|
||||
runtime,
|
||||
user,
|
||||
onecli,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap existing-install probe for mid-setup detection: service registration
|
||||
* (per-platform) or a central DB. No docker or onecli calls.
|
||||
*/
|
||||
export function detectExistingInstall(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'v2.db'))) return true;
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'darwin') {
|
||||
return fs.existsSync(
|
||||
path.join(home, 'Library', 'LaunchAgents', `${getLaunchdLabel(projectRoot)}.plist`),
|
||||
);
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const unit = getSystemdUnit(projectRoot);
|
||||
return (
|
||||
fs.existsSync(path.join(home, '.config', 'systemd', 'user', `${unit}.service`)) ||
|
||||
fs.existsSync(`/etc/systemd/system/${unit}.service`)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanService(
|
||||
deps: ScanDeps,
|
||||
slug: string,
|
||||
containerRuntime: string,
|
||||
notes: string[],
|
||||
): ServiceInventory {
|
||||
const { projectRoot, home, platform, runCommand } = deps;
|
||||
const service: ServiceInventory = { containerIds: [] };
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const plist = path.join(
|
||||
home,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
`${getLaunchdLabel(projectRoot)}.plist`,
|
||||
);
|
||||
if (fs.existsSync(plist)) service.launchdPlist = plist;
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit(projectRoot);
|
||||
const userUnit = path.join(home, '.config', 'systemd', 'user', `${unit}.service`);
|
||||
const systemUnit = `/etc/systemd/system/${unit}.service`;
|
||||
if (fs.existsSync(userUnit)) service.systemdUserUnit = userUnit;
|
||||
if (fs.existsSync(systemUnit)) service.systemdSystemUnit = systemUnit;
|
||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
||||
if (fs.existsSync(pidFile)) service.pidFile = pidFile;
|
||||
}
|
||||
|
||||
// Container label matches what container-runner.ts stamps at spawn time.
|
||||
const installLabel = `nanoclaw-install=${slug}`;
|
||||
const image = `${getContainerImageBase(projectRoot)}:latest`;
|
||||
let runtimeOk = true;
|
||||
try {
|
||||
const ps = runCommand(containerRuntime, [
|
||||
'ps',
|
||||
'-aq',
|
||||
'--filter',
|
||||
`label=${installLabel}`,
|
||||
]);
|
||||
if (ps.status === 0) {
|
||||
service.containerIds = ps.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
runtimeOk = false;
|
||||
}
|
||||
} catch {
|
||||
runtimeOk = false;
|
||||
}
|
||||
if (runtimeOk) {
|
||||
try {
|
||||
const inspect = runCommand(containerRuntime, ['image', 'inspect', image]);
|
||||
if (inspect.status === 0) service.image = image;
|
||||
} catch {
|
||||
runtimeOk = false;
|
||||
}
|
||||
}
|
||||
if (!runtimeOk) {
|
||||
notes.push(
|
||||
`Containers/image: '${containerRuntime}' unavailable; remove later with: ` +
|
||||
`${containerRuntime} ps -aq --filter label=${installLabel} | xargs -r ${containerRuntime} rm -f; ` +
|
||||
`${containerRuntime} rmi ${image}`,
|
||||
);
|
||||
}
|
||||
|
||||
const link = path.join(home, '.local', 'bin', 'ncl');
|
||||
let linkStat: fs.Stats | null = null;
|
||||
try {
|
||||
linkStat = fs.lstatSync(link);
|
||||
} catch {
|
||||
linkStat = null;
|
||||
}
|
||||
if (linkStat?.isSymbolicLink()) {
|
||||
let target = fs.readlinkSync(link);
|
||||
if (!path.isAbsolute(target)) {
|
||||
target = path.resolve(path.dirname(link), target);
|
||||
}
|
||||
if (path.resolve(target) === path.join(projectRoot, 'bin', 'ncl')) {
|
||||
service.nclSymlink = link;
|
||||
} else {
|
||||
notes.push(
|
||||
`ncl command ${tilde(link, home)} points to another NanoClaw copy; left untouched.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
function scanOnecli(
|
||||
projectRoot: string,
|
||||
runCommand: RunCommand,
|
||||
notes: string[],
|
||||
): OnecliInventory {
|
||||
const vault = listVaultAgents(runCommand);
|
||||
if (!vault.available || vault.agents.length === 0) {
|
||||
return { mine: [], orphans: [], idsKnown: false };
|
||||
}
|
||||
|
||||
const { ids, known } = readAgentGroupIds(path.join(projectRoot, 'data', 'v2.db'));
|
||||
const { mine, orphans } = splitVaultAgents(vault.agents, ids, known);
|
||||
if (!known && orphans.length > 0) {
|
||||
notes.push(
|
||||
"Couldn't read agent_groups from data/v2.db; OneCLI agents shown as 'orphan' may actually belong to this copy.",
|
||||
);
|
||||
}
|
||||
return { mine, orphans, idsKnown: known };
|
||||
}
|
||||
|
||||
function existingItems(
|
||||
projectRoot: string,
|
||||
home: string,
|
||||
specs: { rel: string; what: string; where?: string }[],
|
||||
): PathItem[] {
|
||||
const items: PathItem[] = [];
|
||||
for (const spec of specs) {
|
||||
const p = path.join(projectRoot, spec.rel);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
items.push({
|
||||
what: spec.what,
|
||||
where: spec.where ?? `${tilde(p, home)}/`,
|
||||
path: p,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export interface DeliveryAddress {
|
||||
*/
|
||||
export interface InboundEvent {
|
||||
channelType: string;
|
||||
/** Receiving adapter instance; stamped host-side (src/index.ts onInbound).
|
||||
* Absent (e.g. CLI onInboundEvent) means the default instance (= channelType). */
|
||||
instance?: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
message: {
|
||||
@@ -112,6 +115,15 @@ export interface ChannelAdapter {
|
||||
name: string;
|
||||
channelType: string;
|
||||
|
||||
/**
|
||||
* Adapter-instance name — distinguishes N adapters of one platform
|
||||
* (e.g. three Slack apps in one workspace). Defaults to channelType.
|
||||
* channelType stays the SEMANTIC platform key (user ids '<channelType>:<handle>',
|
||||
* formatting, container config); instance is a host-side routing key only.
|
||||
* Must be unique across active adapters and URL-safe (no '/', '?', ':').
|
||||
*/
|
||||
instance?: string;
|
||||
|
||||
/**
|
||||
* Whether this adapter models conversations as threads.
|
||||
*
|
||||
|
||||
@@ -30,19 +30,24 @@ function now() {
|
||||
/** Create a mock ChannelAdapter for testing. */
|
||||
function createMockAdapter(
|
||||
channelType: string,
|
||||
): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } {
|
||||
instance?: string,
|
||||
): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[]; setupTimes: number[] } {
|
||||
const delivered: OutboundMessage[] = [];
|
||||
const inbound: InboundMessage[] = [];
|
||||
const setupTimes: number[] = [];
|
||||
let setupConfig: ChannelSetup | null = null;
|
||||
|
||||
return {
|
||||
name: channelType,
|
||||
name: instance ?? channelType,
|
||||
channelType,
|
||||
instance,
|
||||
supportsThreads: false,
|
||||
delivered,
|
||||
inbound,
|
||||
setupTimes,
|
||||
|
||||
async setup(config: ChannelSetup) {
|
||||
setupTimes.push(Date.now());
|
||||
setupConfig = config;
|
||||
},
|
||||
|
||||
@@ -117,6 +122,117 @@ describe('channel registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel registry — instance keying', () => {
|
||||
// Fresh module per test: the registry and activeAdapters maps are
|
||||
// module-level, and these arms register conflicting same-channelType
|
||||
// adapters that must not leak across tests.
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
// Drop this test's registrations so later describe blocks (which import
|
||||
// the registry without resetting) start from an empty registry instead
|
||||
// of inheriting same-channelType pairs.
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const mockSetup = () => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
});
|
||||
|
||||
it('keys two same-channelType adapters by instance — both resolvable', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const worker = createMockAdapter('slack', 'slack-worker');
|
||||
const tester = createMockAdapter('slack', 'slack-tester');
|
||||
reg.registerChannelAdapter('slack-worker', { factory: () => worker });
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
|
||||
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelAdapter('slack-worker')).toBe(worker);
|
||||
expect(reg.getChannelAdapter('slack-tester')).toBe(tester);
|
||||
expect(reg.getActiveAdapters()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolves channelType to the default-instance adapter when one exists, else first-registered', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const named = createMockAdapter('slack', 'slack-tester');
|
||||
const unnamed = createMockAdapter('slack');
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => named });
|
||||
reg.registerChannelAdapter('slack', { factory: () => unnamed });
|
||||
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Exact key (default instance keyed by channelType) beats the fallback
|
||||
// scan, even though the named sibling registered first.
|
||||
expect(reg.getChannelAdapter('slack')).toBe(unnamed);
|
||||
|
||||
// With ONLY named instances active, channelType still resolves —
|
||||
// deterministic first-registered fallback.
|
||||
await reg.teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
const reg2 = await import('./channel-registry.js');
|
||||
const first = createMockAdapter('slack', 'slack-tester');
|
||||
const second = createMockAdapter('slack', 'slack-worker');
|
||||
reg2.registerChannelAdapter('slack-tester', { factory: () => first });
|
||||
reg2.registerChannelAdapter('slack-worker', { factory: () => second });
|
||||
await reg2.initChannelAdapters(mockSetup);
|
||||
expect(reg2.getChannelAdapter('slack')).toBe(first);
|
||||
});
|
||||
|
||||
it('does NOT reroute default-instance outbound through a named sibling when the default adapter is missing', async () => {
|
||||
// The default Slack app is offline (token rotated, factory returned
|
||||
// null, …) while a named sibling boots fine. Outbound for the default
|
||||
// instance must get the offline-adapter handling (drop into the retry
|
||||
// path) — NEVER a cross-identity send through the sibling bot.
|
||||
const reg = await import('./channel-registry.js');
|
||||
const tester = createMockAdapter('slack', 'slack-tester');
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
|
||||
reg.registerChannelAdapter('slack', { factory: () => null });
|
||||
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Exact lookup (delivery/typing path): the default key resolves nothing.
|
||||
expect(reg.getChannelAdapterExact('slack')).toBeUndefined();
|
||||
// Fallback-capable lookup (channelType-only callers) still resolves.
|
||||
expect(reg.getChannelAdapter('slack')).toBe(tester);
|
||||
|
||||
// The delivery bridge dispatches by exact key: a default-instance
|
||||
// message (instance === channelType after backfill) is dropped, not
|
||||
// delivered through the sibling's identity.
|
||||
const bridge = reg.createChannelDeliveryAdapter();
|
||||
const result = await bridge.deliver(
|
||||
'slack',
|
||||
'slack:C1',
|
||||
null,
|
||||
'chat',
|
||||
JSON.stringify({ text: 'to the default bot' }),
|
||||
undefined,
|
||||
'slack',
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(tester.delivered).toHaveLength(0);
|
||||
|
||||
// Sanity: the same bridge DOES deliver when the exact instance is live.
|
||||
await bridge.deliver(
|
||||
'slack',
|
||||
'slack:C1',
|
||||
null,
|
||||
'chat',
|
||||
JSON.stringify({ text: 'to the tester bot' }),
|
||||
undefined,
|
||||
'slack-tester',
|
||||
);
|
||||
expect(tester.delivered).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel + router integration', () => {
|
||||
beforeEach(async () => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* Channels self-register on import. The host calls initChannelAdapters() at startup
|
||||
* to instantiate and set up all registered adapters.
|
||||
*/
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
|
||||
import type { ChannelDeliveryAdapter } from '../delivery.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000];
|
||||
@@ -26,9 +27,79 @@ export function registerChannelAdapter(name: string, registration: ChannelRegist
|
||||
registry.set(name, registration);
|
||||
}
|
||||
|
||||
/** Get a live adapter by channel type. */
|
||||
export function getChannelAdapter(channelType: string): ChannelAdapter | undefined {
|
||||
return activeAdapters.get(channelType);
|
||||
/** Get a live adapter by its EXACT registry key (instance name; default
|
||||
* instances are keyed by channelType itself). No channelType fallback —
|
||||
* callers that address a specific instance (outbound delivery, typing)
|
||||
* must never be rerouted through a sibling instance: that would send
|
||||
* through the wrong bot identity with the wrong token. A missing key
|
||||
* means the owning adapter is offline; callers apply their normal
|
||||
* offline-adapter handling. */
|
||||
export function getChannelAdapterExact(key: string): ChannelAdapter | undefined {
|
||||
return activeAdapters.get(key);
|
||||
}
|
||||
|
||||
/** Get a live adapter by instance name, falling back to any adapter of the
|
||||
* given channel type. The fallback exists ONLY for channelType-only callers
|
||||
* (user-id prefix resolution and cold DMs in user-dm.ts, approval delivery
|
||||
* in channel-approval.ts, the router's thread-policy probe when an event
|
||||
* carries no instance) — they must still resolve when every instance of a
|
||||
* platform is named. First registered wins (Map insertion order,
|
||||
* deterministic). Default instances are keyed by channelType itself, so
|
||||
* single-instance installs always hit the exact-key path. Instance-addressed
|
||||
* dispatch (delivery, typing) must use getChannelAdapterExact instead. */
|
||||
export function getChannelAdapter(key: string): ChannelAdapter | undefined {
|
||||
const exact = activeAdapters.get(key);
|
||||
if (exact) return exact;
|
||||
for (const [registryKey, adapter] of activeAdapters) {
|
||||
if (adapter.channelType === key) {
|
||||
log.warn('Channel adapter fallback: requested key resolved through a differently-keyed instance', {
|
||||
requested: key,
|
||||
resolvedKey: registryKey,
|
||||
});
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the host's outbound delivery bridge: dispatches delivery-poll and
|
||||
* typing traffic into the adapter registry. Resolution is EXACT-key only —
|
||||
* `instance ?? channelType`. For default-instance messaging_groups rows the
|
||||
* stored instance IS the channelType, which matches default-registered
|
||||
* adapters, so single-instance behavior is unchanged. A named instance whose
|
||||
* adapter is offline gets the normal offline-adapter handling (warn + drop
|
||||
* into the delivery retry path) — never a cross-identity send through a
|
||||
* sibling bot of the same platform.
|
||||
*/
|
||||
export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
|
||||
return {
|
||||
async deliver(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: OutboundFile[],
|
||||
instance?: string,
|
||||
): Promise<string | undefined> {
|
||||
const adapter = getChannelAdapterExact(instance ?? channelType);
|
||||
if (!adapter) {
|
||||
log.warn('No adapter for channel type', { channelType, instance });
|
||||
return;
|
||||
}
|
||||
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
|
||||
},
|
||||
async setTyping(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
instance?: string,
|
||||
): Promise<void> {
|
||||
const adapter = getChannelAdapterExact(instance ?? channelType);
|
||||
await adapter?.setTyping?.(platformId, threadId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all active adapters. */
|
||||
@@ -85,8 +156,16 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
activeAdapters.set(adapter.channelType, adapter);
|
||||
log.info('Channel adapter started', { channel: name, type: adapter.channelType });
|
||||
// Adapters key by instance (default instance = channelType), so N
|
||||
// instances of one platform coexist. Duplicate keys warn instead of
|
||||
// throwing — boot stays resilient, matching the historical silent
|
||||
// last-write-wins, but now visibly.
|
||||
const key = adapter.instance ?? adapter.channelType;
|
||||
if (activeAdapters.has(key)) {
|
||||
log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name });
|
||||
}
|
||||
activeAdapters.set(key, adapter);
|
||||
log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key });
|
||||
} catch (err) {
|
||||
log.error('Failed to start channel adapter', { channel: name, err });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Approval-card actor byline in the Chat SDK bridge.
|
||||
*
|
||||
* Drives the bridge's real onAction handler through the real Chat SDK
|
||||
* dispatch (`chat.processAction`): `bridge.setup()` registers the handler on
|
||||
* a real Chat instance, which the test captures from the webhook-server
|
||||
* registration (mocked so no HTTP server binds a port). After a button click
|
||||
* the bridge edits the card; the edit must append " — <actor>" so shared
|
||||
* channels see who resolved an approval. Goes red if the byLine concatenation
|
||||
* is removed from the edited markdown.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Adapter, Chat } from 'chat';
|
||||
|
||||
const captured = vi.hoisted(() => ({ chat: null as unknown }));
|
||||
|
||||
vi.mock('../webhook-server.js', () => ({
|
||||
registerWebhookAdapter: vi.fn((chat: unknown) => {
|
||||
captured.chat = chat;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { closeDb, initTestDb, runMigrations } from '../db/index.js';
|
||||
import type { ChannelSetup } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
|
||||
interface CapturedEdit {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function makeAdapter(edits: CapturedEdit[]): Adapter {
|
||||
return {
|
||||
name: 'stub',
|
||||
initialize: async () => {},
|
||||
channelIdFromThreadId: (threadId: string) => `stub:${threadId}`,
|
||||
editMessage: async (threadId: string, messageId: string, content: { markdown: string }) => {
|
||||
edits.push({ threadId, messageId, markdown: content.markdown });
|
||||
},
|
||||
} as unknown as Adapter;
|
||||
}
|
||||
|
||||
async function fireAction(user: Record<string, unknown>): Promise<{ edits: CapturedEdit[]; actions: string[] }> {
|
||||
const edits: CapturedEdit[] = [];
|
||||
const actions: string[] = [];
|
||||
const adapter = makeAdapter(edits);
|
||||
const bridge = createChatSdkBridge({ adapter, supportsThreads: false });
|
||||
|
||||
await bridge.setup({
|
||||
onInbound: async () => {},
|
||||
onInboundEvent: async () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: (questionId: string, selectedOption: string, userId: string) => {
|
||||
actions.push(`${questionId}:${selectedOption}:${userId}`);
|
||||
},
|
||||
} as ChannelSetup);
|
||||
|
||||
const chat = captured.chat as Chat;
|
||||
expect(chat).toBeTruthy();
|
||||
await chat.processAction(
|
||||
{
|
||||
actionId: 'ncq:q-1:approve',
|
||||
adapter,
|
||||
messageId: 'msg-1',
|
||||
raw: {},
|
||||
threadId: 'T-1',
|
||||
user: user as never,
|
||||
value: 'approve',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return { edits, actions };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured.chat = null;
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('chat-sdk-bridge approval-card byline', () => {
|
||||
it('appends the acting user to the edited card markdown', async () => {
|
||||
const { edits, actions } = await fireAction({ userId: 'U1', userName: 'gavriel', fullName: 'Gavriel C' });
|
||||
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].threadId).toBe('T-1');
|
||||
expect(edits[0].messageId).toBe('msg-1');
|
||||
expect(edits[0].markdown).toContain('approve — gavriel');
|
||||
expect(actions).toEqual(['q-1:approve:U1']);
|
||||
});
|
||||
|
||||
it('falls back to fullName when userName is missing', async () => {
|
||||
const { edits } = await fireAction({ userId: 'U2', fullName: 'Gavriel C' });
|
||||
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].markdown).toContain('— Gavriel C');
|
||||
});
|
||||
|
||||
it('omits the byline when the actor has no name', async () => {
|
||||
const { edits } = await fireAction({ userId: 'U3' });
|
||||
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].markdown).not.toContain('—');
|
||||
expect(edits[0].markdown).toContain('approve');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat';
|
||||
|
||||
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
|
||||
|
||||
vi.mock('../webhook-server.js', () => ({
|
||||
registerWebhookAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
function stubAdapter(partial: Partial<Adapter>): Adapter {
|
||||
return { name: 'stub', ...partial } as unknown as Adapter;
|
||||
}
|
||||
@@ -93,6 +97,147 @@ describe('createChatSdkBridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge — instance identity', () => {
|
||||
it('default: name === channelType === adapter.name, instance undefined', () => {
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: stubAdapter({ name: 'slack' }),
|
||||
supportsThreads: true,
|
||||
});
|
||||
expect(bridge.name).toBe('slack');
|
||||
expect(bridge.channelType).toBe('slack');
|
||||
expect(bridge.instance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('named instance: name follows the instance, channelType stays the platform', () => {
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: stubAdapter({ name: 'slack' }),
|
||||
instance: 'slack-tester',
|
||||
supportsThreads: true,
|
||||
});
|
||||
expect(bridge.name).toBe('slack-tester');
|
||||
expect(bridge.channelType).toBe('slack');
|
||||
expect(bridge.instance).toBe('slack-tester');
|
||||
});
|
||||
|
||||
it('rejects instance names that would break the webhook route or state delimiter', () => {
|
||||
for (const bad of ['a/b', 'a:b', 'a?b', 'a b']) {
|
||||
expect(() =>
|
||||
createChatSdkBridge({ adapter: stubAdapter({ name: 'slack' }), instance: bad, supportsThreads: true }),
|
||||
).toThrow(/URL-safe/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty and whitespace-only instance names (config bug — fail loud)', () => {
|
||||
// '' is falsy: a truthiness guard would skip it, dead-ending the
|
||||
// webhook route ('/webhook/' + '') and collapsing the state namespace
|
||||
// into the default instance's unprefixed keyspace — the exact
|
||||
// cross-bot dedupe/lock collisions the namespace exists to prevent.
|
||||
for (const bad of ['', ' ', ' ', '\t']) {
|
||||
expect(() =>
|
||||
createChatSdkBridge({ adapter: stubAdapter({ name: 'slack' }), instance: bad, supportsThreads: true }),
|
||||
).toThrow(/URL-safe/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge.setup — webhook route and state namespace', () => {
|
||||
// Real setup() over a stub adapter: Chat.initialize() needs a working
|
||||
// StateAdapter (chat_sdk_* tables) and an adapter.initialize — nothing
|
||||
// platform-side. registerWebhookAdapter is mocked at module level so we
|
||||
// can assert the (chat, adapterName, routingPath) triple.
|
||||
function setupStubAdapter(): Adapter {
|
||||
return stubAdapter({
|
||||
name: 'slack',
|
||||
initialize: async () => {},
|
||||
} as unknown as Partial<Adapter>);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { initTestDb } = await import('../db/connection.js');
|
||||
const { runMigrations } = await import('../db/migrations/index.js');
|
||||
runMigrations(initTestDb());
|
||||
const { registerWebhookAdapter } = await import('../webhook-server.js');
|
||||
vi.mocked(registerWebhookAdapter).mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { closeDb } = await import('../db/connection.js');
|
||||
closeDb();
|
||||
});
|
||||
|
||||
const hostConfig = {
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
};
|
||||
|
||||
it('named instance registers the webhook with adapterName as handler key and instance as route', async () => {
|
||||
const { registerWebhookAdapter } = await import('../webhook-server.js');
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: setupStubAdapter(),
|
||||
instance: 'slack-tester',
|
||||
supportsThreads: true,
|
||||
});
|
||||
await bridge.setup(hostConfig);
|
||||
expect(registerWebhookAdapter).toHaveBeenCalledTimes(1);
|
||||
const [, adapterName, routingPath] = vi.mocked(registerWebhookAdapter).mock.calls[0];
|
||||
expect(adapterName).toBe('slack');
|
||||
expect(routingPath).toBe('slack-tester');
|
||||
await bridge.teardown();
|
||||
});
|
||||
|
||||
it('default instance registers the historical route', async () => {
|
||||
const { registerWebhookAdapter } = await import('../webhook-server.js');
|
||||
const bridge = createChatSdkBridge({ adapter: setupStubAdapter(), supportsThreads: true });
|
||||
await bridge.setup(hostConfig);
|
||||
const [, adapterName, routingPath] = vi.mocked(registerWebhookAdapter).mock.calls[0];
|
||||
expect(adapterName).toBe('slack');
|
||||
expect(routingPath ?? adapterName).toBe('slack');
|
||||
await bridge.teardown();
|
||||
});
|
||||
|
||||
it('named instance namespaces Chat SDK state; default stays unprefixed (live-install constraint)', async () => {
|
||||
const { getDb } = await import('../db/connection.js');
|
||||
|
||||
const named = createChatSdkBridge({
|
||||
adapter: setupStubAdapter(),
|
||||
instance: 'slack-tester',
|
||||
supportsThreads: true,
|
||||
});
|
||||
await named.setup(hostConfig);
|
||||
await named.subscribe!('slack:C1', 'slack:T1');
|
||||
|
||||
const def = createChatSdkBridge({ adapter: setupStubAdapter(), supportsThreads: true });
|
||||
await def.setup(hostConfig);
|
||||
await def.subscribe!('slack:C1', 'slack:T1');
|
||||
|
||||
const rows = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions ORDER BY thread_id').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(rows.map((r) => r.thread_id)).toEqual(['slack-tester:slack:T1', 'slack:T1']);
|
||||
|
||||
await named.teardown();
|
||||
await def.teardown();
|
||||
});
|
||||
|
||||
it('explicitly naming the primary instance after the platform stays on the unprefixed keyspace', async () => {
|
||||
const { getDb } = await import('../db/connection.js');
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: setupStubAdapter(),
|
||||
instance: 'slack', // explicit, but equal to adapter.name ⇒ default keyspace
|
||||
supportsThreads: true,
|
||||
});
|
||||
await bridge.setup(hostConfig);
|
||||
await bridge.subscribe!('slack:C1', 'slack:T9');
|
||||
const rows = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(rows.map((r) => r.thread_id)).toEqual(['slack:T9']);
|
||||
await bridge.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
|
||||
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
|
||||
// Before this branch existed the bridge silently dropped them: cards have no
|
||||
|
||||
@@ -47,6 +47,15 @@ export type ReplyContextExtractor = (raw: Record<string, any>) => ReplyContext |
|
||||
|
||||
export interface ChatSdkBridgeConfig {
|
||||
adapter: Adapter;
|
||||
/**
|
||||
* Adapter-instance name for running multiple bridges of one platform
|
||||
* (e.g. several Slack apps in one workspace). Defaults to the platform
|
||||
* name. Drives the registry key, the webhook route (/webhook/<instance>),
|
||||
* and the Chat SDK state namespace. channelType is NOT affected — user
|
||||
* identity, formatting, and container config stay keyed on the platform.
|
||||
* Must be URL-safe: non-empty, only letters, digits, '.', '_' or '-'.
|
||||
*/
|
||||
instance?: string;
|
||||
concurrency?: ConcurrencyStrategy;
|
||||
/** Bot token for authenticating forwarded Gateway events (required for interaction handling). */
|
||||
botToken?: string;
|
||||
@@ -121,6 +130,19 @@ export function splitForLimit(text: string, limit: number): string[] {
|
||||
|
||||
export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter {
|
||||
const { adapter } = config;
|
||||
// The instance name becomes a webhook route segment (the route regex is
|
||||
// [^/?]+) and ':' is the state-namespace delimiter — reject anything that
|
||||
// would break either, at construction time rather than at first webhook.
|
||||
// Positive allow-list (not a deny-list): also rejects '' and
|
||||
// whitespace-only names, which are config bugs — '' is falsy, so it
|
||||
// would skip a truthiness guard, dead-end the webhook route, and
|
||||
// collapse the state namespace into the default instance's keyspace.
|
||||
if (config.instance !== undefined && !/^[A-Za-z0-9._-]+$/.test(config.instance)) {
|
||||
throw new Error(
|
||||
`chat-sdk bridge instance ${JSON.stringify(config.instance)} must be URL-safe: ` +
|
||||
`non-empty, only letters, digits, '.', '_' or '-'`,
|
||||
);
|
||||
}
|
||||
const transformText = (t: string): string => (config.transformOutboundText ? config.transformOutboundText(t) : t);
|
||||
let chat: Chat;
|
||||
let state: SqliteStateAdapter;
|
||||
@@ -193,14 +215,21 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
}
|
||||
|
||||
const bridge: ChannelAdapter = {
|
||||
name: adapter.name,
|
||||
channelType: adapter.name,
|
||||
name: config.instance ?? adapter.name,
|
||||
channelType: adapter.name, // unchanged — semantic platform key
|
||||
instance: config.instance, // undefined ⇒ default instance
|
||||
|
||||
supportsThreads: config.supportsThreads,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
|
||||
state = new SqliteStateAdapter();
|
||||
// State namespace: ONLY for a named non-default instance. A skill
|
||||
// that explicitly names the primary instance after the platform
|
||||
// (instance === adapter.name) still lands on the legacy UNPREFIXED
|
||||
// keyspace — prefixing the default would orphan every live install's
|
||||
// chat_sdk_subscriptions/kv/locks/lists rows.
|
||||
state = new SqliteStateAdapter(config.instance && config.instance !== adapter.name ? config.instance : undefined);
|
||||
|
||||
chat = new Chat({
|
||||
adapters: { [adapter.name]: adapter },
|
||||
@@ -284,11 +313,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||
|
||||
// Update the card to show the selected answer and remove buttons
|
||||
// Update the card to show the selected answer, who acted, and remove buttons
|
||||
const actorName = event.user?.userName || event.user?.fullName || '';
|
||||
const byLine = actorName ? ` — ${actorName}` : '';
|
||||
try {
|
||||
const tid = event.threadId;
|
||||
await adapter.editMessage(tid, event.messageId, {
|
||||
markdown: `${title}\n\n${selectedLabel}`,
|
||||
markdown: `${title}\n\n${selectedLabel}${byLine}`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('Failed to update card after action', { err });
|
||||
@@ -358,8 +389,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
startGateway();
|
||||
log.info('Gateway listener started', { adapter: adapter.name });
|
||||
} else {
|
||||
// Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server
|
||||
registerWebhookAdapter(chat, adapter.name);
|
||||
// Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the
|
||||
// shared webhook server. The handler key stays adapter.name (the
|
||||
// Chat instance's webhooks map is keyed by it); the route segment is
|
||||
// the instance, so each same-platform bridge gets its own URL (and
|
||||
// its own signing secret — platforms sign per-app).
|
||||
registerWebhookAdapter(chat, adapter.name, config.instance ?? adapter.name);
|
||||
}
|
||||
|
||||
log.info('Chat SDK bridge initialized', { adapter: adapter.name });
|
||||
|
||||
@@ -90,8 +90,8 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
now(),
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (?, 'telegram', 'tg-1', 'chat', 1, 'strict', ?)`,
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (?, 'telegram', 'tg-1', 'telegram', 'chat', 1, 'strict', ?)`,
|
||||
).run(MGID, now());
|
||||
|
||||
db.prepare(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveProviderName } from './container-runner.js';
|
||||
@@ -25,3 +27,22 @@ describe('resolveProviderName', () => {
|
||||
expect(resolveProviderName(null, '')).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildContainerArgs ordering invariant (structural)', () => {
|
||||
// The OneCLI gateway apply (SDK applyContainerConfig) appends credential-stub
|
||||
// mounts — e.g. the codex auth.json sentinel nested INSIDE our RW
|
||||
// /home/node/.codex mount. Docker applies binds in argument order, so the
|
||||
// stub must land AFTER its parent mount or the parent shadows it and the
|
||||
// agent silently degrades to loginless auth. Driving the real
|
||||
// buildContainerArgs needs a live gateway + container runtime, so this
|
||||
// guards the invariant structurally: the gateway apply must appear after
|
||||
// the volume-mounts loop in the source.
|
||||
it('applies the OneCLI gateway after the volume mounts', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
const mountsLoop = src.indexOf('for (const mount of mounts)');
|
||||
const gatewayApply = src.indexOf('onecli.applyContainerConfig');
|
||||
expect(mountsLoop).toBeGreaterThan(-1);
|
||||
expect(gatewayApply).toBeGreaterThan(-1);
|
||||
expect(gatewayApply).toBeGreaterThan(mountsLoop);
|
||||
});
|
||||
});
|
||||
|
||||
+67
-48
@@ -36,6 +36,7 @@ import { validateAdditionalMounts } from './modules/mount-security/index.js';
|
||||
import './providers/index.js';
|
||||
import {
|
||||
getProviderContainerConfig,
|
||||
providerProvidesAgentSurfaces,
|
||||
type ProviderContainerContribution,
|
||||
type VolumeMount,
|
||||
} from './providers/provider-container-registry.js';
|
||||
@@ -127,12 +128,19 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
// and buildContainerArgs so we don't re-read.
|
||||
const containerConfig = materializeContainerJson(agentGroup.id);
|
||||
|
||||
// Per-group filesystem state lives forever after first creation. Init is
|
||||
// idempotent: it only writes paths that don't already exist, so this call
|
||||
// is a no-op for groups that have spawned before. Runs before the provider
|
||||
// contribution so a surfaces-providing provider finds the group dir ready.
|
||||
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
|
||||
initGroupFilesystem(agentGroup, { provider: providerName });
|
||||
|
||||
// Resolve the effective provider + any host-side contribution it declares
|
||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
||||
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
|
||||
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
|
||||
|
||||
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
|
||||
const mounts = buildMounts(agentGroup, session, containerConfig, provider, contribution);
|
||||
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
|
||||
// OneCLI agent identifier is always the agent group id — stable across
|
||||
// sessions and reversible via getAgentGroup() for approval routing.
|
||||
@@ -234,32 +242,37 @@ function resolveProviderContribution(
|
||||
? fn({
|
||||
sessionDir: sessionDir(agentGroup.id, session.id),
|
||||
agentGroupId: agentGroup.id,
|
||||
groupDir: path.resolve(GROUPS_DIR, agentGroup.folder),
|
||||
selectedSkills: selectedSkillNames(containerConfig),
|
||||
hostEnv: process.env,
|
||||
})
|
||||
: {};
|
||||
return { provider, contribution };
|
||||
}
|
||||
|
||||
function buildMounts(
|
||||
export function buildMounts(
|
||||
agentGroup: AgentGroup,
|
||||
session: Session,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
provider: string,
|
||||
providerContribution: ProviderContainerContribution,
|
||||
): VolumeMount[] {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Per-group filesystem state lives forever after first creation. Init is
|
||||
// idempotent: it only writes paths that don't already exist, so this call
|
||||
// is a no-op for groups that have spawned before.
|
||||
initGroupFilesystem(agentGroup);
|
||||
// Default agent surfaces (composed project doc, skill links, provider state
|
||||
// dir) apply unless the provider's registration declares it provides its
|
||||
// own — a capability, never a provider name. See provider-container-registry.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(provider);
|
||||
|
||||
// Sync skill symlinks based on container.json selection before mounting.
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
|
||||
syncSkillSymlinks(claudeDir, containerConfig);
|
||||
if (defaultSurfaces) {
|
||||
// Sync skill symlinks based on container.json selection before mounting.
|
||||
syncSkillSymlinks(claudeDir, containerConfig);
|
||||
|
||||
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
|
||||
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
|
||||
composeGroupClaudeMd(agentGroup);
|
||||
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
|
||||
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
|
||||
composeGroupClaudeMd(agentGroup);
|
||||
}
|
||||
|
||||
const mounts: VolumeMount[] = [];
|
||||
const sessDir = sessionDir(agentGroup.id, session.id);
|
||||
@@ -286,11 +299,11 @@ function buildMounts(
|
||||
// already RO-mounted, so writes through it fail regardless — no need for
|
||||
// a nested mount there.
|
||||
const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
|
||||
if (fs.existsSync(composedClaudeMd)) {
|
||||
if (defaultSurfaces && fs.existsSync(composedClaudeMd)) {
|
||||
mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
|
||||
}
|
||||
const fragmentsDir = path.join(groupDir, '.claude-fragments');
|
||||
if (fs.existsSync(fragmentsDir)) {
|
||||
if (defaultSurfaces && fs.existsSync(fragmentsDir)) {
|
||||
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
|
||||
}
|
||||
|
||||
@@ -303,13 +316,15 @@ function buildMounts(
|
||||
// Shared CLAUDE.md — read-only, imported by the composed entry point via
|
||||
// the `.claude-shared.md` symlink inside the group dir.
|
||||
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
|
||||
if (fs.existsSync(sharedClaudeMd)) {
|
||||
if (defaultSurfaces && fs.existsSync(sharedClaudeMd)) {
|
||||
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
|
||||
}
|
||||
|
||||
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
|
||||
// skill symlinks)
|
||||
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
|
||||
if (defaultSurfaces) {
|
||||
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
|
||||
}
|
||||
|
||||
// Shared agent-runner source — read-only, same code for all groups.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
@@ -346,25 +361,7 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Determine desired skill set
|
||||
const projectRoot = process.cwd();
|
||||
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
|
||||
let desired: string[];
|
||||
if (containerConfig.skills === 'all') {
|
||||
// Recompute from shared dir — newly-added upstream skills appear automatically
|
||||
desired = fs.existsSync(sharedSkillsDir)
|
||||
? fs.readdirSync(sharedSkillsDir).filter((e) => {
|
||||
try {
|
||||
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
} else {
|
||||
desired = containerConfig.skills;
|
||||
}
|
||||
|
||||
const desired = selectedSkillNames(containerConfig);
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Remove symlinks not in the desired set
|
||||
@@ -397,6 +394,24 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the group's skill selection to concrete names — `'all'` recomputes
|
||||
* from `container/skills/` so newly-added upstream skills appear automatically.
|
||||
*/
|
||||
function selectedSkillNames(containerConfig: import('./container-config.js').ContainerConfig): string[] {
|
||||
if (containerConfig.skills !== 'all') return containerConfig.skills;
|
||||
const sharedSkillsDir = path.join(process.cwd(), 'container', 'skills');
|
||||
return fs.existsSync(sharedSkillsDir)
|
||||
? fs.readdirSync(sharedSkillsDir).filter((e) => {
|
||||
try {
|
||||
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
async function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
@@ -419,20 +434,6 @@ async function buildContainerArgs(
|
||||
}
|
||||
}
|
||||
|
||||
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
|
||||
// are routed through the agent vault for credential injection. Treated as
|
||||
// a transient hard failure: if we can't wire the gateway, we don't spawn.
|
||||
// The caller (router or host-sweep) catches the throw, leaves the inbound
|
||||
// message pending, and the next sweep tick retries.
|
||||
if (agentIdentifier) {
|
||||
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
|
||||
}
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
|
||||
if (!onecliApplied) {
|
||||
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
|
||||
}
|
||||
log.info('OneCLI gateway applied', { containerName });
|
||||
|
||||
// Egress lockdown when enabled — throws if it can't be established, aborting
|
||||
// the spawn rather than running with open egress. Otherwise the host gateway.
|
||||
if (ensureEgressNetwork()) {
|
||||
@@ -459,6 +460,24 @@ async function buildContainerArgs(
|
||||
}
|
||||
}
|
||||
|
||||
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
|
||||
// are routed through the agent vault for credential injection, and mounts
|
||||
// any credential stubs the gateway serves (e.g. a sentinel auth file).
|
||||
// Runs AFTER the volume mounts so a stub nested inside one of our mounts
|
||||
// (a parent dir mounted RW above it) lands later in the args and isn't
|
||||
// shadowed by it. Treated as a transient hard failure: if we can't wire
|
||||
// the gateway, we don't spawn. The caller (router or host-sweep) catches
|
||||
// the throw, leaves the inbound message pending, and the next sweep tick
|
||||
// retries.
|
||||
if (agentIdentifier) {
|
||||
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
|
||||
}
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
|
||||
if (!onecliApplied) {
|
||||
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
|
||||
}
|
||||
log.info('OneCLI gateway applied', { containerName });
|
||||
|
||||
// Override entrypoint: run v2 entry point directly via Bun (no tsc, no stdin).
|
||||
args.push('--entrypoint', 'bash');
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Channel-instance dimension tests (migration 016 + messaging-groups queries).
|
||||
*
|
||||
* Covers the three load-bearing rules:
|
||||
* 1. Backfill/default — instance = channel_type everywhere it isn't set,
|
||||
* so single-instance installs behave byte-identically.
|
||||
* 2. UNIQUE(channel_type, platform_id, instance) — siblings coexist,
|
||||
* single-bot pair-uniqueness is preserved via the default value.
|
||||
* 3. Lookup asymmetry — inbound (getMessagingGroupWithAgentCount) is
|
||||
* exact-on-instance with NO fallback (unknown named instance ⇒ null ⇒
|
||||
* router auto-creates instead of hijacking a sibling's row); outbound
|
||||
* (getMessagingGroupByPlatform) is default-instance-first.
|
||||
*
|
||||
* The wired-DB arm reproduces the failure mode that bit migration 011: a
|
||||
* table recreate on a live DB with FK children. It must pass with
|
||||
* disableForeignKeys: true and fail without it.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, getDb } from './connection.js';
|
||||
import { runMigrations, migrations, type Migration } from './migrations/index.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
getMessagingGroupByPlatform,
|
||||
getMessagingGroupWithAgentCount,
|
||||
} from './messaging-groups.js';
|
||||
import type { MessagingGroup } from '../types.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function mg(overrides: Partial<MessagingGroup> & { id: string }): MessagingGroup {
|
||||
return {
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
name: null,
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('migration 016 — fresh DB', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('adds a NOT NULL instance column', () => {
|
||||
const cols = getDb().prepare("PRAGMA table_info('messaging_groups')").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const instance = cols.find((c) => c.name === 'instance');
|
||||
expect(instance).toBeDefined();
|
||||
expect(instance!.notnull).toBe(1);
|
||||
});
|
||||
|
||||
it('createMessagingGroup without instance stamps instance = channel_type', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-default' }));
|
||||
const row = getDb().prepare("SELECT instance FROM messaging_groups WHERE id = 'mg-default'").get() as {
|
||||
instance: string;
|
||||
};
|
||||
expect(row.instance).toBe('slack');
|
||||
});
|
||||
|
||||
it('allows sibling instances on the same (channel_type, platform_id)', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-default' }));
|
||||
createMessagingGroup(mg({ id: 'mg-tester', instance: 'slack-tester' }));
|
||||
const count = getDb().prepare('SELECT COUNT(*) AS c FROM messaging_groups').get() as { c: number };
|
||||
expect(count.c).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects a duplicate (channel_type, platform_id, instance) triple', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-a', instance: 'slack-tester' }));
|
||||
expect(() => createMessagingGroup(mg({ id: 'mg-b', instance: 'slack-tester' }))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a duplicate default pair (single-bot uniqueness preserved)', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-a' }));
|
||||
expect(() => createMessagingGroup(mg({ id: 'mg-b' }))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration 016 — wired legacy DB upgrade (the FK recreate arm)', () => {
|
||||
it('recreates messaging_groups under FK children without violations and backfills instance', () => {
|
||||
const db = initTestDb();
|
||||
// Bring the DB to the pre-016 schema.
|
||||
runMigrations(
|
||||
db,
|
||||
migrations.filter((m) => m.name !== 'messaging-group-instance'),
|
||||
);
|
||||
const preCols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
expect(preCols.some((c) => c.name === 'instance')).toBe(false);
|
||||
|
||||
// Seed a wired install: messaging_groups with live FK children
|
||||
// (messaging_group_agents + sessions reference messaging_groups.id).
|
||||
// Raw SQL — the new createMessagingGroup expects the instance column.
|
||||
db.prepare("INSERT INTO agent_groups (id, name, folder, created_at) VALUES ('ag-1', 'A', 'a', ?)").run(now());
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-1', 'telegram', 'telegram:123', 'Chat', 0, 'public', ?)`,
|
||||
).run(now());
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, engage_mode, sender_scope, ignored_message_policy, created_at)
|
||||
VALUES ('mga-1', 'mg-1', 'ag-1', 'pattern', 'all', 'drop', ?)`,
|
||||
).run(now());
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, agent_group_id, messaging_group_id, created_at)
|
||||
VALUES ('sess-1', 'ag-1', 'mg-1', ?)`,
|
||||
).run(now());
|
||||
|
||||
// Upgrade: only 016 is pending now. Without disableForeignKeys this
|
||||
// throws 'FOREIGN KEY constraint failed' at DROP TABLE.
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
|
||||
// Backfill: existing row got instance = channel_type.
|
||||
const row = db.prepare("SELECT instance FROM messaging_groups WHERE id = 'mg-1'").get() as { instance: string };
|
||||
expect(row.instance).toBe('telegram');
|
||||
|
||||
// Children intact and pointing at the recreated parent.
|
||||
expect(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = 'mg-1'").get(),
|
||||
).toEqual({ c: 1 });
|
||||
expect(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE messaging_group_id = 'mg-1'").get()).toEqual({ c: 1 });
|
||||
|
||||
// Full-DB FK integrity (FK enforcement was restored by the runner).
|
||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||
expect(db.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('tolerates pre-existing FK orphans: the migration still applies (no boot crash-loop)', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(
|
||||
db,
|
||||
migrations.filter((m) => m.name !== 'messaging-group-instance'),
|
||||
);
|
||||
|
||||
// Seed the orphan class that demonstrably exists on live installs
|
||||
// (ensureUserDm tolerates it at runtime): a user_dms row whose
|
||||
// messaging_group was deleted through a FK-OFF connection — the
|
||||
// sqlite3 CLI ships with foreign_keys OFF, and operators are told to
|
||||
// poke v2.db when troubleshooting.
|
||||
db.prepare("INSERT INTO users (id, kind, created_at) VALUES ('slack:U1', 'slack', ?)").run(now());
|
||||
db.pragma('foreign_keys = OFF');
|
||||
db.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES ('slack:U1', 'slack', 'mg-deleted-via-cli', ?)`,
|
||||
).run(now());
|
||||
db.pragma('foreign_keys = ON');
|
||||
expect(db.pragma('foreign_key_check')).toHaveLength(1);
|
||||
|
||||
// 016 did not create this violation — it must still apply (the runner
|
||||
// diffs post-up violations against a pre-up snapshot and only throws
|
||||
// on NEW ones; pre-existing ones are warned about and carried through).
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
expect(cols.some((c) => c.name === 'instance')).toBe(true);
|
||||
|
||||
// The orphan is untouched: still present, still the only violation.
|
||||
expect(db.pragma('foreign_key_check')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still rejects a migration that ITSELF introduces FK violations', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
const rogue: Migration = {
|
||||
version: 999,
|
||||
name: 'test-rogue-fk-violation',
|
||||
disableForeignKeys: true,
|
||||
up: (d) => {
|
||||
d.prepare("INSERT INTO users (id, kind, created_at) VALUES ('slack:U-rogue', 'slack', datetime('now'))").run();
|
||||
d.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES ('slack:U-rogue', 'slack', 'mg-never-existed', datetime('now'))`,
|
||||
).run();
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => runMigrations(db, [...migrations, rogue])).toThrow(/left FK violations/);
|
||||
|
||||
// Rolled back atomically: not recorded as applied, nothing committed.
|
||||
expect(db.prepare("SELECT 1 FROM schema_version WHERE name = 'test-rogue-fk-violation'").get()).toBeUndefined();
|
||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('is idempotent — re-running the full barrel is a no-op', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createMessagingGroup(mg({ id: 'mg-keep', instance: 'slack-tester' }));
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
const row = db.prepare("SELECT instance FROM messaging_groups WHERE id = 'mg-keep'").get() as {
|
||||
instance: string;
|
||||
};
|
||||
expect(row.instance).toBe('slack-tester');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookup asymmetry — inbound exact-only vs outbound default-first', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
// The named instance ('alpha-tester') sorts lexically BEFORE the
|
||||
// channel type ('slack') and is inserted first — so both rowid order
|
||||
// and the triple-autoindex order put it ahead of the default row.
|
||||
// A query missing the `(instance = channel_type) DESC` ORDER BY would
|
||||
// return it; only the deterministic default-first ordering picks
|
||||
// mg-default.
|
||||
createMessagingGroup(mg({ id: 'mg-tester', instance: 'alpha-tester' }));
|
||||
createMessagingGroup(mg({ id: 'mg-default' }));
|
||||
});
|
||||
|
||||
it('getMessagingGroupWithAgentCount without instance resolves the default-instance row', () => {
|
||||
const found = getMessagingGroupWithAgentCount('slack', 'slack:C1');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.mg.id).toBe('mg-default');
|
||||
});
|
||||
|
||||
it('getMessagingGroupWithAgentCount with a named instance resolves exactly that row', () => {
|
||||
const found = getMessagingGroupWithAgentCount('slack', 'slack:C1', 'alpha-tester');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.mg.id).toBe('mg-tester');
|
||||
});
|
||||
|
||||
it('getMessagingGroupWithAgentCount with an unknown instance returns null (no-hijack rule)', () => {
|
||||
expect(getMessagingGroupWithAgentCount('slack', 'slack:C1', 'slack-unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('getMessagingGroupByPlatform without instance prefers the default-instance row', () => {
|
||||
const found = getMessagingGroupByPlatform('slack', 'slack:C1');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe('mg-default');
|
||||
});
|
||||
|
||||
it('getMessagingGroupByPlatform with explicit instance is exact', () => {
|
||||
expect(getMessagingGroupByPlatform('slack', 'slack:C1', 'alpha-tester')!.id).toBe('mg-tester');
|
||||
expect(getMessagingGroupByPlatform('slack', 'slack:C1', 'slack-unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getMessagingGroupByPlatform falls back deterministically when only named instances exist', () => {
|
||||
const db = getDb();
|
||||
db.prepare("DELETE FROM messaging_groups WHERE id = 'mg-default'").run();
|
||||
createMessagingGroup(mg({ id: 'mg-zeta', instance: 'zeta' }));
|
||||
const found = getMessagingGroupByPlatform('slack', 'slack:C1');
|
||||
// Lexically-first named instance: 'alpha-tester' < 'zeta'.
|
||||
expect(found!.id).toBe('mg-tester');
|
||||
});
|
||||
});
|
||||
@@ -21,19 +21,43 @@ import { getDb, hasTable } from './connection.js';
|
||||
export function createMessagingGroup(group: MessagingGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @unknown_sender_policy, @created_at)`,
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @instance, @name, @is_group, @unknown_sender_policy, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
.run({ ...group, instance: group.instance ?? group.channel_type });
|
||||
}
|
||||
|
||||
export function getMessagingGroup(id: string): MessagingGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups WHERE id = ?').get(id) as MessagingGroup | undefined;
|
||||
}
|
||||
|
||||
export function getMessagingGroupByPlatform(channelType: string, platformId: string): MessagingGroup | undefined {
|
||||
/**
|
||||
* Outbound / cold-DM / setup lookup by platform address.
|
||||
*
|
||||
* Instance semantics are deliberately ASYMMETRIC with the router's
|
||||
* `getMessagingGroupWithAgentCount` (exact-only): outbound callers usually
|
||||
* don't know (or care) which adapter instance owns a chat, so an unset
|
||||
* `instance` resolves the default instance first (instance = channel_type),
|
||||
* falling back deterministically to the lexically-first named instance.
|
||||
* A set `instance` is exact-only — unknown instance returns undefined.
|
||||
*/
|
||||
export function getMessagingGroupByPlatform(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
instance?: string,
|
||||
): MessagingGroup | undefined {
|
||||
if (instance !== undefined) {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM messaging_groups WHERE channel_type = ? AND platform_id = ? AND instance = ?')
|
||||
.get(channelType, platformId, instance) as MessagingGroup | undefined;
|
||||
}
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM messaging_groups WHERE channel_type = ? AND platform_id = ?')
|
||||
.prepare(
|
||||
`SELECT * FROM messaging_groups
|
||||
WHERE channel_type = ? AND platform_id = ?
|
||||
ORDER BY (instance = channel_type) DESC, instance ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(channelType, platformId) as MessagingGroup | undefined;
|
||||
}
|
||||
|
||||
@@ -46,23 +70,31 @@ export function getMessagingGroupByPlatform(channelType: string, platformId: str
|
||||
*
|
||||
* Returns `null` when no messaging_groups row exists for this channel.
|
||||
* Returns `{ mg, agentCount: 0 }` when the row exists but has no wired
|
||||
* agents. Uses the `UNIQUE(channel_type, platform_id)` index plus the
|
||||
* `UNIQUE(messaging_group_id, agent_group_id)` index for the JOIN — both
|
||||
* agents. Uses the `UNIQUE(channel_type, platform_id, instance)` index plus
|
||||
* the `UNIQUE(messaging_group_id, agent_group_id)` index for the JOIN — both
|
||||
* covered by existing SQLite auto-indexes from the UNIQUE constraints.
|
||||
*
|
||||
* `instance` is EXACT-ONLY, with no fallback — deliberately asymmetric with
|
||||
* `getMessagingGroupByPlatform`'s default-instance-first resolution. An
|
||||
* unknown named instance must return null so the router auto-creates a
|
||||
* per-instance group instead of hijacking a sibling instance's row. The
|
||||
* default param (= channelType) keeps instance-less callers resolving the
|
||||
* default instance, identical to pre-instance behavior.
|
||||
*/
|
||||
export function getMessagingGroupWithAgentCount(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
instance: string = channelType,
|
||||
): { mg: MessagingGroup; agentCount: number } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT mg.*, COUNT(mga.id) AS agent_count
|
||||
FROM messaging_groups mg
|
||||
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
|
||||
WHERE mg.channel_type = ? AND mg.platform_id = ?
|
||||
WHERE mg.channel_type = ? AND mg.platform_id = ? AND mg.instance = ?
|
||||
GROUP BY mg.id`,
|
||||
)
|
||||
.get(channelType, platformId) as (MessagingGroup & { agent_count: number }) | undefined;
|
||||
.get(channelType, platformId, instance) as (MessagingGroup & { agent_count: number }) | undefined;
|
||||
if (!row) return null;
|
||||
const { agent_count, ...mg } = row;
|
||||
return { mg: mg as MessagingGroup, agentCount: agent_count };
|
||||
@@ -72,6 +104,12 @@ export function getAllMessagingGroups(): MessagingGroup[] {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups ORDER BY name').all() as MessagingGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* All messaging groups on a platform, across every adapter instance.
|
||||
* Semantics intentionally unchanged by the instance dimension — channel_type
|
||||
* stays the semantic platform key. No live caller today; if a caller needs
|
||||
* a single instance's rows, filter on `mg.instance`.
|
||||
*/
|
||||
export function getMessagingGroupsByChannel(channelType: string): MessagingGroup[] {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups WHERE channel_type = ?').all(channelType) as MessagingGroup[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Channel-instance dimension on messaging_groups.
|
||||
*
|
||||
* `instance` names the adapter instance that owns a chat — N adapters of one
|
||||
* platform (e.g. three Slack apps in one workspace) each get their own
|
||||
* messaging_groups rows. The default instance IS the channel type: every
|
||||
* existing row is backfilled with `instance = channel_type`, so all existing
|
||||
* lookups keep resolving the same rows with zero operator action. NOT NULL
|
||||
* (instead of nullable + partial unique index) keeps every lookup two-state:
|
||||
* "default instance" is just the literal value `channel_type`.
|
||||
*
|
||||
* Uniqueness relaxes from UNIQUE(channel_type, platform_id) to
|
||||
* UNIQUE(channel_type, platform_id, instance). SQLite cannot relax a
|
||||
* table-level UNIQUE in place — this requires the documented 12-step
|
||||
* recreate (new table → copy → DROP → RENAME, sqlite.org/lang_altertable.html).
|
||||
* DROP TABLE fails `FOREIGN KEY constraint failed` on live DBs because five
|
||||
* child tables REFERENCE messaging_groups(id) (messaging_group_agents,
|
||||
* user_dms, sessions, pending_sender_approvals, pending_channel_approvals) —
|
||||
* the exact failure that forced migration 011 to abandon its rebuild (see
|
||||
* its header). Hence `disableForeignKeys: true`: the runner toggles
|
||||
* foreign_keys=OFF around the transaction (the pragma is a no-op inside one)
|
||||
* and runs PRAGMA foreign_key_check inside it so violations roll back.
|
||||
*
|
||||
* Column list mirrors the live tip schema exactly (001 columns + 012's
|
||||
* denied_at) — verified against PRAGMA table_info on a freshly-migrated DB.
|
||||
* A recreate with a stale column list silently drops data.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration016: Migration = {
|
||||
version: 16,
|
||||
name: 'messaging-group-instance',
|
||||
disableForeignKeys: true,
|
||||
up: (db: Database.Database) => {
|
||||
// Idempotency guard per the 012 pattern.
|
||||
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
if (cols.some((c) => c.name === 'instance')) return;
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE messaging_groups_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
created_at TEXT NOT NULL,
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
INSERT INTO messaging_groups_new
|
||||
(id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at, denied_at)
|
||||
SELECT id, channel_type, platform_id, channel_type, name, is_group, unknown_sender_policy, created_at, denied_at
|
||||
FROM messaging_groups;
|
||||
DROP TABLE messaging_groups;
|
||||
ALTER TABLE messaging_groups_new RENAME TO messaging_groups;
|
||||
`);
|
||||
},
|
||||
};
|
||||
+70
-13
@@ -12,6 +12,7 @@ import { migration012 } from './012-channel-registration.js';
|
||||
import { migration013 } from './013-approval-render-metadata.js';
|
||||
import { migration014 } from './014-container-configs.js';
|
||||
import { migration015 } from './015-cli-scope.js';
|
||||
import { migration016 } from './016-messaging-group-instance.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
|
||||
@@ -19,9 +20,18 @@ export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: Database.Database) => void;
|
||||
/**
|
||||
* Run with foreign_keys=OFF. Required for table recreates (SQLite can't
|
||||
* drop a table-level UNIQUE without DROP+RENAME, and DROP fails FK
|
||||
* integrity when child rows exist — see migration 011's header).
|
||||
* PRAGMA foreign_keys is a no-op inside a transaction, so the runner
|
||||
* toggles it around the transaction and runs PRAGMA foreign_key_check
|
||||
* inside it, so violations roll the migration back.
|
||||
*/
|
||||
disableForeignKeys?: boolean;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [
|
||||
export const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002,
|
||||
moduleApprovalsPendingApprovals,
|
||||
@@ -35,9 +45,23 @@ const migrations: Migration[] = [
|
||||
migration013,
|
||||
migration014,
|
||||
migration015,
|
||||
migration016,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
|
||||
* parent-table recreate (child tables aren't touched), so this JSON identity
|
||||
* is a reliable before/after diff key. */
|
||||
interface FkViolation {
|
||||
table: string;
|
||||
rowid: number | null;
|
||||
parent: string;
|
||||
fkid: number;
|
||||
}
|
||||
|
||||
const fkIdentity = (v: FkViolation): string =>
|
||||
JSON.stringify({ table: v.table, rowid: v.rowid, parent: v.parent, fkid: v.fkid });
|
||||
|
||||
export function runMigrations(db: Database.Database, list: Migration[] = migrations): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -56,22 +80,55 @@ export function runMigrations(db: Database.Database): void {
|
||||
const applied = new Set<string>(
|
||||
(db.prepare('SELECT name FROM schema_version').all() as { name: string }[]).map((r) => r.name),
|
||||
);
|
||||
const pending = migrations.filter((m) => !applied.has(m.name));
|
||||
const pending = list.filter((m) => !applied.has(m.name));
|
||||
if (pending.length === 0) return;
|
||||
|
||||
log.info('Running migrations', { count: pending.length });
|
||||
|
||||
for (const m of pending) {
|
||||
db.transaction(() => {
|
||||
m.up(db);
|
||||
const next = (db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number })
|
||||
.v;
|
||||
db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run(
|
||||
next,
|
||||
m.name,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
})();
|
||||
// Table recreates need FK enforcement off for the DROP+RENAME window.
|
||||
// The pragma must be toggled OUTSIDE the transaction (it's a silent
|
||||
// no-op inside one); foreign_key_check runs INSIDE so a violating
|
||||
// recreate rolls back atomically with nothing committed.
|
||||
if (m.disableForeignKeys) db.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Snapshot violations BEFORE up() runs: live DBs can carry latent
|
||||
// FK orphans (e.g. parents deleted through a FK-OFF sqlite3 CLI
|
||||
// session — ensureUserDm tolerates exactly this at runtime). The
|
||||
// migration must only fail for violations it INTRODUCED; throwing
|
||||
// on pre-existing ones would crash-loop the host at every boot
|
||||
// (runMigrations runs on startup) until manual DB surgery.
|
||||
const preexisting = m.disableForeignKeys
|
||||
? new Set((db.pragma('foreign_key_check') as FkViolation[]).map(fkIdentity))
|
||||
: null;
|
||||
m.up(db);
|
||||
if (m.disableForeignKeys && preexisting) {
|
||||
const violations = db.pragma('foreign_key_check') as FkViolation[];
|
||||
const introduced = violations.filter((v) => !preexisting.has(fkIdentity(v)));
|
||||
const carried = violations.length - introduced.length;
|
||||
if (carried > 0) {
|
||||
log.warn('Pre-existing FK violations carried through migration (not introduced by it)', {
|
||||
migration: m.name,
|
||||
count: carried,
|
||||
});
|
||||
}
|
||||
if (introduced.length > 0) {
|
||||
throw new Error(`migration ${m.name} left FK violations: ${JSON.stringify(introduced.slice(0, 5))}`);
|
||||
}
|
||||
}
|
||||
const next = (
|
||||
db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number }
|
||||
).v;
|
||||
db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run(
|
||||
next,
|
||||
m.name,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
})();
|
||||
} finally {
|
||||
if (m.disableForeignKeys) db.pragma('foreign_keys = ON');
|
||||
}
|
||||
log.info('Migration applied', { name: m.name });
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -22,16 +22,21 @@ CREATE TABLE agent_groups (
|
||||
-- only matters if something inserts without specifying the field, which no
|
||||
-- current callsite does. Router auto-create hardcodes "request_approval"
|
||||
-- (see src/router.ts:151); setup scripts pick per context.
|
||||
-- instance = adapter-instance name; the default instance IS the channel
|
||||
-- type (migration 016 backfill), so single-instance installs never see it.
|
||||
-- Inbound lookups are exact-on-instance; outbound lookups default-first.
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
-- 'strict' | 'request_approval' | 'public'
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Delivery action registry.
|
||||
*
|
||||
* `registerDeliveryAction` is the hook modules use to handle system-kind
|
||||
* outbound messages; `getDeliveryAction` is the read side that makes those
|
||||
* registrations behavior-testable. Goes red if either half of the registry
|
||||
* is removed or the two stop sharing the same map.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('./container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
killContainer: vi.fn(),
|
||||
buildAgentGroupImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { registerDeliveryAction, getDeliveryAction, type DeliveryActionHandler } from './delivery.js';
|
||||
|
||||
describe('delivery action registry', () => {
|
||||
it('getDeliveryAction returns the handler registerDeliveryAction registered', () => {
|
||||
const handler: DeliveryActionHandler = async () => {};
|
||||
registerDeliveryAction('test_registry_action', handler);
|
||||
expect(getDeliveryAction('test_registry_action')).toBe(handler);
|
||||
});
|
||||
|
||||
it('getDeliveryAction returns undefined for unregistered actions', () => {
|
||||
expect(getDeliveryAction('test_never_registered_action')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-registering an action overwrites the previous handler', () => {
|
||||
const first: DeliveryActionHandler = async () => {};
|
||||
const second: DeliveryActionHandler = async () => {};
|
||||
registerDeliveryAction('test_overwrite_action', first);
|
||||
registerDeliveryAction('test_overwrite_action', second);
|
||||
expect(getDeliveryAction('test_overwrite_action')).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,76 @@ describe('deliverSessionMessages — retry and permanent failure', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — instance resolution', () => {
|
||||
it('delivers via the origin session instance when sibling rows share (channel_type, platform_id)', async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Test Agent',
|
||||
folder: 'test-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
// Two instances own the same chat address. The named row sorts before
|
||||
// 'slack', so a plain by-platform lookup (default-instance-first) would
|
||||
// pick mg-default — only origin-session preference selects mg-tester.
|
||||
createMessagingGroup({
|
||||
id: 'mg-default',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
name: 'Default',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-tester',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
instance: 'alpha-tester',
|
||||
name: 'Tester',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session } = resolveSession('ag-1', 'mg-tester', null, 'shared');
|
||||
const db = new Database(outboundDbPath('ag-1', session.id));
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content)
|
||||
VALUES ('out-inst', datetime('now'), 'chat', 'slack:C1', 'slack', ?)`,
|
||||
).run(JSON.stringify({ text: 'hi' }));
|
||||
db.close();
|
||||
|
||||
const instances: Array<string | undefined> = [];
|
||||
setDeliveryAdapter({
|
||||
async deliver(_ct, _pid, _tid, _kind, _content, _files, instance) {
|
||||
instances.push(instance);
|
||||
return 'plat-1';
|
||||
},
|
||||
});
|
||||
|
||||
await deliverSessionMessages(session);
|
||||
expect(instances).toEqual(['alpha-tester']);
|
||||
});
|
||||
|
||||
it('default session passes the backfilled default instance (= channel_type)', async () => {
|
||||
seedAgentAndChannel();
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
insertOutbound('ag-1', session.id, 'out-default-inst');
|
||||
|
||||
const instances: Array<string | undefined> = [];
|
||||
setDeliveryAdapter({
|
||||
async deliver(_ct, _pid, _tid, _kind, _content, _files, instance) {
|
||||
instances.push(instance);
|
||||
return 'plat-2';
|
||||
},
|
||||
});
|
||||
|
||||
await deliverSessionMessages(session);
|
||||
expect(instances).toEqual(['telegram']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — permission check', () => {
|
||||
it('rejects delivery to an unauthorized channel destination', async () => {
|
||||
seedAgentAndChannel();
|
||||
|
||||
+23
-3
@@ -12,7 +12,7 @@ import type Database from 'better-sqlite3';
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import { getMessagingGroup, getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import {
|
||||
getDueOutboundMessages,
|
||||
getDeliveredIds,
|
||||
@@ -57,8 +57,11 @@ export interface ChannelDeliveryAdapter {
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: OutboundFile[],
|
||||
/** Delivering adapter instance (defaults to channelType downstream).
|
||||
* Host-internal only — containers never see instance. */
|
||||
instance?: string,
|
||||
): Promise<string | undefined>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null, instance?: string): Promise<void>;
|
||||
}
|
||||
|
||||
let deliveryAdapter: ChannelDeliveryAdapter | null = null;
|
||||
@@ -286,8 +289,18 @@ async function deliverMessage(
|
||||
// path in deliverSessionMessages and eventually marks the message as failed
|
||||
// (instead of marking it delivered when nothing was actually delivered,
|
||||
// which was the pre-refactor bug).
|
||||
let deliverInstance: string | undefined;
|
||||
if (msg.channel_type && msg.platform_id) {
|
||||
const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
|
||||
// Resolve the messaging group ORIGIN-SESSION-FIRST: when the message
|
||||
// targets the session's own chat address, the origin row wins even if
|
||||
// sibling instances share the same (channel_type, platform_id) — so the
|
||||
// reply goes out through the instance the message came in on. Otherwise
|
||||
// fall back to the by-platform lookup (default-instance-first).
|
||||
const originMg = session.messaging_group_id ? getMessagingGroup(session.messaging_group_id) : undefined;
|
||||
const mg =
|
||||
originMg && originMg.channel_type === msg.channel_type && originMg.platform_id === msg.platform_id
|
||||
? originMg
|
||||
: getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
|
||||
if (!mg) {
|
||||
throw new Error(`unknown messaging group for ${msg.channel_type}/${msg.platform_id} (message ${msg.id})`);
|
||||
}
|
||||
@@ -308,6 +321,7 @@ async function deliverMessage(
|
||||
);
|
||||
}
|
||||
}
|
||||
deliverInstance = mg.instance;
|
||||
}
|
||||
|
||||
// Track pending questions for ask_user_question flow.
|
||||
@@ -360,6 +374,7 @@ async function deliverMessage(
|
||||
msg.kind,
|
||||
msg.content,
|
||||
files,
|
||||
deliverInstance,
|
||||
);
|
||||
log.info('Message delivered', {
|
||||
id: msg.id,
|
||||
@@ -402,6 +417,11 @@ export function registerDeliveryAction(action: string, handler: DeliveryActionHa
|
||||
actionHandlers.set(action, handler);
|
||||
}
|
||||
|
||||
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
|
||||
export function getDeliveryAction(action: string): DeliveryActionHandler | undefined {
|
||||
return actionHandlers.get(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system actions from the container agent.
|
||||
* These are written to messages_out because the container can't write to inbound.db.
|
||||
|
||||
+32
-20
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
const DEFAULT_SETTINGS_JSON =
|
||||
@@ -46,9 +47,18 @@ const DEFAULT_SETTINGS_JSON =
|
||||
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
|
||||
* per-group instructions (if provided) seed `CLAUDE.local.md`.
|
||||
*/
|
||||
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
|
||||
export function initGroupFilesystem(
|
||||
group: AgentGroup,
|
||||
opts?: { instructions?: string; provider?: string | null },
|
||||
): void {
|
||||
const initialized: string[] = [];
|
||||
|
||||
// Default agent surfaces apply unless the group's provider declares (at
|
||||
// registration) that it provides its own. Callers that don't know the
|
||||
// provider omit it — unregistered/unknown names report no capabilities,
|
||||
// so the default surfaces are written, exactly as before this seam.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
|
||||
|
||||
// 1. groups/<folder>/ — group memory + working dir
|
||||
const groupDir = path.resolve(GROUPS_DIR, group.folder);
|
||||
if (!fs.existsSync(groupDir)) {
|
||||
@@ -59,7 +69,7 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
|
||||
// Claude Code. Seeded with caller-provided instructions on first creation.
|
||||
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
|
||||
if (!fs.existsSync(claudeLocalFile)) {
|
||||
if (defaultSurfaces && !fs.existsSync(claudeLocalFile)) {
|
||||
const body = opts?.instructions ? opts.instructions + '\n' : '';
|
||||
fs.writeFileSync(claudeLocalFile, body);
|
||||
initialized.push('CLAUDE.local.md');
|
||||
@@ -71,26 +81,28 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
initialized.push('container_configs');
|
||||
|
||||
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
initialized.push('.claude-shared');
|
||||
}
|
||||
if (defaultSurfaces) {
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
initialized.push('.claude-shared');
|
||||
}
|
||||
|
||||
const settingsFile = path.join(claudeDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
|
||||
initialized.push('settings.json');
|
||||
} else {
|
||||
ensurePreCompactHook(settingsFile, initialized);
|
||||
}
|
||||
const settingsFile = path.join(claudeDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
|
||||
initialized.push('settings.json');
|
||||
} else {
|
||||
ensurePreCompactHook(settingsFile, initialized);
|
||||
}
|
||||
|
||||
// Skills directory — created empty here; symlinks are synced at spawn
|
||||
// time by container-runner.ts based on container.json skills selection.
|
||||
const skillsDst = path.join(claudeDir, 'skills');
|
||||
if (!fs.existsSync(skillsDst)) {
|
||||
fs.mkdirSync(skillsDst, { recursive: true });
|
||||
initialized.push('skills/');
|
||||
// Skills directory — created empty here; symlinks are synced at spawn
|
||||
// time by container-runner.ts based on container.json skills selection.
|
||||
const skillsDst = path.join(claudeDir, 'skills');
|
||||
if (!fs.existsSync(skillsDst)) {
|
||||
fs.mkdirSync(skillsDst, { recursive: true });
|
||||
initialized.push('skills/');
|
||||
}
|
||||
}
|
||||
|
||||
if (initialized.length > 0) {
|
||||
|
||||
@@ -597,6 +597,189 @@ describe('router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('router — channel instances', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Default Bot',
|
||||
folder: 'default-bot',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Tester Bot',
|
||||
folder: 'tester-bot',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
// Two messaging groups on the SAME (channel_type, platform_id), owned
|
||||
// by different adapter instances and wired to different agents.
|
||||
createMessagingGroup({
|
||||
id: 'mg-default',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
name: 'Default chat',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-tester',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
instance: 'slack-tester',
|
||||
name: 'Tester chat',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
for (const [mgaId, mgId, agId] of [
|
||||
['mga-default', 'mg-default', 'ag-1'],
|
||||
['mga-tester', 'mg-tester', 'ag-2'],
|
||||
] as const) {
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: mgId,
|
||||
agent_group_id: agId,
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('routes by receiving instance: named instance lands in its own mg/agent, default in the default', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { registerChannelAdapter, initChannelAdapters, teardownChannelAdapters } =
|
||||
await import('./channels/channel-registry.js');
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
|
||||
// Default 'slack' adapter is THREADED; the named instance is NOT.
|
||||
// The same arm therefore also pins the thread-policy lookup at the
|
||||
// receiving instance: if the router resolved the adapter by
|
||||
// channelType, the tester event's threadId would survive.
|
||||
const makeAdapter = (instance: string | undefined, supportsThreads: boolean) => ({
|
||||
name: instance ?? 'slack',
|
||||
channelType: 'slack',
|
||||
instance,
|
||||
supportsThreads,
|
||||
async setup() {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
registerChannelAdapter('slack', { factory: () => makeAdapter(undefined, true) });
|
||||
registerChannelAdapter('slack-tester', { factory: () => makeAdapter('slack-tester', false) });
|
||||
await initChannelAdapters(() => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
}));
|
||||
|
||||
try {
|
||||
// Inbound on the named instance, with a threadId the non-threaded
|
||||
// adapter must collapse.
|
||||
await routeInbound({
|
||||
channelType: 'slack',
|
||||
instance: 'slack-tester',
|
||||
platformId: 'slack:C1',
|
||||
threadId: 'thread-9',
|
||||
message: {
|
||||
id: 'msg-tester',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: 'to tester' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const testerSessions = getSessionsByAgentGroup('ag-2');
|
||||
expect(testerSessions).toHaveLength(1);
|
||||
expect(testerSessions[0].messaging_group_id).toBe('mg-tester');
|
||||
expect(getSessionsByAgentGroup('ag-1')).toHaveLength(0);
|
||||
|
||||
const tDb = new Database(inboundDbPath('ag-2', testerSessions[0].id));
|
||||
const tRow = tDb.prepare('SELECT thread_id, content FROM messages_in').get() as {
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
};
|
||||
tDb.close();
|
||||
expect(JSON.parse(tRow.content).text).toBe('to tester');
|
||||
// Collapsed by the named instance's thread policy.
|
||||
expect(tRow.thread_id).toBeNull();
|
||||
|
||||
// Same address, no instance ⇒ default instance ⇒ default mg/agent,
|
||||
// and the default adapter is threaded so the threadId survives.
|
||||
await routeInbound({
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: 'thread-9',
|
||||
message: {
|
||||
id: 'msg-default',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: 'to default' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const defaultSessions = getSessionsByAgentGroup('ag-1');
|
||||
expect(defaultSessions).toHaveLength(1);
|
||||
expect(defaultSessions[0].messaging_group_id).toBe('mg-default');
|
||||
const dDb = new Database(inboundDbPath('ag-1', defaultSessions[0].id));
|
||||
const dRow = dDb.prepare('SELECT thread_id FROM messages_in').get() as { thread_id: string | null };
|
||||
dDb.close();
|
||||
expect(dRow.thread_id).toBe('thread-9');
|
||||
} finally {
|
||||
await teardownChannelAdapters();
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-create persists the receiving instance instead of hijacking the default row', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
|
||||
|
||||
// No row exists for this address on ANY instance yet; create an
|
||||
// unwired default row to prove the named event doesn't reuse it.
|
||||
createMessagingGroup({
|
||||
id: 'mg-plain',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C-NEW',
|
||||
name: null,
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'slack',
|
||||
instance: 'slack-tester',
|
||||
platformId: 'slack:C-NEW',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'msg-mention',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: '@tester hi' }),
|
||||
timestamp: now(),
|
||||
isMention: true,
|
||||
},
|
||||
});
|
||||
|
||||
const created = getMessagingGroupByPlatform('slack', 'slack:C-NEW', 'slack-tester');
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.instance).toBe('slack-tester');
|
||||
expect(created!.id).not.toBe('mg-plain');
|
||||
// The default row is untouched.
|
||||
expect(getMessagingGroupByPlatform('slack', 'slack:C-NEW', 'slack')!.id).toBe('mg-plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing metadata preservation', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Regression test for the wake-tick SLA race in the host sweep.
|
||||
*
|
||||
* Drives the real sweep loop (startHostSweep) against a real central DB and
|
||||
* real on-disk session DBs, mocking only the container runner. Scenario: a
|
||||
* session has a due inbound message AND a stale processing_ack claim left
|
||||
* over from a crashed container. The sweep tick that wakes a fresh container
|
||||
* must NOT kill it in the same tick — the freshly-woken container hasn't had
|
||||
* a chance to clear the stale claim yet (clearStaleProcessingAcks runs on
|
||||
* agent-runner startup). A later tick where the claim is still stale must
|
||||
* kill (claim-stuck). Goes red if the justWoke grace gate
|
||||
* (`if (alive && outDb && !justWoke)`) is removed.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Override DATA_DIR for tests
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-host-sweep-grace' };
|
||||
});
|
||||
|
||||
// Mock container runner to prevent actual Docker spawning
|
||||
vi.mock('./container-runner.js', () => ({
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
wakeContainer: vi.fn().mockResolvedValue(true),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||
import { createSession } from './db/sessions.js';
|
||||
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import { initSessionFolder, openOutboundDbRw, writeSessionMessage } from './session-manager.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-host-sweep-grace';
|
||||
const AG = 'ag-test';
|
||||
const SESS = 'sess-test';
|
||||
// Mirrors SWEEP_INTERVAL_MS in host-sweep.ts — identifies the sweep's
|
||||
// self-reschedule among other setTimeout calls (e.g. vi.waitFor's polling).
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function seedStaleClaim(messageId: string, ageMs: number): void {
|
||||
const db = openOutboundDbRw(AG, SESS);
|
||||
try {
|
||||
db.prepare('INSERT INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, ?)').run(
|
||||
messageId,
|
||||
'processing',
|
||||
new Date(Date.now() - ageMs).toISOString(),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sweep loop signals tick completion by rescheduling itself via
|
||||
* setTimeout(sweep, SWEEP_INTERVAL_MS). Capture those callbacks instead of
|
||||
* scheduling them, so each tick ends inert and the test drives the next tick
|
||||
* explicitly. All other setTimeout calls pass through untouched.
|
||||
*/
|
||||
const sweepCallbacks: Array<() => void> = [];
|
||||
const realSetTimeout = global.setTimeout;
|
||||
let setTimeoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** Run exactly one sweep tick and wait for it to complete. */
|
||||
async function runSweepTick(): Promise<void> {
|
||||
const before = sweepCallbacks.length;
|
||||
if (before === 0) {
|
||||
startHostSweep();
|
||||
} else {
|
||||
// Invoke the captured self-reschedule — the real next-tick path.
|
||||
sweepCallbacks[before - 1]();
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(sweepCallbacks.length).toBe(before + 1);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isContainerRunning).mockReset().mockReturnValue(false);
|
||||
vi.mocked(killContainer).mockReset();
|
||||
vi.mocked(wakeContainer)
|
||||
.mockReset()
|
||||
// Simulate a successful spawn: after wake, the container reports running.
|
||||
.mockImplementation(async () => {
|
||||
vi.mocked(isContainerRunning).mockReturnValue(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
sweepCallbacks.length = 0;
|
||||
setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => {
|
||||
if (ms === SWEEP_INTERVAL_MS) {
|
||||
sweepCallbacks.push(fn);
|
||||
return 0 as unknown as NodeJS.Timeout;
|
||||
}
|
||||
return realSetTimeout(fn, ms);
|
||||
}) as typeof setTimeout);
|
||||
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createAgentGroup({ id: AG, name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: SESS,
|
||||
agent_group_id: AG,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(AG, SESS);
|
||||
|
||||
// A due message (wakes the container) + a stale claim from a previous crash
|
||||
// (would trip claim-stuck if the SLA check ran on the wake tick).
|
||||
writeSessionMessage(AG, SESS, { id: 'm-1', kind: 'chat', timestamp: now(), content: '{"text":"hi"}' });
|
||||
seedStaleClaim('m-1', 2 * 60 * 60 * 1000); // claimed 2h ago
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopHostSweep();
|
||||
setTimeoutSpy.mockRestore();
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('host sweep justWoke grace period', () => {
|
||||
it('does not kill the container on the tick that woke it, kills on a later tick if the claim is still stale', async () => {
|
||||
// Tick 1: due message + no running container → wake. The stale claim is
|
||||
// still in outbound.db, but the grace period must skip the SLA check.
|
||||
await runSweepTick();
|
||||
expect(wakeContainer).toHaveBeenCalledTimes(1);
|
||||
expect(killContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Tick 2: container is running (no fresh wake → no grace), the claim is
|
||||
// still stale because our simulated container never cleared it → kill.
|
||||
await runSweepTick();
|
||||
expect(wakeContainer).toHaveBeenCalledTimes(1); // no second wake
|
||||
expect(killContainer).toHaveBeenCalledTimes(1);
|
||||
expect(killContainer).toHaveBeenCalledWith(SESS, 'claim-stuck');
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -189,17 +189,23 @@ async function sweepSession(session: Session): Promise<void> {
|
||||
// would keep bumping process_after into the future, dueCount would stay 0,
|
||||
// and the wake would never fire.
|
||||
const dueCount = countDueMessages(inDb);
|
||||
let justWoke = false;
|
||||
if (dueCount > 0 && !isContainerRunning(session.id)) {
|
||||
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
|
||||
// wakeContainer never throws — transient spawn failures (OneCLI down,
|
||||
// etc.) return false and leave messages pending for the next tick.
|
||||
await wakeContainer(session);
|
||||
justWoke = true;
|
||||
}
|
||||
|
||||
const alive = isContainerRunning(session.id);
|
||||
|
||||
// 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
|
||||
if (alive && outDb) {
|
||||
// Skip on the same iteration that just woke the container — it hasn't
|
||||
// had a chance to clear stale processing_ack rows from a previous crash
|
||||
// yet. Without this grace period, stale claims cause an immediate
|
||||
// spawn-kill loop.
|
||||
if (alive && outDb && !justWoke) {
|
||||
enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
|
||||
}
|
||||
|
||||
|
||||
+13
-24
@@ -62,7 +62,11 @@ import './cli/delivery-action.js';
|
||||
import { startCliServer, stopCliServer } from './cli/socket-server.js';
|
||||
|
||||
import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js';
|
||||
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';
|
||||
import {
|
||||
initChannelAdapters,
|
||||
teardownChannelAdapters,
|
||||
createChannelDeliveryAdapter,
|
||||
} from './channels/channel-registry.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
log.info('NanoClaw starting');
|
||||
@@ -97,6 +101,9 @@ async function main(): Promise<void> {
|
||||
onInbound(platformId, threadId, message) {
|
||||
routeInbound({
|
||||
channelType: adapter.channelType,
|
||||
// The one host-side stamping seam: adapters stay instance-blind,
|
||||
// the host stamps the receiving instance on every inbound event.
|
||||
instance: adapter.instance ?? adapter.channelType,
|
||||
platformId,
|
||||
threadId,
|
||||
message: {
|
||||
@@ -146,29 +153,11 @@ async function main(): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Delivery adapter bridge — dispatches to channel adapters
|
||||
const deliveryAdapter = {
|
||||
async deliver(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: import('./channels/adapter.js').OutboundFile[],
|
||||
): Promise<string | undefined> {
|
||||
const adapter = getChannelAdapter(channelType);
|
||||
if (!adapter) {
|
||||
log.warn('No adapter for channel type', { channelType });
|
||||
return;
|
||||
}
|
||||
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
|
||||
},
|
||||
async setTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
||||
const adapter = getChannelAdapter(channelType);
|
||||
await adapter?.setTyping?.(platformId, threadId);
|
||||
},
|
||||
};
|
||||
setDeliveryAdapter(deliveryAdapter);
|
||||
// 4. Delivery adapter bridge — dispatches to channel adapters by EXACT
|
||||
// registry key (instance ?? channelType): a named instance with an
|
||||
// offline adapter is never rerouted through a sibling bot. See
|
||||
// createChannelDeliveryAdapter in channels/channel-registry.ts.
|
||||
setDeliveryAdapter(createChannelDeliveryAdapter());
|
||||
|
||||
// 5. Start delivery polls
|
||||
startActiveDeliveryPoll();
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Approval-resolved callback registry.
|
||||
*
|
||||
* Drives the real response-handler entry (`handleApprovalsResponse`) and
|
||||
* asserts that callbacks registered via `registerApprovalResolvedHandler`
|
||||
* fire when an admin resolves a pending approval — the hook modules use to
|
||||
* observe approval resolution (e.g. clearing an "awaiting approval" status
|
||||
* indicator). Goes red if the response handler stops calling
|
||||
* `notifyApprovalResolved`.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createSession, createPendingApproval } from '../../db/sessions.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { initSessionFolder } from '../../session-manager.js';
|
||||
import { handleApprovalsResponse } from './response-handler.js';
|
||||
import { registerApprovalHandler, registerApprovalResolvedHandler, type ApprovalResolvedEvent } from './primitive.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approval-resolved' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-approval-resolved';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function seedApproval(approvalId: string, action: string): void {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-1',
|
||||
request_id: approvalId,
|
||||
action,
|
||||
payload: JSON.stringify({}),
|
||||
created_at: now(),
|
||||
title: 'Test approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder('ag-1', 'sess-1');
|
||||
|
||||
// Resolution only happens for authorized clicks — seed the clicking admin.
|
||||
upsertUser({ id: 'slack:admin-1', kind: 'slack', display_name: 'Admin', created_at: now() });
|
||||
grantRole({ user_id: 'slack:admin-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('approval-resolved callbacks', () => {
|
||||
it('fires registered callbacks on reject with the approval, session, and outcome', async () => {
|
||||
const events: ApprovalResolvedEvent[] = [];
|
||||
registerApprovalResolvedHandler((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
seedApproval('appr-reject-1', 'test_reject_action');
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-reject-1',
|
||||
value: 'reject',
|
||||
userId: 'slack:admin-1',
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].outcome).toBe('reject');
|
||||
expect(events[0].approval.approval_id).toBe('appr-reject-1');
|
||||
expect(events[0].approval.action).toBe('test_reject_action');
|
||||
expect(events[0].session.id).toBe('sess-1');
|
||||
expect(events[0].userId).toBe('slack:admin-1');
|
||||
});
|
||||
|
||||
it('fires registered callbacks on approve after the action handler ran', async () => {
|
||||
const calls: string[] = [];
|
||||
registerApprovalHandler('test_approve_action', async () => {
|
||||
calls.push('handler');
|
||||
});
|
||||
registerApprovalResolvedHandler(({ outcome }) => {
|
||||
calls.push(`resolved:${outcome}`);
|
||||
});
|
||||
|
||||
seedApproval('appr-approve-1', 'test_approve_action');
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-approve-1',
|
||||
value: 'approve',
|
||||
userId: 'slack:admin-1',
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['handler', 'resolved:approve']);
|
||||
});
|
||||
|
||||
it('isolates a throwing callback so later callbacks still fire', async () => {
|
||||
const events: string[] = [];
|
||||
registerApprovalResolvedHandler(() => {
|
||||
events.push('boom');
|
||||
throw new Error('callback exploded');
|
||||
});
|
||||
registerApprovalResolvedHandler(() => {
|
||||
events.push('after');
|
||||
});
|
||||
|
||||
seedApproval('appr-reject-2', 'test_isolation_action');
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-reject-2',
|
||||
value: 'reject',
|
||||
userId: 'slack:admin-1',
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(events).toEqual(['boom', 'after']);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { MessagingGroup, Session } from '../../types.js';
|
||||
import type { MessagingGroup, PendingApproval, Session } from '../../types.js';
|
||||
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
|
||||
@@ -67,6 +67,50 @@ export function getApprovalHandler(action: string): ApprovalHandler | undefined
|
||||
return approvalHandlers.get(action);
|
||||
}
|
||||
|
||||
// ── Approval-resolved callbacks ──
|
||||
// Modules that want to observe approval resolution (any action, approve or
|
||||
// reject) register here at import time. The response handler fires every
|
||||
// registered callback after the admin's decision is applied — e.g. a module
|
||||
// clearing an "awaiting approval" status indicator it set when the card went
|
||||
// out. Callback errors are logged and isolated; they never block resolution.
|
||||
//
|
||||
// Only authorized clicks resolve an approval (the response handler's
|
||||
// isAuthorizedApprovalClick gate runs first), so callbacks never fire for
|
||||
// unauthorized responses.
|
||||
|
||||
export interface ApprovalResolvedEvent {
|
||||
approval: PendingApproval;
|
||||
session: Session;
|
||||
outcome: 'approve' | 'reject';
|
||||
/** Namespaced user ID (`<channel>:<handle>`) of the resolving admin. Empty string if unknown. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type ApprovalResolvedHandler = (event: ApprovalResolvedEvent) => Promise<void> | void;
|
||||
|
||||
const approvalResolvedHandlers: ApprovalResolvedHandler[] = [];
|
||||
|
||||
export function registerApprovalResolvedHandler(handler: ApprovalResolvedHandler): void {
|
||||
approvalResolvedHandlers.push(handler);
|
||||
}
|
||||
|
||||
/** Fire every registered approval-resolved callback. Called by the response handler. */
|
||||
export async function notifyApprovalResolved(event: ApprovalResolvedEvent): Promise<void> {
|
||||
for (const handler of approvalResolvedHandlers) {
|
||||
try {
|
||||
await handler(event);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad callback must not block resolution or other callbacks
|
||||
} catch (err) {
|
||||
log.error('Approval-resolved handler threw', {
|
||||
approvalId: event.approval.approval_id,
|
||||
action: event.approval.action,
|
||||
outcome: event.outcome,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approver picking ──
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
|
||||
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler } from './primitive.js';
|
||||
import { getApprovalHandler, notifyApprovalResolved } from './primitive.js';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
@@ -81,6 +81,7 @@ async function handleRegisteredApproval(
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +95,7 @@ async function handleRegisteredApproval(
|
||||
});
|
||||
notify(`Your ${approval.action} was approved, but no handler is installed to apply it.`);
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
@@ -110,6 +112,7 @@ async function handleRegisteredApproval(
|
||||
}
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Typing-refresh instance forwarding tests.
|
||||
*
|
||||
* Three tick sites can fire setTyping — the immediate tick on a new
|
||||
* refresher, the 4s interval tick, and the immediate re-trigger when
|
||||
* startTypingRefresh is called for an already-refreshing session. All three
|
||||
* must forward the adapter instance, or a named instance's typing indicator
|
||||
* fires through the wrong bot.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-typing' };
|
||||
});
|
||||
|
||||
import { setTypingAdapter, startTypingRefresh, stopTypingRefresh } from './index.js';
|
||||
|
||||
type Call = { channelType: string; platformId: string; threadId: string | null; instance?: string };
|
||||
|
||||
function captureAdapter() {
|
||||
const calls: Call[] = [];
|
||||
setTypingAdapter({
|
||||
async setTyping(channelType, platformId, threadId, instance) {
|
||||
calls.push({ channelType, platformId, threadId, instance });
|
||||
},
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopTypingRefresh('sess-1');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('startTypingRefresh — instance forwarding', () => {
|
||||
it('immediate tick passes the instance to the adapter', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', null, 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
instance: 'slack-tester',
|
||||
});
|
||||
});
|
||||
|
||||
it('interval ticks inside the grace window pass the stored entry instance', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', 'T1', 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
calls.length = 0;
|
||||
|
||||
// Two 4s ticks — well inside the 15s grace window, so they fire
|
||||
// unconditionally (no heartbeat file needed) from the stored entry.
|
||||
await vi.advanceTimersByTimeAsync(8_500);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const c of calls) {
|
||||
expect(c.instance).toBe('slack-tester');
|
||||
expect(c.threadId).toBe('T1');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-trigger on an active session passes (and stores) the new instance', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', null, 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
calls.length = 0;
|
||||
|
||||
// Second call for the same session: immediate tick with the new value.
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', null, 'slack-worker');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].instance).toBe('slack-worker');
|
||||
|
||||
// And the stored entry was updated — subsequent interval ticks carry it.
|
||||
calls.length = 0;
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(calls[calls.length - 1].instance).toBe('slack-worker');
|
||||
});
|
||||
|
||||
it('re-trigger with a changed address updates the whole entry — interval ticks stay self-consistent', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', 'T1', 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
calls.length = 0;
|
||||
|
||||
// Same session re-triggered from a different platform and chat
|
||||
// (agent-shared sessions span messaging groups). The stored entry must
|
||||
// not tear: keeping the old address with the new instance would hand a
|
||||
// telegram platformId to the slack-tester adapter on the next tick.
|
||||
startTypingRefresh('sess-1', 'ag-1', 'telegram', 'tg:99', null, 'telegram');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({
|
||||
channelType: 'telegram',
|
||||
platformId: 'tg:99',
|
||||
threadId: null,
|
||||
instance: 'telegram',
|
||||
});
|
||||
|
||||
// Interval ticks fire from the stored entry — all four fields must
|
||||
// have moved together.
|
||||
calls.length = 0;
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
for (const c of calls) {
|
||||
expect(c).toEqual({
|
||||
channelType: 'telegram',
|
||||
platformId: 'tg:99',
|
||||
threadId: null,
|
||||
instance: 'telegram',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const HEARTBEAT_FRESH_MS = 6000;
|
||||
const POST_DELIVERY_PAUSE_MS = 10000;
|
||||
|
||||
interface TypingAdapter {
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null, instance?: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface TypingTarget {
|
||||
@@ -53,6 +53,8 @@ interface TypingTarget {
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
/** Adapter instance that owns the chat; undefined = default (= channelType). */
|
||||
instance?: string;
|
||||
interval: NodeJS.Timeout;
|
||||
startedAt: number;
|
||||
pausedUntil: number; // epoch ms; 0 = not paused
|
||||
@@ -72,9 +74,14 @@ export function setTypingAdapter(a: TypingAdapter): void {
|
||||
adapter = a;
|
||||
}
|
||||
|
||||
async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
||||
async function triggerTyping(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
instance?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await adapter?.setTyping?.(channelType, platformId, threadId);
|
||||
await adapter?.setTyping?.(channelType, platformId, threadId, instance);
|
||||
} catch {
|
||||
// Typing is best-effort — don't let it fail delivery or routing.
|
||||
}
|
||||
@@ -96,6 +103,7 @@ export function startTypingRefresh(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
instance?: string,
|
||||
): void {
|
||||
const existing = typingRefreshers.get(sessionId);
|
||||
if (existing) {
|
||||
@@ -104,14 +112,24 @@ export function startTypingRefresh(
|
||||
// the container-wake latency budget. Also clear any lingering
|
||||
// post-delivery pause: a new inbound means the user expects
|
||||
// typing to show immediately.
|
||||
triggerTyping(channelType, platformId, threadId).catch(() => {});
|
||||
triggerTyping(channelType, platformId, threadId, instance).catch(() => {});
|
||||
existing.startedAt = Date.now();
|
||||
existing.pausedUntil = 0;
|
||||
// Keep the stored entry self-consistent: a re-trigger can arrive from
|
||||
// a different chat address (agent-shared sessions span messaging
|
||||
// groups, possibly on different platforms/instances), so the address
|
||||
// fields and the owning instance must move together — a torn entry
|
||||
// (old address + new instance) would hand e.g. a telegram platformId
|
||||
// to a Slack instance's setTyping on the next interval tick.
|
||||
existing.channelType = channelType;
|
||||
existing.platformId = platformId;
|
||||
existing.threadId = threadId;
|
||||
existing.instance = instance;
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediate tick + periodic refresh.
|
||||
triggerTyping(channelType, platformId, threadId).catch(() => {});
|
||||
triggerTyping(channelType, platformId, threadId, instance).catch(() => {});
|
||||
const startedAt = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const entry = typingRefreshers.get(sessionId);
|
||||
@@ -124,7 +142,7 @@ export function startTypingRefresh(
|
||||
|
||||
const withinGrace = Date.now() - entry.startedAt < TYPING_GRACE_MS;
|
||||
if (withinGrace || isHeartbeatFresh(entry.agentGroupId, sessionId)) {
|
||||
triggerTyping(entry.channelType, entry.platformId, entry.threadId).catch(() => {});
|
||||
triggerTyping(entry.channelType, entry.platformId, entry.threadId, entry.instance).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,6 +157,7 @@ export function startTypingRefresh(
|
||||
channelType,
|
||||
platformId,
|
||||
threadId,
|
||||
instance,
|
||||
interval,
|
||||
startedAt,
|
||||
pausedUntil: 0,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-provider-surfaces-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
const DATA_DIR = path.join(TEST_ROOT, 'data');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
DATA_DIR: '/tmp/nanoclaw-provider-surfaces-test/data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-provider-surfaces-test/groups',
|
||||
}));
|
||||
|
||||
vi.mock('./log.js', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { buildMounts } from './container-runner.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { initGroupFilesystem } from './group-init.js';
|
||||
import { registerProviderContainerConfig } from './providers/provider-container-registry.js';
|
||||
import type { ContainerConfig } from './container-config.js';
|
||||
import type { AgentGroup, Session } from './types.js';
|
||||
|
||||
// A provider that declares (at registration) that it owns its agent surfaces.
|
||||
// Registered once — the registry is module-global and rejects duplicates.
|
||||
registerProviderContainerConfig('surfaces-test-provider', () => ({}), { providesAgentSurfaces: true });
|
||||
|
||||
function group(id: string, folder: string): AgentGroup {
|
||||
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
|
||||
}
|
||||
|
||||
function session(id: string, agentGroupId: string): Session {
|
||||
return { id, agent_group_id: agentGroupId } as Session;
|
||||
}
|
||||
|
||||
function containerConfig(): ContainerConfig {
|
||||
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('initGroupFilesystem agent surfaces', () => {
|
||||
it('writes the default surfaces when no provider is given (today’s behavior)', () => {
|
||||
const ag = group('ag-default', 'default-group');
|
||||
createAgentGroup(ag);
|
||||
|
||||
initGroupFilesystem(ag, { instructions: 'hello' });
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, ag.folder);
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
|
||||
expect(fs.readFileSync(path.join(groupDir, 'CLAUDE.local.md'), 'utf-8')).toBe('hello\n');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'settings.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'skills'))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the default surfaces for a provider that provides its own', () => {
|
||||
const ag = group('ag-surfy', 'surfy-group');
|
||||
createAgentGroup(ag);
|
||||
|
||||
initGroupFilesystem(ag, { instructions: 'hello', provider: 'surfaces-test-provider' });
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, ag.folder);
|
||||
const sessionRoot = path.join(DATA_DIR, 'v2-sessions', ag.id);
|
||||
expect(fs.existsSync(groupDir)).toBe(true);
|
||||
expect(fs.existsSync(path.join(groupDir, 'CLAUDE.local.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sessionRoot, '.claude-shared'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an unregistered provider name as default surfaces', () => {
|
||||
const ag = group('ag-unknown', 'unknown-group');
|
||||
createAgentGroup(ag);
|
||||
|
||||
initGroupFilesystem(ag, { provider: 'not-registered' });
|
||||
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.local.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMounts agent surfaces', () => {
|
||||
it('mounts the default surfaces for an unregistered provider (today’s behavior)', () => {
|
||||
const ag = group('ag-mounts-default', 'mounts-default');
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
initGroupFilesystem(ag, {});
|
||||
|
||||
const mounts = buildMounts(ag, session('s1', ag.id), containerConfig(), 'claude', {});
|
||||
|
||||
const byContainerPath = new Map(mounts.map((m) => [m.containerPath, m]));
|
||||
expect(byContainerPath.has('/home/node/.claude')).toBe(true);
|
||||
expect(byContainerPath.has('/app/CLAUDE.md')).toBe(true);
|
||||
expect(byContainerPath.has('/workspace/agent/CLAUDE.md')).toBe(true);
|
||||
// Composer ran: the generated project doc exists on disk.
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the default surfaces and keeps contributed mounts for a surfaces-providing provider', () => {
|
||||
const ag = group('ag-mounts-surfy', 'mounts-surfy');
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
initGroupFilesystem(ag, { provider: 'surfaces-test-provider' });
|
||||
|
||||
const contributed = {
|
||||
mounts: [
|
||||
{
|
||||
hostPath: path.join(GROUPS_DIR, ag.folder),
|
||||
containerPath: '/workspace/agent/OWN-DOC.md',
|
||||
readonly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const mounts = buildMounts(ag, session('s2', ag.id), containerConfig(), 'surfaces-test-provider', contributed);
|
||||
|
||||
const containerPaths = mounts.map((m) => m.containerPath);
|
||||
expect(containerPaths).not.toContain('/home/node/.claude');
|
||||
expect(containerPaths).not.toContain('/app/CLAUDE.md');
|
||||
expect(containerPaths).not.toContain('/workspace/agent/CLAUDE.md');
|
||||
// Composer did NOT run for this group.
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(false);
|
||||
// Core mounts and the provider's own contribution are intact.
|
||||
expect(containerPaths).toContain('/workspace');
|
||||
expect(containerPaths).toContain('/workspace/agent');
|
||||
expect(containerPaths).toContain('/app/src');
|
||||
expect(containerPaths).toContain('/workspace/agent/OWN-DOC.md');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,19 @@ export interface ProviderContainerContext {
|
||||
sessionDir: string;
|
||||
/** Agent group ID, for any per-group logic. */
|
||||
agentGroupId: string;
|
||||
/**
|
||||
* Per-group host directory: `<GROUPS_DIR>/<folder>` (mounted RW at
|
||||
* `/workspace/agent`). Exists by the time the config fn runs — group
|
||||
* filesystem init happens first. Surfaces-providing providers compose
|
||||
* their project doc and skill links here.
|
||||
*/
|
||||
groupDir: string;
|
||||
/**
|
||||
* Skill names selected by the group's container config, with `'all'`
|
||||
* already resolved against `container/skills/`. Surfaces-providing
|
||||
* providers use this to sync their own skill-discovery links.
|
||||
*/
|
||||
selectedSkills: string[];
|
||||
/** `process.env` at spawn time — pull passthrough values from here. */
|
||||
hostEnv: NodeJS.ProcessEnv;
|
||||
}
|
||||
@@ -38,19 +51,56 @@ export interface ProviderContainerContribution {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static capabilities a provider declares at registration time — knowable
|
||||
* without a spawn context, so any host path (group init, spawn, creation
|
||||
* flows) can consult them by name.
|
||||
*/
|
||||
export interface ProviderHostCapabilities {
|
||||
/**
|
||||
* Optional. When true, this provider owns its agent-facing surfaces — the
|
||||
* composed project doc, skill-discovery links, and provider state dir —
|
||||
* and the host must NOT compose or mount the default ones (composed
|
||||
* CLAUDE.md, `.claude-fragments`, `/app/CLAUDE.md`, `/home/node/.claude`,
|
||||
* `CLAUDE.local.md` seeding). The provider's config fn does its own
|
||||
* composing and returns its own mounts. Default off — providers that omit
|
||||
* this get the default surfaces, which is today's behavior.
|
||||
*/
|
||||
readonly providesAgentSurfaces?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
|
||||
|
||||
const registry = new Map<string, ProviderContainerConfigFn>();
|
||||
interface RegistryEntry {
|
||||
fn: ProviderContainerConfigFn;
|
||||
capabilities: ProviderHostCapabilities;
|
||||
}
|
||||
|
||||
export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void {
|
||||
const registry = new Map<string, RegistryEntry>();
|
||||
|
||||
export function registerProviderContainerConfig(
|
||||
name: string,
|
||||
fn: ProviderContainerConfigFn,
|
||||
capabilities: ProviderHostCapabilities = {},
|
||||
): void {
|
||||
if (registry.has(name)) {
|
||||
throw new Error(`Provider container config already registered: ${name}`);
|
||||
}
|
||||
registry.set(name, fn);
|
||||
registry.set(name, { fn, capabilities });
|
||||
}
|
||||
|
||||
export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined {
|
||||
return registry.get(name);
|
||||
return registry.get(name)?.fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability lookup by provider name. Unregistered providers (including the
|
||||
* baked-in default) report no capabilities — the host applies its default
|
||||
* surfaces, exactly as before this seam existed.
|
||||
*/
|
||||
export function providerProvidesAgentSurfaces(name: string | null | undefined): boolean {
|
||||
if (!name) return false;
|
||||
return registry.get(name)?.capabilities.providesAgentSurfaces === true;
|
||||
}
|
||||
|
||||
export function listProviderContainerConfigNames(): string[] {
|
||||
|
||||
+24
-5
@@ -161,8 +161,10 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel.
|
||||
const adapter = getChannelAdapter(event.channelType);
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel. Resolved
|
||||
// by the RECEIVING instance — sibling instances of one platform can
|
||||
// differ in thread support.
|
||||
const adapter = getChannelAdapter(event.instance ?? event.channelType);
|
||||
if (adapter && !adapter.supportsThreads) {
|
||||
event = { ...event, threadId: null };
|
||||
}
|
||||
@@ -172,8 +174,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// 1. Combined lookup: messaging_group row + count of wired agents in a
|
||||
// single query. Cheap short-circuit for the common "unwired channel"
|
||||
// case — one DB read and we're out, no auto-create, no sender
|
||||
// resolution, no log spam.
|
||||
const found = getMessagingGroupWithAgentCount(event.channelType, event.platformId);
|
||||
// resolution, no log spam. Exact-on-instance: an unknown named
|
||||
// instance falls through to auto-create rather than hijacking a
|
||||
// sibling instance's row.
|
||||
const found = getMessagingGroupWithAgentCount(
|
||||
event.channelType,
|
||||
event.platformId,
|
||||
event.instance ?? event.channelType,
|
||||
);
|
||||
|
||||
let mg: MessagingGroup;
|
||||
let agentCount: number;
|
||||
@@ -187,6 +195,9 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
id: mgId,
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
// Persist the receiving instance — without this, the first bot's row
|
||||
// would absorb every sibling instance's traffic.
|
||||
instance: event.instance ?? event.channelType,
|
||||
name: null,
|
||||
is_group: event.message.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: 'request_approval',
|
||||
@@ -472,7 +483,15 @@ async function deliverToAgent(
|
||||
if (wake) {
|
||||
// Typing indicator + wake are only for the engaged branch; accumulated
|
||||
// messages sit silently until a real trigger fires.
|
||||
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
|
||||
// Typing fires via the adapter instance that owns this chat's row.
|
||||
startTypingRefresh(
|
||||
session.id,
|
||||
session.agent_group_id,
|
||||
event.channelType,
|
||||
event.platformId,
|
||||
event.threadId,
|
||||
mg.instance,
|
||||
);
|
||||
const freshSession = getSession(session.id);
|
||||
if (freshSession) {
|
||||
const woke = await wakeContainer(freshSession);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for session-manager's direct outbound write path.
|
||||
*
|
||||
* Drives the real `writeOutboundDirect` entry against a real session folder
|
||||
* on disk. A previous implementation opened the outbound DB through
|
||||
* `openOutboundDb` (readonly: true), so every INSERT threw SQLITE_READONLY
|
||||
* and the command-gate denial path silently never delivered. Goes red if the
|
||||
* open call reverts to the readonly form.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
|
||||
});
|
||||
|
||||
import { initSessionFolder, outboundDbPath, writeOutboundDirect } from './session-manager.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
|
||||
const AG = 'ag-test';
|
||||
const SESS = 'sess-test';
|
||||
|
||||
function readMessagesOut(): Array<{ id: string; seq: number; kind: string; content: string }> {
|
||||
const db = new Database(outboundDbPath(AG, SESS), { readonly: true });
|
||||
try {
|
||||
return db.prepare('SELECT id, seq, kind, content FROM messages_out ORDER BY seq').all() as Array<{
|
||||
id: string;
|
||||
seq: number;
|
||||
kind: string;
|
||||
content: string;
|
||||
}>;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
initSessionFolder(AG, SESS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('writeOutboundDirect', () => {
|
||||
it('inserts into messages_out with an even host-side seq (requires a writable outbound.db)', () => {
|
||||
// With a readonly open this very call throws SQLITE_READONLY.
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-1',
|
||||
kind: 'chat',
|
||||
platformId: 'slack:C1',
|
||||
channelType: 'slack',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text: 'Admin commands are restricted.' }),
|
||||
});
|
||||
|
||||
const rows = readMessagesOut();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('denial-1');
|
||||
expect(rows[0].seq).toBe(2);
|
||||
expect(rows[0].seq % 2).toBe(0); // host uses even seq numbers
|
||||
expect(JSON.parse(rows[0].content).text).toBe('Admin commands are restricted.');
|
||||
});
|
||||
|
||||
it('keeps host seq numbers even across multiple writes and ignores duplicate ids', () => {
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-1',
|
||||
kind: 'chat',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: '{"text":"first"}',
|
||||
});
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-2',
|
||||
kind: 'chat',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: '{"text":"second"}',
|
||||
});
|
||||
// INSERT OR IGNORE — a delivery retry with the same id must not throw or duplicate.
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-1',
|
||||
kind: 'chat',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: '{"text":"retry"}',
|
||||
});
|
||||
|
||||
const rows = readMessagesOut();
|
||||
expect(rows.map((r) => r.id)).toEqual(['denial-1', 'denial-2']);
|
||||
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
@@ -378,6 +378,12 @@ export function openOutboundDbRw(agentGroupId: string, sessionId: string): Datab
|
||||
* Write a message directly to a session's outbound DB so the host delivery
|
||||
* loop picks it up. Used by the command gate to send denial responses
|
||||
* without waking a container.
|
||||
*
|
||||
* Needs the read-write open — the readonly handle the delivery poll uses
|
||||
* can't INSERT. This is a host-side write to the container-owned outbound.db,
|
||||
* but it's safe even with a container running: both sides open with DELETE
|
||||
* journal + busy_timeout, and the even host seq stays out of the container's
|
||||
* odd-seq space.
|
||||
*/
|
||||
export function writeOutboundDirect(
|
||||
agentGroupId: string,
|
||||
@@ -391,7 +397,7 @@ export function writeOutboundDirect(
|
||||
content: string;
|
||||
},
|
||||
): void {
|
||||
const db = openOutboundDb(agentGroupId, sessionId);
|
||||
const db = openOutboundDbRw(agentGroupId, sessionId);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* SqliteStateAdapter namespace tests.
|
||||
*
|
||||
* All Chat SDK bridges share the chat_sdk_* tables in data/v2.db. Two
|
||||
* same-platform adapter instances see identical thread/message ids, so the
|
||||
* SDK's `dedupe:${adapter.name}:${message.id}` keys collide — the second
|
||||
* bot silently drops every message the first processed — unless each named
|
||||
* instance gets its own key namespace.
|
||||
*
|
||||
* The inverse constraint is just as load-bearing: the DEFAULT instance must
|
||||
* keep today's UNPREFIXED keys byte-identically, or live installs orphan
|
||||
* every existing subscription/lock/kv row on upgrade.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, getDb } from './db/connection.js';
|
||||
import { runMigrations } from './db/migrations/index.js';
|
||||
import { SqliteStateAdapter } from './state-sqlite.js';
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
async function makeAdapter(namespace?: string): Promise<SqliteStateAdapter> {
|
||||
const state = new SqliteStateAdapter(namespace);
|
||||
await state.connect();
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('default instance — legacy unprefixed keys (live-install regression arm)', () => {
|
||||
it('reads rows written before the namespace dimension existed', async () => {
|
||||
// A pre-existing install's subscription row: bare thread id.
|
||||
getDb().prepare("INSERT INTO chat_sdk_subscriptions (thread_id) VALUES ('T-raw')").run();
|
||||
const state = await makeAdapter();
|
||||
expect(await state.isSubscribed('T-raw')).toBe(true);
|
||||
});
|
||||
|
||||
it('writes raw keys — kv, subscriptions, lists bind the exact input strings', async () => {
|
||||
const state = await makeAdapter();
|
||||
await state.set('k1', { v: 1 });
|
||||
await state.subscribe('slack:T1');
|
||||
await state.appendToList('l1', 'item');
|
||||
|
||||
const kv = getDb().prepare('SELECT key FROM chat_sdk_kv').all() as Array<{ key: string }>;
|
||||
expect(kv.map((r) => r.key)).toEqual(['k1']);
|
||||
const subs = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(subs.map((r) => r.thread_id)).toEqual(['slack:T1']);
|
||||
const lists = getDb().prepare('SELECT key FROM chat_sdk_lists').all() as Array<{ key: string }>;
|
||||
expect(lists.map((r) => r.key)).toEqual(['l1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('namespaced instance — round-trips and raw-key shape', () => {
|
||||
it('kv get/set/setIfNotExists/delete round-trip under a prefixed key', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
await state.set('k1', { v: 42 });
|
||||
expect(await state.get('k1')).toEqual({ v: 42 });
|
||||
|
||||
const raw = getDb().prepare('SELECT key FROM chat_sdk_kv').all() as Array<{ key: string }>;
|
||||
expect(raw.map((r) => r.key)).toEqual(['slack-tester:k1']);
|
||||
|
||||
expect(await state.setIfNotExists('k1', 'other')).toBe(false);
|
||||
expect(await state.setIfNotExists('k2', 'fresh')).toBe(true);
|
||||
await state.delete('k1');
|
||||
expect(await state.get('k1')).toBeNull();
|
||||
expect(await state.get('k2')).toBe('fresh');
|
||||
});
|
||||
|
||||
it('subscribe/isSubscribed/unsubscribe round-trip under a prefixed thread_id', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
await state.subscribe('slack:T1');
|
||||
expect(await state.isSubscribed('slack:T1')).toBe(true);
|
||||
|
||||
const raw = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(raw.map((r) => r.thread_id)).toEqual(['slack-tester:slack:T1']);
|
||||
|
||||
await state.unsubscribe('slack:T1');
|
||||
expect(await state.isSubscribed('slack:T1')).toBe(false);
|
||||
});
|
||||
|
||||
it('lists round-trip under a prefixed key', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
await state.appendToList('history', 'a');
|
||||
await state.appendToList('history', 'b');
|
||||
expect(await state.getList('history')).toEqual(['a', 'b']);
|
||||
const raw = getDb().prepare('SELECT DISTINCT key FROM chat_sdk_lists').all() as Array<{ key: string }>;
|
||||
expect(raw.map((r) => r.key)).toEqual(['slack-tester:history']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-namespace isolation', () => {
|
||||
it('setIfNotExists succeeds in BOTH namespaces (the SDK-dedupe collision fix)', async () => {
|
||||
const a = await makeAdapter('slack-worker');
|
||||
const b = await makeAdapter('slack-tester');
|
||||
// Same SDK dedupe key from both bots — each must win in its own space.
|
||||
expect(await a.setIfNotExists('dedupe:slack:m1', 1)).toBe(true);
|
||||
expect(await b.setIfNotExists('dedupe:slack:m1', 1)).toBe(true);
|
||||
// And re-asserting within one namespace still dedupes.
|
||||
expect(await a.setIfNotExists('dedupe:slack:m1', 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("one namespace's subscription is invisible to the other (and to the default)", async () => {
|
||||
const a = await makeAdapter('slack-worker');
|
||||
const b = await makeAdapter('slack-tester');
|
||||
const def = await makeAdapter();
|
||||
await a.subscribe('slack:T1');
|
||||
expect(await a.isSubscribed('slack:T1')).toBe(true);
|
||||
expect(await b.isSubscribed('slack:T1')).toBe(false);
|
||||
expect(await def.isSubscribed('slack:T1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locks under a namespace', () => {
|
||||
it('acquire returns the RAW threadId; extend and release hit the prefixed row', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
const lock = await state.acquireLock('slack:T1', 5000);
|
||||
expect(lock).not.toBeNull();
|
||||
// Raw id on the Lock object — release/extend apply the prefix at their
|
||||
// own SQL sites. A prefixed id here would double-prefix on release.
|
||||
expect(lock!.threadId).toBe('slack:T1');
|
||||
|
||||
const raw = getDb().prepare('SELECT thread_id FROM chat_sdk_locks').all() as Array<{ thread_id: string }>;
|
||||
expect(raw.map((r) => r.thread_id)).toEqual(['slack-tester:slack:T1']);
|
||||
|
||||
expect(await state.extendLock(lock!, 10_000)).toBe(true);
|
||||
await state.releaseLock(lock!);
|
||||
expect(getDb().prepare('SELECT COUNT(*) AS c FROM chat_sdk_locks').get()).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('same-thread locks in different namespaces do not contend', async () => {
|
||||
const a = await makeAdapter('slack-worker');
|
||||
const b = await makeAdapter('slack-tester');
|
||||
expect(await a.acquireLock('slack:T1', 5000)).not.toBeNull();
|
||||
expect(await b.acquireLock('slack:T1', 5000)).not.toBeNull();
|
||||
// Within one namespace the second acquire still fails.
|
||||
expect(await a.acquireLock('slack:T1', 5000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue under a namespace', () => {
|
||||
it('enqueue → queueDepth → dequeue drains to empty; raw key is ns:queue:<tid>', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
const entry = { message: { id: 'm1' } } as never;
|
||||
expect(await state.enqueue('slack:T1', entry, 10)).toBe(1);
|
||||
|
||||
const raw = getDb().prepare('SELECT DISTINCT key FROM chat_sdk_lists').all() as Array<{ key: string }>;
|
||||
// Single prefix: enqueue must NOT apply k() itself (appendToList does);
|
||||
// a double prefix ('slack-tester:slack-tester:queue:…') never drains.
|
||||
expect(raw.map((r) => r.key)).toEqual(['slack-tester:queue:slack:T1']);
|
||||
|
||||
expect(await state.queueDepth('slack:T1')).toBe(1);
|
||||
const out = await state.dequeue('slack:T1');
|
||||
expect(out).toEqual(entry);
|
||||
expect(await state.queueDepth('slack:T1')).toBe(0);
|
||||
expect(await state.dequeue('slack:T1')).toBeNull();
|
||||
});
|
||||
});
|
||||
+56
-21
@@ -20,6 +20,28 @@ interface Lock {
|
||||
export class SqliteStateAdapter implements StateAdapter {
|
||||
private db!: Database.Database;
|
||||
|
||||
/**
|
||||
* namespace = adapter-instance name; undefined ⇒ legacy unprefixed keys.
|
||||
*
|
||||
* All bridges share the same chat_sdk_* tables, and two same-platform
|
||||
* instances see identical thread/message ids — the SDK's dedupe key is
|
||||
* `dedupe:${adapter.name}:${message.id}`, so without a namespace the
|
||||
* second bot silently drops every message the first processed, locks
|
||||
* serialize across bots, and subscriptions leak engagement between them.
|
||||
*
|
||||
* The default instance MUST stay unprefixed: prefixing it would orphan
|
||||
* every live install's existing chat_sdk_subscriptions/kv/locks/lists
|
||||
* rows (silently killing engaged threads) with no clean way to rewrite
|
||||
* them. `k()` is the single choke point between every public method and
|
||||
* its SQL parameter — with namespace undefined it is the identity
|
||||
* function, so every statement binds the exact same strings as before.
|
||||
*/
|
||||
constructor(private readonly namespace?: string) {}
|
||||
|
||||
private k(key: string): string {
|
||||
return this.namespace ? `${this.namespace}:${key}` : key;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.db = getDb();
|
||||
this.cleanup();
|
||||
@@ -31,12 +53,13 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
|
||||
async get<T = unknown>(key: string): Promise<T | null> {
|
||||
this.cleanup();
|
||||
const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
|
||||
const k = this.k(key);
|
||||
const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(k) as
|
||||
| { value: string; expires_at: number | null }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < Date.now()) {
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(k);
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(row.value) as T;
|
||||
@@ -46,39 +69,42 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
|
||||
this.db
|
||||
.prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
|
||||
.run(key, JSON.stringify(value), expiresAt);
|
||||
.run(this.k(key), JSON.stringify(value), expiresAt);
|
||||
}
|
||||
|
||||
async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean> {
|
||||
const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
|
||||
const k = this.k(key);
|
||||
const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(k) as
|
||||
| { expires_at: number | null }
|
||||
| undefined;
|
||||
if (existing?.expires_at && existing.expires_at < Date.now()) {
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(k);
|
||||
}
|
||||
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
|
||||
const result = this.db
|
||||
.prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
|
||||
.run(key, JSON.stringify(value), expiresAt);
|
||||
.run(k, JSON.stringify(value), expiresAt);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(this.k(key));
|
||||
}
|
||||
|
||||
// --- Subscriptions ---
|
||||
|
||||
async subscribe(threadId: string): Promise<void> {
|
||||
this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(threadId);
|
||||
this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(this.k(threadId));
|
||||
}
|
||||
|
||||
async unsubscribe(threadId: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(threadId);
|
||||
this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(this.k(threadId));
|
||||
}
|
||||
|
||||
async isSubscribed(threadId: string): Promise<boolean> {
|
||||
const row = this.db.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1').get(threadId);
|
||||
const row = this.db
|
||||
.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1')
|
||||
.get(this.k(threadId));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -88,23 +114,28 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
const now = Date.now();
|
||||
const token = crypto.randomUUID();
|
||||
const expiresAt = now + ttlMs;
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now);
|
||||
const k = this.k(threadId);
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(k, now);
|
||||
const result = this.db
|
||||
.prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)')
|
||||
.run(threadId, token, expiresAt);
|
||||
.run(k, token, expiresAt);
|
||||
if (result.changes === 0) return null;
|
||||
// The Lock carries the RAW threadId; release/extend re-apply k() at
|
||||
// their own SQL sites. Uniform — no un/re-prefixing on the caller side.
|
||||
return { threadId, token, expiresAt };
|
||||
}
|
||||
|
||||
async releaseLock(lock: Lock): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?').run(lock.threadId, lock.token);
|
||||
this.db
|
||||
.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?')
|
||||
.run(this.k(lock.threadId), lock.token);
|
||||
}
|
||||
|
||||
async extendLock(lock: Lock, ttlMs: number): Promise<boolean> {
|
||||
const newExpiry = Date.now() + ttlMs;
|
||||
const result = this.db
|
||||
.prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?')
|
||||
.run(newExpiry, lock.threadId, lock.token);
|
||||
.run(newExpiry, this.k(lock.threadId), lock.token);
|
||||
if (result.changes > 0) {
|
||||
lock.expiresAt = newExpiry;
|
||||
return true;
|
||||
@@ -113,24 +144,25 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
}
|
||||
|
||||
async forceReleaseLock(threadId: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(threadId);
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(this.k(threadId));
|
||||
}
|
||||
|
||||
// --- Lists ---
|
||||
|
||||
async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise<void> {
|
||||
const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null;
|
||||
const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as
|
||||
const k = this.k(key);
|
||||
const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(k) as
|
||||
| { maxIdx: number | null }
|
||||
| undefined;
|
||||
const nextIdx = (maxRow?.maxIdx ?? -1) + 1;
|
||||
this.db
|
||||
.prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)')
|
||||
.run(key, nextIdx, JSON.stringify(value), expiresAt);
|
||||
.run(k, nextIdx, JSON.stringify(value), expiresAt);
|
||||
if (options?.maxLength) {
|
||||
const cutoff = nextIdx - options.maxLength;
|
||||
if (cutoff >= 0) {
|
||||
this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(key, cutoff);
|
||||
this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(k, cutoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,20 +173,23 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
.prepare(
|
||||
'SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC',
|
||||
)
|
||||
.all(key, now) as { value: string }[];
|
||||
.all(this.k(key), now) as { value: string }[];
|
||||
return rows.map((r) => JSON.parse(r.value) as T);
|
||||
}
|
||||
|
||||
// --- Queue ---
|
||||
|
||||
async enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise<number> {
|
||||
// No k() here: appendToList prefixes at its own SQL boundary. Prefixing
|
||||
// twice would write `ns:ns:queue:<tid>` and the queue would never drain.
|
||||
// Resulting on-disk layout is `ns:queue:<tid>`.
|
||||
const key = `queue:${threadId}`;
|
||||
await this.appendToList(key, entry, { maxLength: maxSize });
|
||||
return await this.queueDepth(threadId);
|
||||
}
|
||||
|
||||
async dequeue(threadId: string): Promise<QueueEntry | null> {
|
||||
const key = `queue:${threadId}`;
|
||||
const key = this.k(`queue:${threadId}`);
|
||||
const row = this.db
|
||||
.prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1')
|
||||
.get(key) as { idx: number; value: string } | undefined;
|
||||
@@ -164,7 +199,7 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
}
|
||||
|
||||
async queueDepth(threadId: string): Promise<number> {
|
||||
const key = `queue:${threadId}`;
|
||||
const key = this.k(`queue:${threadId}`);
|
||||
const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as {
|
||||
count: number;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,14 @@ export interface MessagingGroup {
|
||||
id: string;
|
||||
channel_type: string;
|
||||
platform_id: string;
|
||||
/**
|
||||
* Adapter-instance name. Defaults to channel_type (the "default instance").
|
||||
* Column is NOT NULL (migration 016 backfills instance = channel_type);
|
||||
* optional on the TS type per the denied_at convention so fixtures that
|
||||
* build MessagingGroup objects don't need updating — createMessagingGroup
|
||||
* stamps the default.
|
||||
*/
|
||||
instance?: string;
|
||||
name: string | null;
|
||||
is_group: number; // 0 | 1
|
||||
unknown_sender_policy: UnknownSenderPolicy;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Guard for the raw-route half of src/webhook-server.ts —
|
||||
* registerWebhookHandler + the rawRoutes dispatch branch.
|
||||
*
|
||||
* Drives the REAL shared HTTP server on an ephemeral WEBHOOK_PORT (no
|
||||
* mocking of the routing layer): a registered raw route must dispatch,
|
||||
* unknown paths must 404, a throwing handler must surface as 500,
|
||||
* raw routes must coexist with Chat SDK adapter routes on the same
|
||||
* server, and stopWebhookServer must clear them.
|
||||
*/
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Chat } from 'chat';
|
||||
|
||||
import { registerWebhookAdapter, registerWebhookHandler, stopWebhookServer } from './webhook-server.js';
|
||||
|
||||
const PORT = 21000 + Math.floor(Math.random() * 20000);
|
||||
|
||||
async function post(path: string, body = '{}'): Promise<globalThis.Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fetch(`http://127.0.0.1:${PORT}/webhook/${path}`, { method: 'POST', body });
|
||||
} catch (err) {
|
||||
if (attempt >= 40) throw err;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await stopWebhookServer();
|
||||
delete process.env.WEBHOOK_PORT;
|
||||
});
|
||||
|
||||
describe('webhook server raw routes', () => {
|
||||
it('dispatches a registered raw route to its handler', async () => {
|
||||
process.env.WEBHOOK_PORT = String(PORT);
|
||||
const methods: string[] = [];
|
||||
registerWebhookHandler('ping', (req, res) => {
|
||||
methods.push(req.method || '');
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('pong');
|
||||
});
|
||||
|
||||
const res = await post('ping');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('pong');
|
||||
expect(methods).toEqual(['POST']);
|
||||
});
|
||||
|
||||
it('returns 404 for paths with no registered route', async () => {
|
||||
const res = await post('nope');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('turns a throwing handler into a 500 response', async () => {
|
||||
registerWebhookHandler('boom', () => {
|
||||
throw new Error('handler exploded');
|
||||
});
|
||||
|
||||
const res = await post('boom');
|
||||
expect(res.status).toBe(500);
|
||||
expect(await res.text()).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
it('coexists with Chat SDK adapter routes on the same server', async () => {
|
||||
const handler = vi.fn(async () => new Response('ok-chat', { status: 200 }));
|
||||
const chat = { webhooks: { fake: handler } } as unknown as Chat;
|
||||
registerWebhookAdapter(chat, 'fake');
|
||||
|
||||
const chatRes = await post('fake');
|
||||
expect(chatRes.status).toBe(200);
|
||||
expect(await chatRes.text()).toBe('ok-chat');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The raw route registered earlier is still live alongside it.
|
||||
const rawRes = await post('ping');
|
||||
expect(rawRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('clears raw routes on stopWebhookServer', async () => {
|
||||
await stopWebhookServer();
|
||||
|
||||
// Restart the server with a fresh route; the old raw routes must be gone.
|
||||
registerWebhookHandler('fresh', (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('fresh');
|
||||
});
|
||||
|
||||
const stale = await post('ping');
|
||||
expect(stale.status).toBe(404);
|
||||
|
||||
const fresh = await post('fresh');
|
||||
expect(fresh.status).toBe(200);
|
||||
expect(await fresh.text()).toBe('fresh');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Webhook server route/handler split tests.
|
||||
*
|
||||
* The route key (URL segment, `/webhook/<routingPath>`) and the handler key
|
||||
* (`chat.webhooks[adapterName]`) are independent: a named adapter instance
|
||||
* registers its own Chat under its own URL while dispatching to the same
|
||||
* SDK adapter name. The 2-arg default keeps the historical single-instance
|
||||
* route byte-identical. Conventions follow PR #2617: real HTTP server on a
|
||||
* fixed WEBHOOK_PORT, real fetch.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import type { Chat } from 'chat';
|
||||
|
||||
import { registerWebhookAdapter, stopWebhookServer } from './webhook-server.js';
|
||||
|
||||
const PORT = 3917;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
/** Minimal Chat stand-in: only `webhooks` is touched by the server. */
|
||||
function stubChat(tag: string, adapterName = 'slack'): { chat: Chat; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const chat = {
|
||||
webhooks: {
|
||||
[adapterName]: async (req: Request) => {
|
||||
calls.push(await req.text());
|
||||
return new Response(JSON.stringify({ via: tag }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown as Chat;
|
||||
return { chat, calls };
|
||||
}
|
||||
|
||||
async function post(path: string, body: string): Promise<Response> {
|
||||
// The server starts listening asynchronously after registration — retry
|
||||
// briefly on connection refusal instead of sleeping a fixed amount.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fetch(`${BASE}${path}`, { method: 'POST', body });
|
||||
} catch (err) {
|
||||
if (attempt >= 20) throw err;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.WEBHOOK_PORT = String(PORT);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopWebhookServer();
|
||||
delete process.env.WEBHOOK_PORT;
|
||||
});
|
||||
|
||||
describe('registerWebhookAdapter — route/handler split', () => {
|
||||
it('2-arg default: /webhook/<adapterName> dispatches to chat.webhooks[adapterName]', async () => {
|
||||
const { chat, calls } = stubChat('default');
|
||||
registerWebhookAdapter(chat, 'slack');
|
||||
|
||||
const res = await post('/webhook/slack', 'payload-default');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ via: 'default' });
|
||||
expect(calls).toEqual(['payload-default']);
|
||||
});
|
||||
|
||||
it('3-arg: routes by routingPath, dispatches by adapterName; the bare route stays unregistered', async () => {
|
||||
const { chat, calls } = stubChat('tester');
|
||||
registerWebhookAdapter(chat, 'slack', 'slack-tester');
|
||||
|
||||
const res = await post('/webhook/slack-tester', 'payload-tester');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ via: 'tester' });
|
||||
expect(calls).toEqual(['payload-tester']);
|
||||
|
||||
// Only the routed entry exists — /webhook/slack must 404, not leak into
|
||||
// the named instance's Chat.
|
||||
const miss = await post('/webhook/slack', 'stray');
|
||||
expect(miss.status).toBe(404);
|
||||
expect(calls).toEqual(['payload-tester']);
|
||||
});
|
||||
|
||||
it('two same-adapterName registrations under distinct paths hit their own Chat instances', async () => {
|
||||
const worker = stubChat('worker');
|
||||
const tester = stubChat('tester');
|
||||
registerWebhookAdapter(worker.chat, 'slack');
|
||||
registerWebhookAdapter(tester.chat, 'slack', 'slack-tester');
|
||||
|
||||
const r1 = await post('/webhook/slack', 'to-worker');
|
||||
const r2 = await post('/webhook/slack-tester', 'to-tester');
|
||||
expect(await r1.json()).toEqual({ via: 'worker' });
|
||||
expect(await r2.json()).toEqual({ via: 'tester' });
|
||||
expect(worker.calls).toEqual(['to-worker']);
|
||||
expect(tester.calls).toEqual(['to-tester']);
|
||||
});
|
||||
|
||||
it('unregistered path 404s', async () => {
|
||||
const { chat } = stubChat('only');
|
||||
registerWebhookAdapter(chat, 'slack');
|
||||
const res = await post('/webhook/nope', 'x');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
+54
-12
@@ -3,9 +3,12 @@
|
||||
*
|
||||
* Starts lazily on first adapter registration. Routes requests by path:
|
||||
* /webhook/{adapterName} → chat.webhooks[adapterName](request)
|
||||
* /webhook/{path} → raw handler from registerWebhookHandler(path, ...)
|
||||
*
|
||||
* Multiple Chat instances can register adapters — each adapter name maps
|
||||
* to its owning Chat instance.
|
||||
* to its owning Chat instance. Raw routes let modules receive non-Chat-SDK
|
||||
* webhooks (GitHub, payment providers, health checks) on the same server
|
||||
* without editing this file or opening a second port.
|
||||
*/
|
||||
import http from 'http';
|
||||
|
||||
@@ -20,7 +23,11 @@ interface WebhookEntry {
|
||||
adapterName: string;
|
||||
}
|
||||
|
||||
/** Node-style handler for raw (non-Chat-SDK) webhook routes. */
|
||||
export type RawWebhookHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>;
|
||||
|
||||
const routes = new Map<string, WebhookEntry>();
|
||||
const rawRoutes = new Map<string, RawWebhookHandler>();
|
||||
let server: http.Server | null = null;
|
||||
|
||||
/** Convert Node.js IncomingMessage to a Web API Request. */
|
||||
@@ -69,11 +76,35 @@ async function fromWebResponse(webRes: Response, nodeRes: http.ServerResponse):
|
||||
/**
|
||||
* Register a webhook adapter on the shared server.
|
||||
* Starts the server lazily on first call.
|
||||
*
|
||||
* `routingPath` is the URL segment (`/webhook/<routingPath>`); `adapterName`
|
||||
* stays the handler key into `chat.webhooks`. The split lets N instances of
|
||||
* one platform (each with its own Chat + signing secret) listen on distinct
|
||||
* URLs while dispatching to the same SDK adapter name. Defaulting
|
||||
* routingPath to adapterName keeps the historical single-instance route
|
||||
* byte-identical. Signature adopted verbatim from PR #2617 (@davekim917's
|
||||
* #1804 prototype) so the two changes converge textually.
|
||||
*/
|
||||
export function registerWebhookAdapter(chat: Chat, adapterName: string): void {
|
||||
routes.set(adapterName, { chat, adapterName });
|
||||
export function registerWebhookAdapter(chat: Chat, adapterName: string, routingPath: string = adapterName): void {
|
||||
routes.set(routingPath, { chat, adapterName });
|
||||
ensureServer();
|
||||
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${adapterName}` });
|
||||
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${routingPath}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a raw Node-style handler at /webhook/{path} on the shared server.
|
||||
*
|
||||
* For webhooks that don't flow through a Chat SDK adapter (GitHub, payment
|
||||
* providers, health checks): modules register their endpoint here instead of
|
||||
* editing this file or standing up a second HTTP server on another port.
|
||||
* The handler owns the request/response directly.
|
||||
*
|
||||
* Starts the server lazily on first call.
|
||||
*/
|
||||
export function registerWebhookHandler(path: string, handler: RawWebhookHandler): void {
|
||||
rawRoutes.set(path, handler);
|
||||
ensureServer();
|
||||
log.info('Webhook handler registered', { path: `/webhook/${path}` });
|
||||
}
|
||||
|
||||
function ensureServer(): void {
|
||||
@@ -93,14 +124,22 @@ function ensureServer(): void {
|
||||
}
|
||||
|
||||
const adapterName = match[1];
|
||||
const entry = routes.get(adapterName);
|
||||
if (!entry) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end(`Unknown adapter: ${adapterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Raw routes take priority — the handler writes the response itself.
|
||||
const rawHandler = rawRoutes.get(adapterName);
|
||||
if (rawHandler) {
|
||||
await rawHandler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = routes.get(adapterName);
|
||||
if (!entry) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end(`Unknown adapter: ${adapterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const webReq = await toWebRequest(req);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const webhooks = entry.chat.webhooks as Record<string, (r: Request, opts?: any) => Promise<Response>>;
|
||||
@@ -113,8 +152,10 @@ function ensureServer(): void {
|
||||
await fromWebResponse(webRes, res);
|
||||
} catch (err) {
|
||||
log.error('Webhook handler error', { adapter: adapterName, url: req.url, err });
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,6 +170,7 @@ export async function stopWebhookServer(): Promise<void> {
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
routes.clear();
|
||||
rawRoutes.clear();
|
||||
log.info('Webhook server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# The uninstaller lives in the setup driver now (setup/uninstall/).
|
||||
# Translate the short flags the old bash uninstaller accepted.
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-n) ARGS+=("--dry-run") ;;
|
||||
-y) ARGS+=("--yes") ;;
|
||||
*) ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/nanoclaw.sh" --uninstall "${ARGS[@]}"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"onecli-gateway": "1.36.0",
|
||||
"onecli-cli": "2.2.5"
|
||||
}
|
||||
Reference in New Issue
Block a user