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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,30 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify:package

platforms:
# Informational: the console runs locally on macOS and Windows through `npx agent-taskbay`, and CI otherwise only covers Linux.
# `continue-on-error` keeps a platform-specific failure visible without blocking a merge; remove it once this job has
# been green for a while. It runs what needs no PostgreSQL: lint, unit tests, the embedded-database lock and people
# tests, and the production build. The PostgreSQL, HTTP and package checks stay on Linux in `verify` and `package`.
name: platforms (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: true
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22.19.0
cache: npm
- run: npm ci
- run: npm run lint
- run: npx vitest run --exclude "src/server/adapters/db/**/*.db.test.ts"
- run: npx vitest run src/server/adapters/db/pglite-guard.db.test.ts src/server/adapters/db/people.db.test.ts --maxWorkers=1 --testTimeout=60000
- run: npm run build
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ the project uses [semantic versioning](https://semver.org/) once it reaches 1.0;
- **Dev server serves `127.0.0.1`.** `next dev` now allows the `127.0.0.1` origin, which the launcher and docs print; before, the page loaded there but never became interactive.
- **Browsing an agent's tasks is a page, not a dialog.** **Find tasks** (`/discover`) works across agents and is linked from the sidebar, the **Tasks** list and each agent's **Task discovery** card.

- **Clear errors when the embedded database cannot be opened.** PGlite has no lock of its own, so a second process (for example `npm run db:migrate` while `npm run dev` runs) could open the same folder silently and damage it. Taskbay now takes a `<folder>.lock` file and refuses with "already open in another process"; a stale lock from a dead process is taken over. A damaged folder, which made PGlite end the whole process with `RuntimeError: Aborted()`, now fails the open with an error that says what to do. Background workers print the reason once instead of a bare "will retry" in a loop. Nothing is deleted or moved automatically.

### Added

- **Browse and track tasks started elsewhere.** **Browse tasks** on an agent page reads live `ListTasks` pages without storing them; **Track** and **Track by task ID** re-read and store one task to answer, steer, cancel or claim. Requires an administrator or whole-agent `operate` grant. Stored tasks carry **Discovered** labels, an origin filter and tracking details; tracking never claims a task or notifies people about its first stored state. The **Task discovery** card defaults to **Browse only**, with **Off** and a deployment-wide `A2A_TASK_DISCOVERY` cap. Schema version 2.
Expand Down
15 changes: 15 additions & 0 deletions docs/contributing/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ npm run dev

The console is at `http://localhost:3002`. `npm run dev` uses embedded PGlite under `.data/`, applies migrations on start (`A2A_AUTO_MIGRATE`), and signs you in as a labelled development administrator. Copy `.env.example` to `.env.local` to change settings; see the [configuration reference](../reference/configuration.md).

### Where the database is

| How you start it | Database | Migrations | Sign-in |
| --- | --- | --- | --- |
| `npx agent-taskbay` (or `node bin/agent-taskbay.mjs` after `npm run build`) | PGlite in `~/.agent-taskbay/pglite` (`--data-dir` or `A2A_DATA_DIR` to change), with generated secrets and artifacts beside it | Applied on start, after a backup of an existing database | Local development administrator, loopback only |
| `npm run dev` | PGlite in `.data/pglite` under the repository (`A2A_PGLITE_DATA_DIR` to change) | Applied on start | Local development administrator |
| `npm run db:migrate`, `npm run auth:provision` and the other scripts | The same folder `npm run dev` uses, unless you set `A2A_*` variables | `db:migrate` applies them | Not applicable |
| PostgreSQL (`A2A_DATABASE_PROFILE=postgresql`, `A2A_DATABASE_URL`) | Your server | Explicit `npm run db:migrate`, unless `A2A_AUTO_MIGRATE=true` | OIDC |

PGlite allows one owner. Taskbay now takes a lock file beside the folder (`<folder>.lock`), so a second process, such as `npm run db:migrate` while `npm run dev` is running, stops with "already open in another process" instead of sharing the folder. Stop the dev server before running a script against its database, or point the script at a different folder.

If the folder is damaged (typically by a process killed while writing, or by two processes opening it before the lock existed), opening it fails with "could not be opened (PGlite aborted)". Taskbay does not delete or move it for you, because it holds your data. Move the folder aside (`mv .data/pglite .data/pglite.damaged`), start again to get a fresh one, and keep the old one if you need to recover anything. Stop the dev server with Ctrl+C rather than killing it.

To give it agents, start the sample ones in a second terminal and register a printed Agent Card URL in the console:

```bash
Expand Down Expand Up @@ -66,6 +79,8 @@ export A2A_TEST_POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:55432/post

The test account must be able to create and drop temporary databases. Use a disposable server, never a real one.

The `verify` and `package` jobs run on Linux. An informational `platforms` job also runs lint, the unit tests, the embedded-database lock and people tests, and the production build on macOS and Windows; it is allowed to fail without blocking a merge until it has proved stable.

### Test and verification variables

These are read by the repository's tests and verification scripts, not by the application, so they are not in the [configuration reference](../reference/configuration.md).
Expand Down
3 changes: 3 additions & 0 deletions docs/operations/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ API errors have the shape `{"error":{"message":"..."}}`, sometimes with a `name`
| `agent-taskbay: Local mode signs everyone in as an administrator, so it cannot listen on <host>. Use 127.0.0.1, or configure OIDC (A2A_AUTH_MODE=oidc and the A2A_OIDC_* settings).` | Launcher | `--host` is not loopback and OIDC is not configured. | Bind loopback or configure [OIDC](../guides/sign-in-and-roles.md). |
| `agent-taskbay: <path>/secrets.json is not valid. Fix or remove it. Removing it makes stored agent credentials unreadable.` | Launcher | Corrupt generated key file. The launcher never overwrites it. | Restore it from backup. Remove it only if you accept re-entering every credential. |
| `agent-taskbay: --port needs a value.`, `"x" is not a valid port.`, `Unknown option --x.`, `Unexpected argument x.` | Launcher | Bad flags. | See `agent-taskbay --help`. |
| `The embedded database in <dir> is already open in another process (pid N).` | Web, launcher, scripts | PGlite allows one owner and a live process holds the lock file `<dir>.lock`. | Stop that process (the dev server, the launcher, a migration), then retry. A lock left by a process that died is taken over automatically. If nothing of yours is running, the number was reused by another process (more likely on Windows and in containers): delete the `<dir>.lock` file the message names. |
| `The embedded database in <dir> could not be opened (PGlite aborted).` | Web, launcher, scripts | The PGlite folder is damaged, usually by a process killed mid-write or two processes opening it. | Stop everything that uses it, move the folder aside, start again for a fresh one, and keep the old folder if you need to recover data. See [Development setup](../contributing/development.md#where-the-database-is). |
| `… could not access durable state; will retry. <reason>` | Web, workers | A background worker cannot reach the database. The reason is printed once, and again only if it changes. | Fix the reason shown (for example the two rows above). |
| `External command workers require PostgreSQL.` | Web, at start | `A2A_COMMAND_WORKER_MODE=external` with PGlite. | Use PostgreSQL, or leave the mode `embedded`. |
| `Invalid A2A_COMMAND_WORKER_MODE.` | Web, at start | Value is neither `embedded` nor `external`. | Fix it. |
| `The separate worker requires PostgreSQL; PGlite uses embedded dispatch.` | Worker | Worker started with `A2A_DATABASE_PROFILE` not `postgresql`. | Set the profile and URL for the worker's environment. |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"start": "next start -p 3002",
"lint": "eslint",
"test": "vitest run --exclude 'src/server/adapters/db/**/*.db.test.ts'",
"test:db": "vitest run src/server/adapters/db/database.db.test.ts src/server/adapters/db/migration-startup.db.test.ts src/server/adapters/db/repositories.db.test.ts src/server/adapters/db/agent-registry.db.test.ts src/server/adapters/db/task-observation.db.test.ts src/server/adapters/db/commands.db.test.ts src/server/adapters/db/subscriptions.db.test.ts src/server/adapters/db/push.db.test.ts src/server/adapters/db/push-cleanup.db.test.ts src/server/adapters/db/reconciliation.db.test.ts src/server/adapters/db/projection-rebuild.db.test.ts src/server/adapters/db/freshness.db.test.ts src/server/adapters/db/identity.db.test.ts src/server/adapters/db/scoped-security.db.test.ts src/server/adapters/db/skill-less-tasks.db.test.ts src/server/adapters/db/auto-discovery.db.test.ts src/server/adapters/db/agent-settings.db.test.ts src/server/adapters/db/discovered-notifications.db.test.ts src/server/adapters/db/discovery.db.test.ts src/server/adapters/db/required-extensions.db.test.ts src/server/adapters/db/terminal-guard.db.test.ts src/server/adapters/db/discovery-model.db.test.ts src/server/adapters/db/decisions.db.test.ts src/server/adapters/db/workflow.db.test.ts src/server/adapters/db/audit.db.test.ts src/server/adapters/db/notifications.db.test.ts src/server/adapters/db/inbox.db.test.ts src/server/adapters/db/phase6-exit.db.test.ts src/server/adapters/db/task-history.db.test.ts src/server/adapters/db/task-list.db.test.ts src/server/adapters/db/steering.db.test.ts src/server/adapters/db/extended-card.db.test.ts src/server/adapters/db/task-links.db.test.ts src/server/adapters/db/people.db.test.ts --maxWorkers=1 --testTimeout=30000",
"test:db": "vitest run src/server/adapters/db/database.db.test.ts src/server/adapters/db/migration-startup.db.test.ts src/server/adapters/db/repositories.db.test.ts src/server/adapters/db/agent-registry.db.test.ts src/server/adapters/db/task-observation.db.test.ts src/server/adapters/db/commands.db.test.ts src/server/adapters/db/subscriptions.db.test.ts src/server/adapters/db/push.db.test.ts src/server/adapters/db/push-cleanup.db.test.ts src/server/adapters/db/reconciliation.db.test.ts src/server/adapters/db/projection-rebuild.db.test.ts src/server/adapters/db/freshness.db.test.ts src/server/adapters/db/identity.db.test.ts src/server/adapters/db/scoped-security.db.test.ts src/server/adapters/db/skill-less-tasks.db.test.ts src/server/adapters/db/auto-discovery.db.test.ts src/server/adapters/db/agent-settings.db.test.ts src/server/adapters/db/discovered-notifications.db.test.ts src/server/adapters/db/discovery.db.test.ts src/server/adapters/db/required-extensions.db.test.ts src/server/adapters/db/terminal-guard.db.test.ts src/server/adapters/db/discovery-model.db.test.ts src/server/adapters/db/decisions.db.test.ts src/server/adapters/db/workflow.db.test.ts src/server/adapters/db/audit.db.test.ts src/server/adapters/db/notifications.db.test.ts src/server/adapters/db/inbox.db.test.ts src/server/adapters/db/phase6-exit.db.test.ts src/server/adapters/db/task-history.db.test.ts src/server/adapters/db/task-list.db.test.ts src/server/adapters/db/steering.db.test.ts src/server/adapters/db/extended-card.db.test.ts src/server/adapters/db/task-links.db.test.ts src/server/adapters/db/people.db.test.ts src/server/adapters/db/pglite-guard.db.test.ts --maxWorkers=1 --testTimeout=30000",
"test:http": "node scripts/verify-service-security.mjs && node scripts/verify-task-http.mjs && node --import tsx scripts/verify-identity-http.mjs && node scripts/verify-decisions-http.mjs && node --import tsx scripts/verify-workflow-http.mjs && node --import tsx scripts/verify-notifications-http.mjs && node scripts/verify-forms-http.mjs && node scripts/verify-agui-http.mjs && node scripts/verify-a2ui-http.mjs && node scripts/verify-agent-approvals-http.mjs && node scripts/verify-phase6-exit-http.mjs && node scripts/verify-discovery-http.mjs && node scripts/verify-history-http.mjs && node --import tsx scripts/verify-steering-http.mjs && node scripts/verify-extended-card-http.mjs",
"test:coverage": "vitest run --coverage --exclude 'src/server/adapters/db/**/*.db.test.ts'",
"db:migrate": "mikro-orm migration:up",
Expand Down
3 changes: 3 additions & 0 deletions src/server/adapters/db/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PostgreSqlDriver } from "@mikro-orm/postgresql";
import { z } from "zod";

import { persistenceEntities } from "./entities";
import { claimPgliteDirectory } from "./pglite-guard";
import { nextMigrationName } from "./schema-version";

const databaseEnvironmentSchema = z
Expand Down Expand Up @@ -122,6 +123,8 @@ export function createMikroOrmOptions(

// PGlite creates its directory without `recursive`, so a clean checkout (no `.data`) would fail with ENOENT.
mkdirSync(path.dirname(config.dataDir), { recursive: true });
// PGlite has no lock of its own; this also covers the MikroORM CLI (`npm run db:migrate`), which builds its options here.
claimPgliteDirectory(config.dataDir);

return {
...commonOptions,
Expand Down
20 changes: 12 additions & 8 deletions src/server/adapters/db/orm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type DatabaseConfig,
} from "./config";
import { migrateOnStartup } from "./migration-startup";
import { openGuarded } from "./pglite-guard";
import { createPersistenceRepositories } from "./repositories";

type DatabaseGlobal = typeof globalThis & {
Expand All @@ -22,14 +23,17 @@ const databaseGlobal = globalThis as DatabaseGlobal;
export async function createDatabaseOrm(
config: DatabaseConfig = loadDatabaseConfig(),
): Promise<MikroORM> {
const orm = await MikroORM.init(createMikroOrmOptions(config));
try {
await orm.connect();
return orm;
} catch (error) {
await orm.close(true).catch(() => undefined);
throw error;
}
const open = async () => {
const orm = await MikroORM.init(createMikroOrmOptions(config));
try {
await orm.connect();
return orm;
} catch (error) {
await orm.close(true).catch(() => undefined);
throw error;
}
};
return config.profile === "pglite" ? openGuarded(config.dataDir, open) : open();
}

export async function bootstrapRuntimeDatabase(orm: MikroORM) {
Expand Down
78 changes: 78 additions & 0 deletions src/server/adapters/db/pglite-guard.db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { claimPgliteDirectory, DatabaseOpenError, lockPathFor } from "./pglite-guard";

function scratch() {
const dir = mkdtempSync(join(tmpdir(), "a2a-guard-"));
return { dir, dataDir: join(dir, "db"), done: () => rmSync(dir, { recursive: true, force: true }) };
}

/** Runs one script in a separate process; the open that aborts would end this test process. */
function run(code: string, dataDir: string) {
return spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", code], {
cwd: process.cwd(), env: { ...process.env, A2A_PGLITE_DATA_DIR: dataDir, A2A_DATABASE_PROFILE: "pglite" }, encoding: "utf8", timeout: 90_000,
});
}

describe("PGlite directory lock", () => {
it("refuses a live owner, takes over a dead one's lock and ignores an unreadable one", () => {
const { dataDir, dir, done } = scratch();
try {
mkdirSync(dir, { recursive: true });
writeFileSync(lockPathFor(dataDir), JSON.stringify({ pid: process.ppid }));
expect(() => claimPgliteDirectory(dataDir)).toThrow(DatabaseOpenError);
expect(() => claimPgliteDirectory(dataDir)).toThrow(/already open in another process/);
writeFileSync(lockPathFor(dataDir), JSON.stringify({ pid: 2 ** 22 + 7 }));
claimPgliteDirectory(dataDir);
claimPgliteDirectory(dataDir); // the same process may claim again
const other = scratch();
writeFileSync(lockPathFor(other.dataDir), "not json");
claimPgliteDirectory(other.dataDir);
other.done();
} finally { done(); }
});
});

describe("PGlite open failures", () => {
it("a second process is refused with a clear message instead of sharing the folder", () => {
const { dataDir, done } = scratch();
try {
const script = `
import { createDatabaseOrm } from "./src/server/adapters/db/orm";
const a = await createDatabaseOrm();
const { spawnSync } = await import("node:child_process");
const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e",
'import { createDatabaseOrm } from "./src/server/adapters/db/orm"; try { await createDatabaseOrm(); console.log("OPENED"); } catch (e) { console.log(e.name + ": " + e.message); } process.exit(0);'],
{ encoding: "utf8", env: process.env });
console.log(child.stdout);
await a.close(true); process.exit(0);`;
const result = run(script, dataDir);
expect(result.stdout).toContain("DatabaseOpenError: The embedded database");
expect(result.stdout).toContain("already open in another process");
expect(result.stdout).not.toContain("OPENED");
} finally { done(); }
}, 120_000);

it("an Aborted() thrown from inside PGlite becomes a clear error, not a crash of the whole process", () => {
const { dataDir, done } = scratch();
try {
// PGlite's WebAssembly throws this from a callback, so it is uncaught rather than a rejection of the open.
const result = run(`
import { openGuarded } from "./src/server/adapters/db/pglite-guard";
const dir = process.env.A2A_PGLITE_DATA_DIR;
try { await openGuarded(dir, () => new Promise(() => setTimeout(() => { throw new Error("Aborted(). Build with -sASSERTIONS for more info."); }, 20))); console.log("OPENED"); }
catch (e) { console.log(e.name + ": " + e.message); }
try { await openGuarded(dir, async () => { setTimeout(() => { throw new Error("something else"); }, 20); await new Promise((r) => setTimeout(r, 200)); }); console.log("SWALLOWED"); }
catch (e) { console.log("unexpected " + e.message); }`, dataDir);
expect(result.stdout).toContain("DatabaseOpenError: The embedded database");
expect(result.stdout).toContain("could not be opened");
expect(result.stdout).toContain("move the folder aside");
// An unrelated uncaught error must still end the process; the guard only claims the abort.
expect(result.stdout).not.toContain("SWALLOWED");
expect(result.status).not.toBe(0);
} finally { done(); }
}, 120_000);
});
Loading
Loading