Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions apps/full-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,26 @@ pnpm --filter full-app dev

Open `http://localhost:5173/dev-login`. The `.env` defaults bind the backend and Vite to loopback, enable only the non-production dev-login helper, and intentionally leave mail unset so local accounts are not blocked on email verification. The helper also rejects non-loopback clients, including clients forwarded through the Vite dev proxy. Uncomment both mail values when testing verification flows. Stop Postgres with `docker compose -f apps/full-app/docker-compose.dev.yml down`; its named `postgres-data` volume persists for the next interactive run. Use `down --volumes` only when intentionally resetting local data. Override only the interactive Postgres host port with `BORING_DEV_POSTGRES_PORT`; the smoke always supplies an allocated value and removes its own uniquely named volume.

### Hosted automation trigger
### Hosted automation scheduler

Set `BORING_AUTOMATION_TRIGGER_TOKEN` to a deployment secret. The platform
scheduler invokes:
The hosted Automation plugin starts an internal Croner wake-up once per minute
by default and evaluates the current minute once when Fastify becomes ready.
Each automation creator is re-authorized before execution, overlapping ticks in
one process are skipped, and database constraints prevent duplicate active or
scheduled-minute runs across processes.

For a deployment that intentionally uses an external scheduler, set
`BORING_AUTOMATION_INTERNAL_SCHEDULER=false` and set
`BORING_AUTOMATION_TRIGGER_TOKEN` to a deployment secret. Invoke:

```bash
curl --fail --silent --show-error -X POST \
-H "Authorization: Bearer $BORING_AUTOMATION_TRIGGER_TOKEN" \
http://localhost:5173/api/v1/boring-automation/due/hosted
```

The token is service-principal authentication only; each automation creator is
re-authorized before execution. The plugin does not start a timer.
The token authenticates only the external service principal. The endpoint stays
available as an operational fallback when the internal scheduler is enabled.

## Scripts

Expand Down Expand Up @@ -129,6 +136,7 @@ Common optional:
| `PORT` / `HOST` / `LOG_LEVEL` | `3000` / `0.0.0.0` / `info` | HTTP server. The local `.env.example` narrows `HOST` to `127.0.0.1`. |
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Allowed origins |
| `BORING_PLUGIN_AUTHORING` | `0` | `1` installs the plugin-authoring surface |
| `BORING_AUTOMATION_INTERNAL_SCHEDULER` | `true` | Set to `false` only when an external scheduler owns hosted Automation wake-ups |
| `ENABLE_DEV_LOGIN` | `0` | Dev server only. Set `1` to enable `GET /dev-login`, which creates/signs in a local dev user and redirects to `/`. Ignored in `NODE_ENV=production`. |
| `DEV_LOGIN_EMAIL`, `DEV_LOGIN_PASSWORD`, `DEV_LOGIN_NAME` | `[email protected]`, strong local password, `Dev` | Optional credentials for `ENABLE_DEV_LOGIN=1`. |
| `RESEND_API_KEY` | — | Resend mail transport |
Expand Down
2 changes: 1 addition & 1 deletion apps/full-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"build:deps": "pnpm --filter 'full-app^...' --workspace-concurrency=4 run --if-present build",
"dev": "pnpm run build:deps && NODE_ENV=development node --env-file-if-exists=.env --import tsx src/server/dev.ts",
"dev": "pnpm run build:deps && pnpm run migrate && NODE_ENV=development node --env-file-if-exists=.env --import tsx src/server/dev.ts",
"build": "pnpm run build:deps && tsx ../../packages/workspace/scripts/build-app.mts",
"start": "NODE_ENV=production node dist/server/main.js",
"start:worker": "NODE_ENV=production node dist/server/agent-worker.js",
Expand Down
1 change: 1 addition & 0 deletions apps/full-app/src/front/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('full-app production chat composition', () => {
expect(workspaceFrontPropsSource).toMatch(/\baddressedAgentSelection\b/)
expect(workspaceFrontPropsSource).toMatch(/\buseAddressedAgentSelection=\{useAddressedAgentSelection\}/)
expect(workspaceFrontPropsSource).toMatch(/\bworkspaceLayout="plugin-tabs"/)
expect(workspaceFrontPropsSource).not.toMatch(/\bagentTypeId\s*=/)
expect(chatParamsSource).not.toMatch(/\bagentTypeId\s*:/)
expect(source).toMatch(/\bchatParams=\{chatParams\}/)
expect(source).toMatch(/\bchatFirstPublicWorkspaceProps=\{\{[\s\S]*\baddressedAgentSelection:\s*false\b/)
Expand Down
3 changes: 3 additions & 0 deletions apps/full-app/src/front/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
import '@hachej/boring-core/app/front/styles.css'
import { GovernanceUsagePanel, createGovernanceCompanyAdmin } from '@hachej/boring-governance/front'
import { BoringMcpSourcesOverlay } from '@hachej/boring-mcp/front'
import boringAutomationPlugin from '@hachej/boring-automation/front'
import { PublicHeroDescription } from './PublicHeroDescription'
import { fullAppBoringMcpOptions } from './boringMcp'

const PRODUCT_NAME = 'Seneca AI'
const fullAppFrontPlugins = [boringAutomationPlugin]

// Show the Buy-credits button when the server has Lemon Squeezy checkout wired
// (set this alongside the server-side LS env). The checkout itself is created
Expand Down Expand Up @@ -122,6 +124,7 @@ const chatParams = {
createRoot(document.getElementById('root')!).render(
<>
<CoreWorkspaceAgentFront
plugins={fullAppFrontPlugins}
apiBaseUrl=""
apiTimeout={10_000}
addressedAgentSelection
Expand Down
13 changes: 7 additions & 6 deletions docs/issues/590/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,14 @@ Execution slice:
- creator authorization re-check and creator-scoped execution;
- operational proof across multiple invocations.

**Status:** implemented. The platform invokes `POST /api/v1/boring-automation/due/hosted` with `Authorization: Bearer $BORING_AUTOMATION_TRIGGER_TOKEN`. There is no plugin-owned timer.
**Status:** implemented, then superseded by #896 for single-node hosted operation. The plugin now owns a lifecycle-bound internal Croner wake-up by default. The authenticated `POST /api/v1/boring-automation/due/hosted` endpoint remains available for operational fallback or multi-replica deployments that explicitly disable the internal scheduler.

**Review budget:** high.

## Schedule Policy To Preserve During Replan

- no hidden in-process timer without lifecycle/dispose support;
- the default hosted wake-up is explicit, process-local, non-overlapping, unreferenced, and drained before agent runtime shutdown;
- multi-replica deployments may explicitly disable it and use the authenticated endpoint externally;
- no unbounded backfill after downtime;
- skip/conflict while the same automation is already running;
- timezone-aware due calculation with explicit DST behavior;
Expand Down Expand Up @@ -377,7 +378,7 @@ Not a wide refactor. Any missing generic workspace/agent capability must be plan
- Hosted Markdown as a predetermined canonical format.
- Prompt version-history UI beyond run snapshots.
- New transcript viewer.
- Hidden plugin timers.
- Timers without explicit lifecycle, overlap, logging, and opt-out contracts.
- Automation-specific core logic.
- Runtime/untrusted plugin support.
- Boring Claw/GitHub auto-picker controls from #197.
Expand All @@ -387,7 +388,7 @@ Not a wide refactor. Any missing generic workspace/agent capability must be plan
Confirmed by Slice 0:

- headless execution uses the host's existing `Agent.send()` through a minimal trusted dispatcher seam;
- due evaluation is externally invoked, never a hidden plugin timer;
- due evaluation uses the same service for the #896 internal hosted wake-up and authenticated external fallback;
- hosted orchestration stays on the public host while sandbox/worker executes workspace operations;
- first-pass token totals come from live usage events, not direct billing-ledger queries.

Expand All @@ -401,5 +402,5 @@ Owner decisions recorded before Slice 5:

- Slice 0 state: complete; see `docs/issues/590/seam-spike.md`.
- `ready-for-agent`: Slice 2 UI, Slice 3A generic dispatcher, then Slice 3B local manual executor.
- `ready-for-human`: final end-to-end hosted smoke and production scheduler wiring.
- The implementation is complete; deployment must configure `BORING_AUTOMATION_TRIGGER_TOKEN` and invoke the hosted endpoint from its scheduler.
- `ready-for-human`: final end-to-end hosted smoke.
- #896 removed the default deployment scheduler requirement: single-node hosted apps start the internal wake-up automatically. External scheduler deployments must explicitly opt out and configure `BORING_AUTOMATION_TRIGGER_TOKEN`.
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,20 @@ describe('workspace agent dispatcher', () => {

const dispatched = await dispatcher.dispatch!({
requestId: 'run-1',
title: 'Automation Daily summary: run this',
content: 'run this',
model: { provider: 'test', id: 'gpt-5.5' },
})
expect(dispatched).toMatchObject({
ref: { agentTypeId: 'default', sessionId: 'session-1' },
receipt: { accepted: true, disposition: 'prompt', clientNonce: 'run-1' },
})
expect(gateway.createSession).toHaveBeenCalledWith(expect.objectContaining({ scope, agentTypeId: 'default', requestId: 'run-1' }))
expect(gateway.createSession).toHaveBeenCalledWith(expect.objectContaining({
scope,
agentTypeId: 'default',
requestId: 'run-1',
title: 'Automation Daily summary: run this',
}))
expect(gateway.sends).toEqual([expect.objectContaining({ kind: 'prompt', requestId: 'run-1', clientNonce: 'run-1' })])

const received = []
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/server/workspaceAgentDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async function dispatchGatewayInput(
scope: binding.scope,
agentTypeId: binding.agentTypeId,
requestId,
title: contentToText(input.content ?? input.message).slice(0, 80) || undefined,
title: input.title?.trim() || contentToText(input.content ?? input.message).slice(0, 80) || undefined,
})
const connection = await binding.gateway.connectSession({
scope: binding.scope,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/shared/workspaceAgentDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface WorkspaceAgentDispatcherContext {
export type WorkspaceAgentDispatcherSendInput = Omit<AgentSendInput, 'ctx'>

export interface WorkspaceAgentDispatcherDispatchInput extends WorkspaceAgentDispatcherSendInput {
/** Optional title for a newly created addressed session. */
title?: string
/** Durable caller-owned idempotency key. */
requestId: string
/** Defaults to requestId. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,10 @@ describe("workspaces mode runtime plugin wiring", () => {
const app = await createWorkspacesModeApp({ mode: "direct", registryPath, provisionWorkspace: false })
const address = await app.listen({ port: 0, host: "127.0.0.1" })
const sse = await openSse(`${address}/api/v1/agent-plugins/events?workspaceId=${encodeURIComponent(registeredA.id)}`)
const automationSse = await openSse(`${address}/api/v1/boring-automation/events?workspaceId=${encodeURIComponent(registeredA.id)}`)

try {
await expect(automationSse.nextEvent((message) => message.event === "ready")).resolves.toMatchObject({ event: "ready" })
// The CLI bundles @hachej/boring-ask-user as an internal default plugin
// package. Internal plugins are statically bundled into the app front and
// never appear on the SSE channel — only the external test fixtures do.
Expand Down Expand Up @@ -308,7 +310,7 @@ describe("workspaces mode runtime plugin wiring", () => {
})
expect(automationB.json()).toMatchObject({ ok: true, automations: [] })
} finally {
await sse.close()
await Promise.all([sse.close(), automationSse.close()])
await app.close()
}
}, 60_000)
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/server/modeApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ export async function createWorkspacesModeApp(opts: {
registryPath?: string
provisionWorkspace?: boolean
}): Promise<FastifyInstance> {
const [workspaceAppServer, workspaceServer, agentServer, agentShared, fastifyModule, { createPluginFrontRuntimeHost }, { automationRoutes, createBoringAutomationTool, DueRunService, FileAutomationStore, ManualRunExecutor, resolveAutomationOperationsForActor }, pluginDiscovery] = await Promise.all([
const [workspaceAppServer, workspaceServer, agentServer, agentShared, fastifyModule, { createPluginFrontRuntimeHost }, { automationRoutes, createBoringAutomationTool, DueRunService, FileAutomationStore, InMemoryAutomationRunEventBus, ManualRunExecutor, resolveAutomationOperationsForActor }, pluginDiscovery] = await Promise.all([
import("@hachej/boring-workspace/app/server"),
import("@hachej/boring-workspace/server"),
import("@hachej/boring-agent/server"),
Expand Down Expand Up @@ -584,6 +584,8 @@ export async function createWorkspacesModeApp(opts: {
const pluginPiSnapshots = new Map<string, CliPluginPiSnapshot>()
const runtimeProvisioningByWorkspace = new Map<string, WorkspaceProvisioningResult | undefined>()
const automationStores = new Map<string, InstanceType<typeof FileAutomationStore>>()
const automationEventBus = new InMemoryAutomationRunEventBus()
app.addHook("onClose", async () => await automationEventBus.close())
let workspaceAgentDispatcher: WorkspaceAgentDispatcherResolver | undefined

function getBridge(workspaceId: string) {
Expand Down Expand Up @@ -652,6 +654,7 @@ export async function createWorkspacesModeApp(opts: {
store: automationStore(workspace),
dispatcherResolver: workspaceAgentDispatcher,
actorResolver: () => ({ workspaceId: workspace.id, userId: "local" }),
eventPublisher: automationEventBus,
})
}

Expand All @@ -666,6 +669,7 @@ export async function createWorkspacesModeApp(opts: {
store,
dispatcherResolver: workspaceAgentDispatcher,
actorResolver: () => actor,
eventPublisher: automationEventBus,
})
},
localUserId: "local",
Expand Down Expand Up @@ -933,6 +937,8 @@ export async function createWorkspacesModeApp(opts: {
executor: await automationExecutorForRequest(request),
})
},
actorResolver: async (request) => ({ workspaceId: (await workspaceFromRequest(request)).id, userId: "local" }),
eventBus: automationEventBus,
})

await app.register(workspaceServer.uiRoutes, {
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/server/__tests__/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { runMigrations } = vi.hoisted(() => ({
runMigrations: vi.fn(async () => undefined),
}))

vi.mock('../db/index.js', () => ({ runMigrations }))

import { runCoreMigrationsFromEnv } from '../migrations.js'

describe('runCoreMigrationsFromEnv', () => {
beforeEach(() => vi.clearAllMocks())

it('loads only the database URL required for schema deployment', async () => {
const env = {
DATABASE_URL: 'postgres://test',
NODE_ENV: 'production',
BETTER_AUTH_SECRET_FILE: '/missing/unrelated-secret',
}

await runCoreMigrationsFromEnv({ loadConfigOptions: { env } })

expect(runMigrations).toHaveBeenCalledWith({ databaseUrl: 'postgres://test' }, expect.any(Object))
})

it('rejects conflicting inline and file database secrets', async () => {
await expect(runCoreMigrationsFromEnv({
loadConfigOptions: {
env: {
DATABASE_URL: 'postgres://test',
DATABASE_URL_FILE: '/tmp/database-url',
},
},
})).rejects.toMatchObject({
issues: [expect.objectContaining({ path: ['env', 'DATABASE_URL_FILE'] })],
})
expect(runMigrations).not.toHaveBeenCalled()
})
})
2 changes: 1 addition & 1 deletion packages/core/src/server/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface RunMigrationsOptions {
}

export async function runMigrations(
config: CoreConfig,
config: Pick<CoreConfig, 'databaseUrl'>,
options?: RunMigrationsOptions,
): Promise<void> {
if (!config.databaseUrl) {
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/server/migrations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadConfig, type LoadConfigOptions } from './config/index.js'
import type { LoadConfigOptions } from './config/index.js'
import { resolveConfigFileSecrets } from './config/fileSecrets.js'
import { runMigrations, type RunMigrationsOptions } from './db/index.js'

export interface RunCoreMigrationsFromEnvOptions extends RunMigrationsOptions {
Expand All @@ -9,7 +10,16 @@ export interface RunCoreMigrationsFromEnvOptions extends RunMigrationsOptions {
export async function runCoreMigrationsFromEnv(
options: RunCoreMigrationsFromEnvOptions = {},
): Promise<void> {
const config = await loadConfig(options.loadConfigOptions)
await runMigrations(config, options)
// Schema deployment only needs DATABASE_URL; unrelated runtime secrets must not block migrations.
await runMigrations({ databaseUrl: resolveMigrationDatabaseUrl(options.loadConfigOptions) }, options)
options.log?.log('migrations complete')
}

function resolveMigrationDatabaseUrl(options?: Pick<LoadConfigOptions, 'env'>): string | null {
const env = options?.env ?? (process.env as Record<string, string | undefined>)
const fileSecrets = resolveConfigFileSecrets({
DATABASE_URL: env.DATABASE_URL,
DATABASE_URL_FILE: env.DATABASE_URL_FILE,
})
return fileSecrets.DATABASE_URL ?? env.DATABASE_URL ?? null
}
Loading