mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c36835762 |
@@ -135,22 +135,7 @@ ncl groups restart --id <group-id>
|
||||
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
|
||||
### Default new groups to codex (optional)
|
||||
|
||||
New groups are created on the **instance default** (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). Installing this skill wires codex in but does NOT change that default — "installed" is not "authenticated", so the default stays claude until you opt in explicitly.
|
||||
|
||||
After install, ask the operator before flipping it:
|
||||
|
||||
> "Codex is installed. Default new agent groups to codex? Existing groups keep their current provider."
|
||||
|
||||
On yes — set it, then restart the host so it takes effect:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step set-env -- --key DEFAULT_AGENT_PROVIDER --value codex
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS; Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
This affects only groups created afterward. Per-group `ncl groups config update --provider` still overrides the default in either direction. Creation itself stays provider-agnostic (no `--provider` flag — provider is a DB property stamped from the instance default at creation).
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -206,6 +206,29 @@ ncl groups restart --id <group-id> --message "on_wake test"
|
||||
|
||||
Without `--message`, the container comes back on the next user message. From inside a container, `--id` is auto-filled and only the calling session restarts.
|
||||
|
||||
## Runaway task
|
||||
|
||||
A scheduled task (cron) that keeps firing is a `messages_in` row with `kind='task'` in a session's `inbound.db`. When an agent won't stop one — or you'd rather not ask the misbehaving agent — use the `ncl tasks` operator surface to inspect and stop it directly from the host:
|
||||
|
||||
```bash
|
||||
# List every pending/paused task across all groups (one row per series).
|
||||
ncl tasks list
|
||||
|
||||
# Scope to a single agent group.
|
||||
ncl tasks list --group <group-id>
|
||||
|
||||
# Inspect one task by id or series id.
|
||||
ncl tasks get --id <task-id>
|
||||
|
||||
# Stop it. cancel/pause/resume match by id OR series id, so a recurring
|
||||
# task's live next occurrence is caught, not just the row you looked up.
|
||||
ncl tasks cancel --id <task-id>
|
||||
ncl tasks pause --id <task-id>
|
||||
ncl tasks resume --id <task-id>
|
||||
```
|
||||
|
||||
The host is the legitimate writer of `inbound.db`, so these apply straight to the session DB without waking the container. `cancel` marks the series completed, `pause` holds a pending task, `resume` re-arms a paused one.
|
||||
|
||||
## Manual Container Probes
|
||||
|
||||
The container's entry point is `exec bun run /app/src/index.ts`; it talks only to the mounted session DBs, so there is no JSON to pipe in. To probe the image directly:
|
||||
@@ -258,13 +281,14 @@ docker builder prune -af
|
||||
|
||||
## Clearing a Session
|
||||
|
||||
Conversation continuity lives in the container-owned `session_state` table in `outbound.db` (the provider's session/continuation id). The agent's `/clear` clears it. To reset a session from the host, remove the session folder so a fresh one is provisioned on the next message:
|
||||
Conversation continuity lives in the container-owned `session_state` table in `outbound.db` (the provider's session/continuation id). The agent's `/clear` clears it. To reset a session from the host, just remove the session folder — it self-heals: the host re-creates the folder and re-initializes both DBs on the next inbound message, so you leave the central `sessions` row in place and don't need to restart the host.
|
||||
|
||||
```bash
|
||||
# Inspect first
|
||||
ncl sessions get <session-id>
|
||||
|
||||
# Remove a single session's folder (host re-provisions both DBs on next message)
|
||||
# Remove a single session's folder — the host re-provisions it (both DBs)
|
||||
# on the next message. No row deletion or host restart needed.
|
||||
rm -rf data/v2-sessions/<group>/<session>/
|
||||
```
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
The new group is created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To put it on a different provider, switch after creation with `ncl groups config update --id <group-id> --provider <name>`. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
|
||||
@@ -67,7 +67,7 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
New agent groups are created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To run a group on a different provider, switch it after creation with `ncl groups config update --provider <name>` (e.g. `codex`).
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ Parse the diff output for lines that contain `[BREAKING]` anywhere in the line.
|
||||
```
|
||||
|
||||
If no `[BREAKING]` lines are found:
|
||||
- Skip this step silently. Proceed to Step 7.
|
||||
- Skip this step silently. Proceed to Step 7 (skill updates check).
|
||||
|
||||
If one or more `[BREAKING]` lines are found:
|
||||
- Display a warning header to the user: "This update includes breaking changes that may require action:"
|
||||
@@ -244,7 +244,7 @@ If one or more `[BREAKING]` lines are found:
|
||||
- "Skip — I'll handle these manually"
|
||||
- Set `multiSelect: true` so the user can pick multiple skills 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.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
- [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 gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||
- **New groups inherit an instance-wide default provider.** `DEFAULT_AGENT_PROVIDER` in `.env` (default `claude`) sets which provider newly created agent groups get at creation; provider stays a per-group DB property, overridable via `ncl groups config update --provider` + restart. Existing groups are untouched — no migration, no retroactive flips.
|
||||
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
||||
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
|
||||
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
|
||||
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
|
||||
|
||||
@@ -105,6 +105,7 @@ ncl help
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| tasks | list, get, cancel, pause, resume | Scheduled tasks (cron jobs) in a group's sessions — operator surface to stop a runaway task |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
|
||||
@@ -21,6 +21,7 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
@@ -123,11 +124,11 @@ async function main(): Promise<void> {
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
// The operator's setup pick (NANOCLAW_PICKED_PROVIDER) when set; otherwise
|
||||
// undefined, so initGroupFilesystem falls back to the instance default and
|
||||
// stamps it onto the fresh config row.
|
||||
provider: pickedProvider,
|
||||
});
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
|
||||
// 3. CLI messaging group + wiring.
|
||||
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
|
||||
|
||||
@@ -205,13 +205,15 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
console.log(`Reusing agent group: ${ag.id} (${folder})`);
|
||||
}
|
||||
// Seed the config row, stamped with the effective provider: the operator's
|
||||
// setup pick (NANOCLAW_PICKED_PROVIDER) when this runs inside a setup run,
|
||||
// otherwise the persisted instance default. Workspace scaffolding is deferred
|
||||
// to the first spawn (group-init), where the DB-resolved provider decides the
|
||||
// surface (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory
|
||||
// scaffold). A reused group keeps its provider (INSERT OR IGNORE).
|
||||
ensureContainerConfig(ag.id, pickedProvider);
|
||||
// Ensure the config row exists; defer workspace scaffolding to the first
|
||||
// spawn (group-init), where the DB-resolved provider decides the surface
|
||||
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
|
||||
// — so a non-Claude group never gets stale CLAUDE.* files written here.
|
||||
ensureContainerConfig(ag.id);
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
const groupDir = path.resolve(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
||||
+4
-16
@@ -46,7 +46,6 @@ import './providers/index.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||
import { setPickedProvider } from './lib/picked-provider.js';
|
||||
import { upsertEnvVar } from './set-env.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
@@ -66,7 +65,6 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './l
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
import { DEFAULT_AGENT_PROVIDER } from '../src/config.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
@@ -377,12 +375,6 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
await runAuthStep();
|
||||
}
|
||||
// Persist the pick as the instance-wide default so every future group
|
||||
// (channel-approved, ncl-created) is created on this provider. Read from
|
||||
// .env at host start; per-group `ncl groups config update --provider` wins.
|
||||
// Only after install + auth succeeded — a failed setup must not leave new
|
||||
// groups defaulting to an unauthenticated runtime.
|
||||
upsertEnvVar('DEFAULT_AGENT_PROVIDER', agentProvider);
|
||||
}
|
||||
|
||||
if (!skip.has('mounts')) {
|
||||
@@ -835,18 +827,14 @@ async function askAgentProviderChoice(): Promise<string> {
|
||||
phEmit('agent_provider_chosen', { provider: preset, preset: true });
|
||||
return preset;
|
||||
}
|
||||
// The pick is persisted as the instance default (DEFAULT_AGENT_PROVIDER), so
|
||||
// pre-select the current default — a re-run Enter-through then preserves it
|
||||
// instead of silently resetting it to claude. Fall back to claude if the
|
||||
// persisted default isn't an offered option (e.g. its provider was removed).
|
||||
const currentDefault = options.some((o) => o.value === DEFAULT_AGENT_PROVIDER)
|
||||
? DEFAULT_AGENT_PROVIDER
|
||||
: 'claude';
|
||||
// The pick installs and authenticates a runtime — it is not an
|
||||
// install-wide default, so re-runs safely Enter-through on claude (its
|
||||
// auth flow short-circuits when the secret already exists).
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Which agent runtime should power your assistant?',
|
||||
options,
|
||||
initialValue: currentDefault,
|
||||
initialValue: 'claude',
|
||||
}),
|
||||
) as string;
|
||||
setupLog.userInput('agent_provider', choice);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* The agent runtime the operator picked in THIS setup run, carried to the
|
||||
* group-creation child processes over the process boundary.
|
||||
* The agent runtime the operator picked in THIS setup run.
|
||||
*
|
||||
* There is no `--provider` flag in the creation contract — provider is a DB
|
||||
* property of a group. Setup persists the pick two ways: as the install-wide
|
||||
* default (`DEFAULT_AGENT_PROVIDER` in `.env`, see src/config.ts), which every
|
||||
* future group inherits at creation via the `ensureContainerConfig` chokepoint;
|
||||
* and here, in a setup-run-scoped env var, so the FIRST agent created in the
|
||||
* same run (by `init-first-agent` / `init-cli-agent`, which run as child
|
||||
* processes) is stamped with the pick before the welcome wakes the container —
|
||||
* without waiting for the host to restart and reload `.env`. `undefined` /
|
||||
* `'claude'` means no run-scoped pick; the creation scripts then fall back to
|
||||
* the install-wide default.
|
||||
* There is no install-wide default provider and no `--provider` in the
|
||||
* creation contract — provider is a DB property of a group. Setup is the one
|
||||
* orchestrator that knows the operator's pick, so it stashes it here (set once
|
||||
* at the auth step). The group-creation scripts (`init-first-agent`,
|
||||
* `init-cli-agent`) run as **child processes**, so the pick is carried over the
|
||||
* process boundary via an environment variable they inherit; they apply it to
|
||||
* the group at creation, before the welcome wakes the container. This is the
|
||||
* only place the value lives — a setup-run-scoped global, NOT a persisted
|
||||
* install default. `undefined` / `'claude'` means the built-in default and no
|
||||
* provider write at all.
|
||||
*/
|
||||
const ENV_KEY = 'NANOCLAW_PICKED_PROVIDER';
|
||||
|
||||
|
||||
@@ -55,19 +55,12 @@ describe('setup carries the picked provider to creation via a setup-run env var'
|
||||
// The creation scripts run as child processes, inherit the env var, and apply
|
||||
// it to the group's runtime config — container_configs.provider, the source of
|
||||
// truth materialized into container.json (agent_provider is deprecated) — before
|
||||
// the welcome wakes the container, falling back to the instance default
|
||||
// (DEFAULT_AGENT_PROVIDER) when the env var is unset. No `--provider` flag in
|
||||
// the contract (above). init-first-agent stamps directly via
|
||||
// ensureContainerConfig; init-cli-agent threads it through initGroupFilesystem.
|
||||
const applyPattern: Record<string, RegExp> = {
|
||||
'scripts/init-first-agent.ts': /ensureContainerConfig\([^)]*pickedProvider/,
|
||||
'scripts/init-cli-agent.ts': /provider:\s*pickedProvider/,
|
||||
};
|
||||
for (const [file, pattern] of Object.entries(applyPattern)) {
|
||||
// the welcome wakes the container. No `--provider` flag in the contract (above).
|
||||
for (const file of ['scripts/init-first-agent.ts', 'scripts/init-cli-agent.ts']) {
|
||||
it(`${file} applies the env-carried provider to container_configs.provider`, () => {
|
||||
const src = read(file);
|
||||
expect(src).toContain('NANOCLAW_PICKED_PROVIDER');
|
||||
expect(src).toMatch(pattern);
|
||||
expect(src).toMatch(/updateContainerConfigScalars\([^)]*provider:\s*pickedProvider/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+5
-6
@@ -126,12 +126,11 @@ export async function run(args: string[]): Promise<void> {
|
||||
const db = initDb(dbPath);
|
||||
runMigrations(db);
|
||||
|
||||
// 1. Create or find agent group. The workspace is scaffolded at the first
|
||||
// spawn (group-init), where the DB-resolved provider is known; here we only
|
||||
// seed the config row — stamped with the instance default so a newly wired
|
||||
// channel group is created on the operator's chosen provider (per-group
|
||||
// `ncl groups config update --provider` still overrides). A reused group
|
||||
// keeps its existing provider (INSERT OR IGNORE).
|
||||
// 1. Create or find agent group. Provider-agnostic: provider is a DB
|
||||
// property set via `ncl groups config update --provider`, not a creation
|
||||
// flag. The workspace is scaffolded at the first spawn (group-init), where
|
||||
// the DB-resolved provider is known; here we only ensure the config row
|
||||
// exists so that update has a row to write.
|
||||
let agentGroup = getAgentGroupByFolder(parsed.folder);
|
||||
if (!agentGroup) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
+25
-31
@@ -18,34 +18,6 @@ import path from 'path';
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* Upsert a `KEY=VALUE` line into the project's `.env`, returning whether the
|
||||
* key already existed. The canonical writer for new `.env` edits (legacy setup
|
||||
* steps still write directly) so flows don't invent grep/sed pipelines (which
|
||||
* can't be allowlisted tightly).
|
||||
*/
|
||||
export function upsertEnvVar(key: string, value: string): { existed: boolean } {
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
|
||||
}
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = '';
|
||||
if (fs.existsSync(envFile)) {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
}
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const existed = lineRegex.test(content);
|
||||
const newLine = `${key}=${value}`;
|
||||
if (existed) {
|
||||
content = content.replace(lineRegex, newLine);
|
||||
} else {
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
content = content + sep + newLine + '\n';
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
return { existed };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const keyIdx = args.indexOf('--key');
|
||||
const valueIdx = args.indexOf('--value');
|
||||
@@ -61,15 +33,37 @@ export async function run(args: string[]): Promise<void> {
|
||||
const key = args[keyIdx + 1];
|
||||
const value = args[valueIdx + 1];
|
||||
|
||||
const { existed } = upsertEnvVar(key, value);
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
|
||||
let content = '';
|
||||
if (fs.existsSync(envFile)) {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
}
|
||||
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const newLine = `${key}=${value}`;
|
||||
const existed = lineRegex.test(content);
|
||||
|
||||
if (existed) {
|
||||
content = content.replace(lineRegex, newLine);
|
||||
} else {
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
content = content + sep + newLine + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(envFile, content);
|
||||
log.info('Updated .env', { key, existed });
|
||||
|
||||
let synced = false;
|
||||
if (syncContainer) {
|
||||
const projectRoot = process.cwd();
|
||||
const dataEnvDir = path.join(projectRoot, 'data', 'env');
|
||||
fs.mkdirSync(dataEnvDir, { recursive: true });
|
||||
fs.copyFileSync(path.join(projectRoot, '.env'), path.join(dataEnvDir, 'env'));
|
||||
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
|
||||
synced = true;
|
||||
log.info('Synced .env to container mount', { path: 'data/env/env' });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
ensureContainerConfig,
|
||||
getContainerConfig,
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
@@ -91,11 +90,6 @@ registerResource({
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
// Seed the config row now so the group is created on the instance
|
||||
// default (ensureContainerConfig stamps it) and is spawnable without
|
||||
// waiting for the startup backfill. Per-group overrides via
|
||||
// `groups config update --provider` still win.
|
||||
ensureContainerConfig(group.id);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,3 +14,4 @@ import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
import './sessions.js';
|
||||
import './tasks.js';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* `ncl tasks` operator surface — the only host-side way to inspect and stop
|
||||
* scheduled tasks (cron jobs) without asking the agent that owns them.
|
||||
*
|
||||
* Tasks are `messages_in` rows with `kind='task'` in a session's inbound.db,
|
||||
* so the resource opens each session DB from the host (the legitimate writer),
|
||||
* the same way destinations.ts projects rows. These tests drive dispatch with
|
||||
* `caller: 'host'` — the path a real operator socket connection uses.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-tasks' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-tasks';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { insertRecurrence, insertTask } from '../../modules/scheduling/db.js';
|
||||
import { initSessionFolder, inboundDbPath, openInboundDb } from '../../session-manager.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `tasks-*` commands.
|
||||
import './tasks.js';
|
||||
|
||||
const AG = 'ag-tasks';
|
||||
const SESSION = 'sess-tasks-1';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readTaskStatus(id: string): string | undefined {
|
||||
const db = new Database(inboundDbPath(AG, SESSION), { readonly: true });
|
||||
const row = db.prepare('SELECT status FROM messages_in WHERE id = ?').get(id) as { status: string } | undefined;
|
||||
db.close();
|
||||
return row?.status;
|
||||
}
|
||||
|
||||
function seedTask(id: string, prompt: string): void {
|
||||
const db = openInboundDb(AG, SESSION);
|
||||
try {
|
||||
insertTask(db, {
|
||||
id,
|
||||
processAfter: now(),
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt }),
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('tasks CLI resource (operator surface)', () => {
|
||||
beforeEach(() => {
|
||||
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: 'tasks', folder: 'tasks', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: SESSION,
|
||||
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, SESSION);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('list: returns a pending task from the session inbound.db', async () => {
|
||||
seedTask('task-1', 'water the plants');
|
||||
|
||||
const resp = await dispatch({ id: 'req-list', command: 'tasks-list', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const rows = (resp as { ok: true; data: unknown }).data as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
agent_group_id: AG,
|
||||
session_id: SESSION,
|
||||
id: 'task-1',
|
||||
status: 'pending',
|
||||
prompt: 'water the plants',
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel: cancels a task matched by its id', async () => {
|
||||
seedTask('task-1', 'water the plants');
|
||||
expect(readTaskStatus('task-1')).toBe('pending');
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-cancel', command: 'tasks-cancel', args: { id: 'task-1' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect((resp as { ok: true; data: { affected: number } }).data.affected).toBe(1);
|
||||
expect(readTaskStatus('task-1')).toBe('completed');
|
||||
});
|
||||
|
||||
it('cancel: cancels a recurring follow-up matched by series id', async () => {
|
||||
// Original firing plus a live follow-up occurrence that shares the series
|
||||
// id but has a distinct row id — the shape recurrence produces. Cancelling
|
||||
// by the series id must reach the follow-up whose id != the arg.
|
||||
seedTask('series-1', 'daily standup');
|
||||
const db = openInboundDb(AG, SESSION);
|
||||
try {
|
||||
insertRecurrence(
|
||||
db,
|
||||
{
|
||||
id: 'series-1',
|
||||
kind: 'task',
|
||||
content: JSON.stringify({ prompt: 'daily standup' }),
|
||||
recurrence: 'daily',
|
||||
process_after: now(),
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'series-1',
|
||||
},
|
||||
'occ-2',
|
||||
now(),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
expect(readTaskStatus('occ-2')).toBe('pending');
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-cancel-series', command: 'tasks-cancel', args: { id: 'series-1' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
// Both the original row and the follow-up share series_id 'series-1'.
|
||||
expect((resp as { ok: true; data: { affected: number } }).data.affected).toBe(2);
|
||||
expect(readTaskStatus('occ-2')).toBe('completed');
|
||||
expect(readTaskStatus('series-1')).toBe('completed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { getSessionsByAgentGroup } from '../../db/sessions.js';
|
||||
import { cancelTask, pauseTask, resumeTask } from '../../modules/scheduling/db.js';
|
||||
import { inboundDbPath, openInboundDb } from '../../session-manager.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
|
||||
/**
|
||||
* `ncl tasks` — the operator surface for scheduled tasks (cron jobs).
|
||||
*
|
||||
* Tasks are `messages_in` rows with `kind='task'` living in each session's
|
||||
* host-owned `inbound.db` — there is no central table, so the auto-CRUD in
|
||||
* crud.ts (which is central-DB-bound) can't back this resource. Instead we
|
||||
* iterate `getSessionsByAgentGroup` and open each session's `inbound.db`
|
||||
* directly from the host process, which is the sole legitimate writer of that
|
||||
* file (see session-manager.ts). This is the same shape destinations.ts uses
|
||||
* to project rows into live sessions.
|
||||
*
|
||||
* Before this existed the only way to stop a runaway recurring task was to ask
|
||||
* the (misbehaving) agent to cancel it via its container-side task tools.
|
||||
*/
|
||||
|
||||
interface ProjectedTask {
|
||||
agent_group_id: string;
|
||||
session_id: string;
|
||||
/** The stable series handle — one row per series (see list_tasks). */
|
||||
id: string;
|
||||
status: string;
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which agent groups the caller may act on. Agent callers are pinned
|
||||
* to their own group (cli_scope=group), mirroring how groups/sessions/
|
||||
* destinations keep a container inside its own agent group. Host (operator)
|
||||
* callers may target one group via --group or every group when it's omitted.
|
||||
*/
|
||||
function targetAgentGroupIds(group: string | undefined, ctx: CallerContext): string[] {
|
||||
if (ctx.caller === 'agent') return [ctx.agentGroupId];
|
||||
if (group) return [group];
|
||||
return getAllAgentGroups().map((g) => g.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `cb` against every session's host-owned inbound.db for the resolved
|
||||
* agent groups. Skips sessions whose inbound.db hasn't been created yet
|
||||
* (same guard as write-destinations.ts) so opening one never fabricates an
|
||||
* empty, schema-less DB file.
|
||||
*/
|
||||
function forEachSessionDb(
|
||||
groupIds: string[],
|
||||
cb: (db: Database.Database, agentGroupId: string, sessionId: string) => void,
|
||||
): void {
|
||||
for (const agentGroupId of groupIds) {
|
||||
for (const session of getSessionsByAgentGroup(agentGroupId)) {
|
||||
if (!fs.existsSync(inboundDbPath(agentGroupId, session.id))) continue;
|
||||
const db = openInboundDb(agentGroupId, session.id);
|
||||
try {
|
||||
cb(db, agentGroupId, session.id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function taskPrompt(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { prompt?: unknown };
|
||||
return typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Count the live rows a control op would touch, so we can report `affected`. */
|
||||
function countTargetRows(db: Database.Database, taskId: string, statuses: string[]): number {
|
||||
const placeholders = statuses.map(() => '?').join(', ');
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM messages_in
|
||||
WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN (${placeholders})`,
|
||||
)
|
||||
.get(taskId, taskId, ...statuses) as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared body for cancel/pause/resume. `statuses` is the set of live statuses
|
||||
* the underlying db.ts helper actually mutates, used only to report how many
|
||||
* rows were affected. The helper itself matches by id OR series_id, so a
|
||||
* recurring task's live next occurrence is caught, not just the row an agent
|
||||
* happens to remember.
|
||||
*/
|
||||
function control(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
statuses: string[],
|
||||
apply: (db: Database.Database, taskId: string) => void,
|
||||
): { taskId: string; affected: number; sessions: string[] } {
|
||||
const taskId = args.id as string | undefined;
|
||||
if (!taskId) throw new Error('--id is required (task id or series id)');
|
||||
const groupIds = targetAgentGroupIds(args.group as string | undefined, ctx);
|
||||
|
||||
let affected = 0;
|
||||
const sessions: string[] = [];
|
||||
forEachSessionDb(groupIds, (db, _agentGroupId, sessionId) => {
|
||||
const n = countTargetRows(db, taskId, statuses);
|
||||
if (n === 0) return;
|
||||
apply(db, taskId);
|
||||
affected += n;
|
||||
sessions.push(sessionId);
|
||||
});
|
||||
|
||||
return { taskId, affected, sessions };
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'task',
|
||||
plural: 'tasks',
|
||||
// Tasks aren't a central-DB table — they're messages_in rows in per-session
|
||||
// inbound.db files. `table` is unused because no generic CRUD op is enabled.
|
||||
table: 'messages_in',
|
||||
description:
|
||||
"Scheduled task (cron job) — a messages_in row with kind=task in a session inbound.db. Operator surface to inspect and stop tasks without going through the agent. list/get show one row per series; cancel/pause/resume match by id OR series id so a recurring task's live next occurrence is caught.",
|
||||
idColumn: 'id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group whose session holds the task.' },
|
||||
{ name: 'session_id', type: 'string', description: 'Session whose inbound.db holds the task.' },
|
||||
{ name: 'id', type: 'string', description: 'Series id — the stable handle for the task.' },
|
||||
{ name: 'status', type: 'string', description: '"pending" or "paused".' },
|
||||
{ name: 'process_after', type: 'string', description: 'When the next occurrence is due (UTC).' },
|
||||
{ name: 'recurrence', type: 'string', description: 'Recurrence rule, or null for a one-shot.' },
|
||||
{ name: 'prompt', type: 'string', description: 'The task prompt the agent runs when it fires.' },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description:
|
||||
'List pending and paused scheduled tasks (one row per series). Use --group to scope to a single agent group; defaults to all groups.',
|
||||
handler: async (args, ctx) => {
|
||||
const groupIds = targetAgentGroupIds(args.group as string | undefined, ctx);
|
||||
const tasks: ProjectedTask[] = [];
|
||||
forEachSessionDb(groupIds, (db, agentGroupId, sessionId) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status IN ('pending', 'paused')
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
content: string;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
tasks.push({
|
||||
agent_group_id: agentGroupId,
|
||||
session_id: sessionId,
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
process_after: r.process_after,
|
||||
recurrence: r.recurrence,
|
||||
prompt: taskPrompt(r.content),
|
||||
});
|
||||
}
|
||||
});
|
||||
return tasks;
|
||||
},
|
||||
},
|
||||
get: {
|
||||
access: 'open',
|
||||
description: 'Get a scheduled task by id or series id. Use --id <task-id>, optional --group.',
|
||||
handler: async (args, ctx) => {
|
||||
const taskId = args.id as string | undefined;
|
||||
if (!taskId) throw new Error('--id is required (task id or series id)');
|
||||
const groupIds = targetAgentGroupIds(args.group as string | undefined, ctx);
|
||||
let found: ProjectedTask | undefined;
|
||||
forEachSessionDb(groupIds, (db, agentGroupId, sessionId) => {
|
||||
if (found) return;
|
||||
const r = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND (id = ? OR series_id = ?) AND status IN ('pending', 'paused')
|
||||
GROUP BY series_id`,
|
||||
)
|
||||
.get(taskId, taskId) as
|
||||
| { id: string; status: string; process_after: string | null; recurrence: string | null; content: string }
|
||||
| undefined;
|
||||
if (r) {
|
||||
found = {
|
||||
agent_group_id: agentGroupId,
|
||||
session_id: sessionId,
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
process_after: r.process_after,
|
||||
recurrence: r.recurrence,
|
||||
prompt: taskPrompt(r.content),
|
||||
};
|
||||
}
|
||||
});
|
||||
if (!found) throw new Error(`task not found: ${taskId}`);
|
||||
return found;
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
access: 'approval',
|
||||
description: 'Cancel a scheduled task (matches id or series id). Use --id <task-id>, optional --group.',
|
||||
handler: async (args, ctx) => control(args, ctx, ['pending', 'paused'], cancelTask),
|
||||
},
|
||||
pause: {
|
||||
access: 'approval',
|
||||
description: 'Pause a scheduled task so it stops firing until resumed. Use --id <task-id>, optional --group.',
|
||||
handler: async (args, ctx) => control(args, ctx, ['pending'], pauseTask),
|
||||
},
|
||||
resume: {
|
||||
access: 'approval',
|
||||
description: 'Resume a paused scheduled task. Use --id <task-id>, optional --group.',
|
||||
handler: async (args, ctx) => control(args, ctx, ['paused'], resumeTask),
|
||||
},
|
||||
},
|
||||
});
|
||||
+1
-19
@@ -6,27 +6,9 @@ import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'ONECLI_URL',
|
||||
'ONECLI_API_KEY',
|
||||
'TZ',
|
||||
'DEFAULT_AGENT_PROVIDER',
|
||||
]);
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);
|
||||
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
|
||||
// Instance-wide default agent provider for newly created groups. `claude` (the
|
||||
// built-in provider) when unset, so existing installs are unaffected on upgrade.
|
||||
// Applied only at group-creation time (stamped onto the config row) — never in
|
||||
// provider resolution — so existing groups are never retroactively flipped.
|
||||
// Per-group `ncl groups config update --provider` still overrides it.
|
||||
export const DEFAULT_AGENT_PROVIDER = (
|
||||
process.env.DEFAULT_AGENT_PROVIDER ||
|
||||
envConfig.DEFAULT_AGENT_PROVIDER ||
|
||||
'claude'
|
||||
).toLowerCase();
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* ensureContainerConfig provider stamping (global-default-provider feature).
|
||||
*
|
||||
* Two load-bearing guarantees:
|
||||
* 1. A fresh row is stamped with the given provider (claude → NULL), so a new
|
||||
* group is created on the instance default.
|
||||
* 2. An existing row is never overwritten (INSERT OR IGNORE), so enabling a
|
||||
* non-claude default never retroactively flips existing groups.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb } from './connection.js';
|
||||
import { runMigrations } from './migrations/index.js';
|
||||
import { createAgentGroup } from './agent-groups.js';
|
||||
import { ensureContainerConfig, getContainerConfig } from './container-configs.js';
|
||||
|
||||
function makeGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
describe('ensureContainerConfig provider stamping', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
it('stamps a non-default provider on a fresh row; claude is stored as NULL', () => {
|
||||
makeGroup('ag-codex');
|
||||
ensureContainerConfig('ag-codex', 'codex');
|
||||
expect(getContainerConfig('ag-codex')?.provider).toBe('codex');
|
||||
|
||||
makeGroup('ag-claude');
|
||||
ensureContainerConfig('ag-claude', 'claude');
|
||||
expect(getContainerConfig('ag-claude')?.provider).toBeNull();
|
||||
|
||||
// Casing is normalized to match what resolution lowercases to.
|
||||
makeGroup('ag-cased');
|
||||
ensureContainerConfig('ag-cased', 'Codex');
|
||||
expect(getContainerConfig('ag-cased')?.provider).toBe('codex');
|
||||
|
||||
makeGroup('ag-cased-claude');
|
||||
ensureContainerConfig('ag-cased-claude', 'Claude');
|
||||
expect(getContainerConfig('ag-cased-claude')?.provider).toBeNull();
|
||||
});
|
||||
|
||||
it('never overwrites an existing row — existing groups are not flipped', () => {
|
||||
makeGroup('ag-existing');
|
||||
ensureContainerConfig('ag-existing', 'codex'); // existing group already on codex
|
||||
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
|
||||
|
||||
// A later bare ensure (defensive re-init, or a changed instance default)
|
||||
// must NOT change it — INSERT OR IGNORE keeps the row frozen.
|
||||
ensureContainerConfig('ag-existing');
|
||||
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DEFAULT_AGENT_PROVIDER } from '../config.js';
|
||||
import type { ContainerConfigRow } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
@@ -40,36 +39,14 @@ export function createContainerConfig(config: ContainerConfigRow): void {
|
||||
.run(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a config row if one doesn't exist, stamping the provider. Idempotent —
|
||||
* no-ops if the row already exists, so an existing group's provider is never
|
||||
* overwritten (load-bearing: this is how the global default stays "new groups
|
||||
* only" for groups that already have a row).
|
||||
*
|
||||
* An absent `provider` takes the instance default (`DEFAULT_AGENT_PROVIDER`);
|
||||
* `claude` and an absent value that resolves to claude are stored as NULL — the
|
||||
* column means "follows the built-in default", matching pre-feature rows.
|
||||
*/
|
||||
export function ensureContainerConfig(agentGroupId: string, provider?: string | null): void {
|
||||
// Single chokepoint for the instance default: a fresh row with no explicit
|
||||
// provider is stamped with DEFAULT_AGENT_PROVIDER, so every new-group creation
|
||||
// path inherits it without each having to remember. INSERT OR IGNORE keeps an
|
||||
// EXISTING row untouched — so this stays "new groups only" for any group that
|
||||
// already has a config row (backfillContainerConfigs seeds one for every group
|
||||
// at host startup; a non-claude default would only reach a row-less *legacy*
|
||||
// group if a creation script reused it before that first backfill ran). Callers
|
||||
// that know the provider (subagent → parent's, spawn → resolved) pass it
|
||||
// explicitly and override the default.
|
||||
// `claude` (the built-in default) and casing normalize to NULL/lowercase so the
|
||||
// column matches what resolution lowercases to.
|
||||
const normalized = (provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
|
||||
const stamped = normalized && normalized !== 'claude' ? normalized : null;
|
||||
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
|
||||
export function ensureContainerConfig(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, provider, updated_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
|
||||
VALUES (?, ?)`,
|
||||
)
|
||||
.run(agentGroupId, stamped, new Date().toISOString());
|
||||
.run(agentGroupId, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
|
||||
|
||||
+8
-17
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, DEFAULT_AGENT_PROVIDER, GROUPS_DIR } from './config.js';
|
||||
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';
|
||||
@@ -53,18 +53,11 @@ export function initGroupFilesystem(
|
||||
): void {
|
||||
const initialized: string[] = [];
|
||||
|
||||
// `opts.provider` absent means "caller has no provider opinion" — for a
|
||||
// brand-new group that resolves to the instance default, so the scaffold and
|
||||
// the stamped config row both match it. A caller that knows the provider
|
||||
// (subagent → parent's, spawn → resolved, setup → operator's pick) passes it
|
||||
// explicitly — including `claude` — which pins the group and skips the
|
||||
// default. ensureContainerConfig is INSERT OR IGNORE, so this only stamps a
|
||||
// genuinely new group; existing rows are never touched.
|
||||
const providerHint = (opts?.provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
|
||||
|
||||
// Default agent surfaces apply unless the provider declares (at registration)
|
||||
// that it provides its own.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(providerHint);
|
||||
// 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);
|
||||
@@ -113,10 +106,8 @@ export function initGroupFilesystem(
|
||||
}
|
||||
|
||||
// Ensure container_configs row exists in the DB. Idempotent — no-op if
|
||||
// the row already exists (e.g. created by backfill or group creation). On a
|
||||
// fresh row, stamp the resolved provider hint so a new group is created on
|
||||
// the instance default (or the caller's explicit pick).
|
||||
ensureContainerConfig(group.id, providerHint);
|
||||
// the row already exists (e.g. created by backfill or group creation).
|
||||
ensureContainerConfig(group.id);
|
||||
initialized.push('container_configs');
|
||||
|
||||
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
||||
|
||||
@@ -16,6 +16,7 @@ const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockUpdateScalars = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
|
||||
@@ -25,6 +26,7 @@ vi.mock('../approvals/index.js', () => ({
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
ensureContainerConfig: () => {},
|
||||
updateContainerConfigScalars: (...a: unknown[]) => mockUpdateScalars(...a),
|
||||
}));
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
|
||||
@@ -78,10 +80,8 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
|
||||
it('child inherits the creator provider (codex parent → codex child)', async () => {
|
||||
// A subagent must run on the same authenticated runtime as its creator —
|
||||
// on a codex-only install a claude default would 401. The provider is
|
||||
// passed to initGroupFilesystem, which stamps the child's config row.
|
||||
// Red-on-delete: dropping the inheritance lets the child fall through to the
|
||||
// instance default instead of codex.
|
||||
// on a codex-only install a claude default would 401. Red-on-delete:
|
||||
// dropping the inheritance leaves the child provider-less (→ claude).
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
@@ -90,19 +90,15 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'codex' }),
|
||||
);
|
||||
expect(mockUpdateScalars).toHaveBeenCalledWith(expect.any(String), { provider: 'codex' });
|
||||
});
|
||||
|
||||
it('claude creator pins the child to claude, not the instance default', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // parent has no explicit provider
|
||||
it('claude creator leaves the child provider unset (built-in default)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
// The child inherits the parent's EFFECTIVE provider (claude), passed
|
||||
// explicitly so it never falls through to a non-claude instance default.
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'claude' }),
|
||||
);
|
||||
expect(mockUpdateScalars).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getContainerConfig, updateContainerConfigScalars } from '../../db/container-configs.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
@@ -163,15 +163,17 @@ async function performCreateAgent(
|
||||
created_at: now,
|
||||
};
|
||||
createAgentGroup(newGroup);
|
||||
// Subagent path: a child inherits its creator's EFFECTIVE provider, NOT the
|
||||
// instance-wide default — so a child is never spawned on a runtime the parent
|
||||
// can't reach (e.g. a codex-only install where claude isn't authenticated).
|
||||
// Passing it explicitly to initGroupFilesystem pins the child's scaffold and
|
||||
// stamps its config row in one step (a NULL parent resolves to claude). The
|
||||
// A subagent inherits its creator's provider. Provider is a DB property; the
|
||||
// child is created provider-agnostic, then stamped with the parent's runtime
|
||||
// so a single-provider install (e.g. codex-only, where claude isn't
|
||||
// authenticated) doesn't spawn a child on a runtime it can't reach. The
|
||||
// operator can still flip a child later with `ncl groups config update
|
||||
// --provider`.
|
||||
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? 'claude';
|
||||
// --provider`. claude (the built-in default) leaves the column unset.
|
||||
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? undefined;
|
||||
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined, provider: parentProvider });
|
||||
if (parentProvider) {
|
||||
updateContainerConfigScalars(newGroup.id, { provider: parentProvider });
|
||||
}
|
||||
|
||||
// Insert bidirectional destination rows (= ACL grants).
|
||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||
|
||||
@@ -292,10 +292,8 @@ export function createNewAgentGroup(name: string): AgentGroup {
|
||||
});
|
||||
|
||||
const ag = getAgentGroup(agId)!;
|
||||
// Channel-approved groups are created on the instance default provider
|
||||
// (DEFAULT_AGENT_PROVIDER, or claude when unset) — initGroupFilesystem stamps
|
||||
// it onto the fresh config row. The operator flips a group afterward with
|
||||
// `ncl groups config update --provider`.
|
||||
// Channel-approved groups get the built-in default provider (claude); the
|
||||
// operator flips a group with `ncl groups config update --provider`.
|
||||
initGroupFilesystem(ag);
|
||||
return ag;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user