Integrate current master and restore provider pack shim imports

This commit is contained in:
Dotta committed 2026-10-08 16:08:14 -05:00
commit 97a6f05863
69 files changed
+6583 -3665

No files matched your search

+7 -7
View File
@@ -384,7 +384,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -609,7 +609,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -767,7 +767,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -905,7 +905,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -1078,7 +1078,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -1137,7 +1137,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -1327,7 +1327,7 @@ jobs:
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store (read only)
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+19 -1
View File
@@ -1241,10 +1241,28 @@ issue and wake context, responsible-user resolution, routine environment snapsho
skill mentions, adapter environment configuration, and MCP/tool access setup.
`createHeartbeatRunPreparation(db)` binds the context loaders to a service's database
without doing database work during construction. `heartbeat.ts` keeps queueing,
dispatch, retries, cancellation, and execution order, and re-exports the existing
dispatch, cancellation, and execution order, and re-exports the existing
public helpers and configuration-incomplete error class. Keep preparation policy
changes in this module and its tests.
Run retrieval and session state are in `server/src/services/heartbeat/run-state.ts`.
It owns bounded run projections, database encoding checks, task session reads and
writes, explicit resumes, session compaction, and usage/billing helpers.
`createHeartbeatRunState(db)` binds these operations without doing database work
during construction. The encoding-check cache belongs to each factory instance.
`heartbeat.ts` keeps run execution, session-goal recovery, cost accounting writes,
and status transitions, and re-exports the existing public helpers. Keep session
policy changes separate from run orchestration changes.
Retry scheduling is in `server/src/services/heartbeat/retries.ts`. It owns bounded
retry schedules, connection and workspace contention deferrals, shared-workspace
holder checks, due retry promotion, and retry-now requests. `createHeartbeatRetries`
binds these operations to the service database and explicit lifecycle callbacks
without doing work during construction. `heartbeat.ts` supplies status writes,
run events, issue-lock release, plan-resume reporting, and worktree cutoffs. It
re-exports the existing retry helpers and workspace-busy error class. Keep retry
policy changes separate from this extraction.
## Wake Context Delivery
Built-in adapters deliver wake context through the run prompt, including structured
+1
View File
@@ -1115,6 +1115,7 @@ Core authorization follows these rules:
- A user may set inbox-agent policy to `disabled` or `allowlist`. Policy restrictions override the default-open path, and low-trust agents are denied.
- An agent targeting any user other than its resolved responsible user requires either a materialized target-user policy that permits that agent (`open` or matching `allowlist`) or an explicit `inbox:manage` grant. The implicit default-open policy for a missing row remains responsible-user-only, so it never becomes a blanket cross-user grant. Grants may be unscoped or constrained by `scope.userIds` and act as administrative overrides, including over a disabled target-user policy.
- Archive and unarchive operations are company-scoped, reversible, and activity logged with actor, agent, run, target user, target-resolution source, and policy mode.
- Concurrent archives keep the newest archive time and its actor attribution. An earlier request must not undo an archive written by human completion while it waited. Unarchive removes that state so a later archive starts fresh.
- New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work.
- Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with a user-authored comment, issue creation/assignment, or another audited user mutation; explicit product actions such as manually running a routine may record an audited inbox touch.
@@ -9,7 +9,8 @@ separate contracts and were not changed.
Methods reviewed after this audit carry their own `reviewedAt` date in the same
ledger; the counts above are not restated. Later additions: [Neon](./NEON.md)
(`mcp-oauth`, `mcp-api-key`; 2026-10-02), [Superagent](./SUPERAGENT.md)
(`mcp-api-key`; 2026-10-06).
(`mcp-api-key`; 2026-10-06), [Gauge](./GAUGE.md)
(`mcp-oauth`, `mcp-api-key`; 2026-10-08).
## Shared credential failure
+189
View File
@@ -0,0 +1,189 @@
# Gauge
Updated: 2026-10-08. Status: catalog definition reviewed against official
documentation and live provider metadata; live account qualification
outstanding.
Gauge (`withgauge.com`) measures how AI answer engines mention and cite a
brand, combines that with SEO, analytics and ads data, and runs a content
pipeline that can publish to a connected CMS. It appears in Apps and uses
Paperclip's shared remote-MCP connection, vault, catalog, grants, policies,
gateway, and audit trail. It is a resource connection, not Paperclip sign-in.
No plugin, database migration, or provider-specific runtime code is required.
Paperclip connects to Gauge's hosted MCP server at
`https://app.withgauge.com/mcp` (Streamable HTTP) with two methods:
- `mcp-oauth`: browser sign-in through Gauge's dynamic client registration.
The user chooses one Gauge organization on the consent screen.
- `mcp-api-key`: an organization API key stored as a Paperclip secret and
sent as an `Authorization: Bearer <key>` header.
This curated connection is the polished route: it provides branding, method
guidance, and a publish warning. The same URL can also be connected from
**Connect your own MCP server**. See
[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md).
## Service involvement
Gauge hosts the MCP resource and the authorization server. Paperclip stores
the OAuth tokens or the key as secret references, discovers tools with
`tools/list`, and sends the credential on every governed call. No
Paperclip-operated vendor relay is involved; cloud and self-hosted instances
use the same path.
## Endpoints
| Purpose | URL |
| --- | --- |
| MCP resource | `https://app.withgauge.com/mcp` |
| Protected-resource metadata | `https://app.withgauge.com/.well-known/oauth-protected-resource` |
| Authorization-server metadata | `https://app.withgauge.com/.well-known/oauth-authorization-server` |
| Authorize | `https://app.withgauge.com/mcp/oauth/authorize` |
| Token | `https://app.withgauge.com/mcp/oauth/token` |
| Dynamic client registration | `https://app.withgauge.com/mcp/oauth/register` |
The authorization server supports `authorization_code` and `refresh_token`,
PKCE `S256`, and public clients (`token_endpoint_auth_method: none`). It
advertises the scopes `mcp`, `openid`, `profile`, `email` and
`organizations`, and rejects unknown scopes with HTTP 400.
## Scopes
Paperclip requests only `mcp`, the scope that grants MCP access. The identity
scopes are not needed: Gauge selects the organization on its consent screen,
not through a scope. This is recorded as an explicit review in
[`tool-method-permission-reviews.json`](./tool-method-permission-reviews.json).
## Administrator setup
Browser sign-in:
1. In Paperclip, open **Apps → Browse → Gauge** and click
**Continue to sign in**.
2. Sign in to Gauge, choose the organization, and approve access.
API key:
1. In Gauge, open **Settings → Integrations → API Keys** and click
**Generate API Key**. Use a separate key for Paperclip so it can be deleted
independently.
2. In Paperclip, open **Apps → Browse → Gauge**, open **Advanced**, choose
**Use an API key**, paste the key, and click **Connect**.
After either method, set publish actions to **Ask first** or **Off** on the
connection's **Permissions** screen if agents run unattended.
To disconnect, remove the connection in Paperclip. For an API key, also
delete the key in Gauge under **Settings → Integrations → API Keys**, which
revokes every connection that uses it.
## Capabilities and policy
Gauge documents about 25 tools in these groups:
- AI visibility and citations: mentions, citations, answers, visibility and
performance trends, and the sources and pages behind them.
- Topics and prompts: browse tracked prompts and answers; create, generate,
move and tag prompts; bulk keyword updates, which replace each prompt's
full keyword list.
- SEO and keyword reporting: organic rankings, volume, difficulty, and the
keywords a domain or page ranks for.
- GA4, Search Console and PostHog reports, where those integrations are
connected in Gauge.
- Google Ads and OpenAI Ads reporting.
- Content pipeline: brief, research, outline, write, review, images, and
publish to a connected CMS as a draft, to staging, or live.
- Memory files and saved skills: list, read, create and edit.
- Action Center: list, create, assign, set due dates and update status.
- Ask Gauge: natural-language questions answered from the organization's
data.
Tool names were not captured during this review because it needed a signed-in
account. The generic `classifyRisk` rules apply: `publish`, `create`,
`update`, `set` and similar verbs are writes, and `delete` or `remove` is
destructive. Bulk keyword replacement and live CMS publishing are the
highest-impact actions. The method warnings and the API-key helper tell
operators to put publish actions behind Ask first. If live discovery shows
mutations with read-like names, add a reviewed `gauge` rule to
`classifyRisk`, as Superagent has.
## Transport and auth
- Transport: `mcp_remote`, Streamable HTTP (SSE is also documented).
- OAuth: DCR, public client, PKCE, scope `mcp`. Connects as the approving
user, limited to the chosen organization.
- API key: `Authorization: Bearer <key>`. Keys are bound to one
organization, are not scoped, and carry no user identity. Requests are
attributed to the organization. Paperclip cannot narrow an issued key.
## Resource filters
None. Gauge documents no query or header options that narrow the hosted
server. Each connection is already limited to one organization; narrow further
with per-action policies.
## Manifest
| Field | Value |
| --- | --- |
| Slug | `gauge` |
| Category | `analytics` |
| Methods | `mcp-oauth` (`dcr`, S3, scope `mcp`); `mcp-api-key` (`customer`, S3) |
| Server URL | `https://app.withgauge.com/mcp` |
| Credential | `authorization`, password, required |
| Key placement | header `Authorization`, prefix `Bearer ` |
| Console links | docs `https://docs.withgauge.com/help/mcp` |
The definition is generated from the `gauge` row in
`packages/shared/src/self-serve-mcp-research.json` and the `gauge` branch of
`specialMethodsFor` in `scripts/ingest-app-definitions.mjs`.
## Wizard path
`/apps/connect?source=gauge` defaults to **Sign in with Gauge**. Both methods
share one `write` capability profile, so there is no capability picker, but
the access step shows its description, including the publish warning, before
sign-in. Under
**Advanced**, **Use an API key** shows the key field; **Connect** stays
disabled until a key is entered. A successful sign-in or key check and tool
discovery lead to the connection's Permissions screen.
## Governance defaults
- Default profile and bindings: the standard connection profile; every
discovered action starts Allowed under the current product default.
- Policies: operators narrow publish and bulk-edit tools to Ask first or Off
on the Permissions screen.
- Risk: the generic `classifyRisk` rules.
- Quarantine rules: the shared defaults; no Gauge-specific exceptions.
## Brand provenance
Gauge's site ships only a 32×32 favicon, below the catalog's 128px minimum.
The same orange "G" gauge mark is the avatar of Gauge's official GitHub
organization (`withgauge`, whose profile links `https://withgauge.com/`),
published as a 460×460 transparent PNG. That file is used unchanged in both
themes.
| File | Source | SHA-256 |
| --- | --- | --- |
| `ui/public/brands/apps/gauge.png` (460×460) | `https://avatars.githubusercontent.com/u/213101673?v=4&s=512` | `bf1f63a5d30b7f6af1064b74f5f66bf5ea6454d6036a068bf8b0c926fa3f37a6` |
## Validation hook
- Environment: definition review on 2026-10-08 against `origin/master`; no
Gauge account was used.
- Metadata probe: an unauthenticated `initialize` on `/mcp` returned HTTP 401
with `resource_metadata`; both `.well-known` documents returned the
endpoints and scopes above; dynamic client registration succeeded for a
loopback redirect; the authorize endpoint accepted `scope=mcp` (redirect to
Gauge sign-in) and rejected an unknown scope with HTTP 400.
- Deterministic tests: manifest shape, store visibility, artwork, URL
recognition, reviewed scope, bearer-header placement, and the connect
form's sign-in default, the publish warning before sign-in, and API-key
gating.
- Connect evidence, catalog evidence, allowed read, governed write, denied
case, revoke, audit: not run. Both methods still need the full live
lifecycle with a Gauge organization before this connection is considered
qualified.
+1 -1
View File
@@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
Long-term memory: [Experimental memory connectors](./MEMORY.md).
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md),
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md),
[AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
[Enterpret](./ENTERPRET.md). Optional credential custody:
[Vercel Connect](./VERCEL-CONNECT.md).
@@ -507,6 +507,40 @@
"liveProof": "not-run",
"keyPermissions": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key\u2019s permissions."
},
{
"app": "gauge",
"method": "mcp-oauth",
"auth": "oauth",
"policy": "explicit",
"requestedScopes": [
"mcp"
],
"capability": "write",
"supportedActions": "Read AI visibility, mentions, citations, answers and competitor data; browse and curate tracked topics and prompts (create, generate, move, tag, and replace keyword lists); pull SEO keyword, GA4, Search Console, PostHog and Google Ads/OpenAI Ads reports; run the content pipeline from brief to article and publish to a connected CMS as a draft, to staging or live; read and edit organization memory files and skills; create, assign and update Action Center items; ask Ask Gauge questions. Access is limited to the organization chosen during consent.",
"evidence": [
"https://docs.withgauge.com/help/mcp",
"https://app.withgauge.com/.well-known/oauth-protected-resource",
"https://app.withgauge.com/.well-known/oauth-authorization-server"
],
"reviewedAt": "2026-10-08",
"liveProof": "not-run"
},
{
"app": "gauge",
"method": "mcp-api-key",
"auth": "api_key",
"policy": "provider-key",
"requestedScopes": [],
"capability": "provider-controlled",
"supportedActions": "Read AI visibility, mentions, citations, answers and competitor data; browse and curate tracked topics and prompts (create, generate, move, tag, and replace keyword lists); pull SEO keyword, GA4, Search Console, PostHog and Google Ads/OpenAI Ads reports; run the content pipeline from brief to article and publish to a connected CMS as a draft, to staging or live; read and edit organization memory files and skills; create, assign and update Action Center items; ask Ask Gauge questions.",
"evidence": [
"https://docs.withgauge.com/help/mcp",
"https://app.withgauge.com/.well-known/oauth-protected-resource"
],
"reviewedAt": "2026-10-08",
"liveProof": "not-run",
"keyPermissions": "Gauge API keys are not scoped: a key reaches every MCP tool in the organization that created it, including publishing to a connected CMS, and requests are attributed to the organization rather than a user. Generate a separate key for Paperclip so it can be deleted independently, and set publish actions to Ask first before agents run unattended. Paperclip cannot narrow an existing key."
},
{
"app": "github",
"method": "managed",
+5
View File
@@ -403,6 +403,11 @@ The 26 native `first-task` cells exercise onboarding before native selection
becomes the UI default. Live results and semantic answer reviews must accompany
any qualification claim; catalog presence alone is not a pass.
The explicit-only [native question/resume qualification](../tests/runner-e2e/README.md#task-continuation)
separates a completed two-answer user journey from semantic-tool documentation
qualification. It verifies the exact provider-pause or semantic-response-wake
binding for each answer and preserves prior grades when the definition changes.
## Lifecycle behavior baseline
The credential-free [lifecycle baseline](../tests/lifecycle-baseline/README.md)
+10
View File
@@ -443,6 +443,16 @@ evidence, unknown binding reasons, secret-provider failures, ambiguous missing
secret-definition lookups remain reportable. This filter does not change task
recovery, credentials, or execution policy.
AI account selection also stays local when the selection service proves a
missing responsible user, personal default, or selected connection; an
incompatible selection; or an account that needs reconnection. The service marks
the original error, and setup preserves its closed `selectionFailure` reason.
The same failed/preparing/setup/bootstrap checks apply, and resumed native runs
with persisted provider input remain reportable. The generic
`ai_connection_unavailable` wrapper alone is insufficient: database, credential
provider, controller, and unclassified errors still report. Failed runs, blocked
tasks, and account-repair actions remain unchanged.
A workspace policy conflict also stays local when the resolver proves that an
explicit `local_path` or `non_git_path` project workspace has no repository URL,
Git confirms the selected directory is not a repository, and the task requests
+32
View File
@@ -25,6 +25,38 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
building custom versions.
- `ctx.assets` is not supported in the current runtime.
## Idle sleep
An enabled worker blocks automatic idle sleep unless it implements
`onIdleDrain(signal): Promise<"none" | "present" | "unknown">`. This is a live
runtime handshake, not a manifest or version exemption. Older SDKs remain
blockers. Manual shutdown keeps its existing behavior.
The host checks the exact owned, expiring task-drain hold. It closes new worker
admission and checks outstanding RPCs in both directions and live terminal
routes. The SDK also counts accepted handlers and notifications until they
actually finish. Caller timeouts do not count as completion. An unconfirmed
write, missing worker, crash, invalid reply, or expired hold prevents sleep.
The hook must return `none` only when plugin-owned timers, sockets, detached
operations, and cleanup are settled and cannot start work until `signal`
aborts. Do not cancel useful work to make a worker idle. Return `present` while
work remains and `unknown` when completion cannot be proved. The SDK closes
ordinary RPC admission while the hook runs and until the matching hold releases
or expires. Release aborts the signal; expiry does so without another request.
Hooks that suspend autonomous work must arrange its safe resumption on abort.
The handshake does not exempt database work. Enabled jobs, deliveries,
schedules, external ingress, and other persisted work still block sleep until
they have their own safe wake contract. An installed plugin without a matching
live worker receipt also blocks sleep.
Daytona currently opts in only before its worker has contacted the provider.
After provider access it remains a blocker for that process lifetime, even if
the visible operation finishes. Its timeout and terminal cleanup paths need
stronger completion receipts before that restriction can be relaxed. Restarting
does not bypass the separate persisted lease and recovery checks.
## Durable resource lifecycle inbox
Plugins with `events.subscribe` can read durable, company-scoped resource hooks
@@ -21,6 +21,7 @@ import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workspaceRoot = resolve(packageRoot, "../..");
@@ -40,6 +40,21 @@ import { environmentCreationCleanupErrorData, readEnvironmentCreationCleanupErro
import manifest from "./manifest.js";
import { parseTarVerboseListingLine, splitLinkEntryOnce } from "./file-sync.js";
describe("Daytona idle eligibility", () => {
it("permits a fresh unused provider but remains conservative after provider access", async () => {
__resetDaytonaSandboxHandleCacheForTest();
const signal = new AbortController().signal;
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
mockGet.mockRejectedValueOnce(new Error("provider unreachable"));
await plugin.definition.onEnvironmentProbe!({
driverKey: "daytona", companyId: "fixture-company", environmentId: "fixture-environment",
config: { apiKey: "fixture-not-a-key" },
}).catch(() => undefined);
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
__resetDaytonaSandboxHandleCacheForTest();
});
});
function createMockSandbox(overrides: {
id?: string;
name?: string;
@@ -107,6 +107,10 @@ let timingNow: () => number = () => Date.now();
// read the tracer through `getPluginTracer()`. Before `setup` runs (or in a
// test) the tracer is a no-op, so a span never throws.
let pluginContext: PluginContext | null = null;
// Provider deadlines and terminal close failures do not prove remote work
// stopped. Until those paths provide complete cleanup receipts, only a worker
// which has never contacted the provider can opt into automatic idle sleep.
let providerUsed = false;
/**
* Return the plugin tracer. It is the injected `ctx.tracer` after `setup`, or a
@@ -313,6 +317,7 @@ function resolveApiKey(config: DaytonaDriverConfig): string {
}
function createDaytonaClient(config: DaytonaDriverConfig): Daytona {
providerUsed = true;
const clientConfig: DaytonaConfig = {
apiKey: resolveApiKey(config),
};
@@ -1570,6 +1575,7 @@ const sandboxHandleSessionStore = (() => {
* Not used in production.
*/
export function __resetDaytonaSandboxHandleCacheForTest(): void {
providerUsed = false;
sandboxHandleCache.reset();
sandboxHandleTeardownGates.reset();
sandboxHandleActivityGates.reset();
@@ -2281,6 +2287,11 @@ const plugin = definePlugin({
return { status: "ok", message: "Daytona sandbox provider plugin healthy" };
},
async onIdleDrain() {
return providerUsed || daytonaLoginPtyByRoute.size > 0 || daytonaDuplexChannelByRoute.size > 0
? "present" : "none";
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
+11
View File
@@ -293,6 +293,17 @@ export interface PluginDefinition {
*/
onShutdown?(): Promise<void>;
/** Prove plugin-owned background work is quiescent for idle sleep.
* The SDK first closes admission and checks accepted RPCs and notifications.
* Return `none` only when timers, sockets, detached operations and cleanup
* are settled and cannot start work until signal aborts. Do not cancel useful
* work to satisfy this check. Missing hooks, failures and uncertainty block
* sleep. The signal aborts on exact-owner release or bounded hold expiry.
* Plugins with autonomous work must keep returning `present` unless they
* can safely suspend and resume that work under this contract.
*/
onIdleDrain?(signal: AbortSignal): Promise<"none" | "present" | "unknown">;
/**
* Called to validate the current plugin configuration.
*
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPluginIdleDrain } from "./idle-drain.js";
describe("plugin idle admission", () => {
afterEach(() => vi.useRealTimers());
const hold = () => ({ ownerId: "idle-owner", expiresAt: Date.now() + 10_000 });
it("keeps accepted work counted until actual settlement", async () => {
const gate = createPluginIdleDrain();
const done = gate.begin();
const inspect = vi.fn(async () => "none" as const);
expect(await gate.prepare(hold(), inspect)).toBe("present");
expect(inspect).not.toHaveBeenCalled();
done(); done();
expect(await gate.prepare(hold(), inspect)).toBe("none");
expect(() => gate.begin()).toThrow("held");
gate.close();
});
it("rejects legacy hooks, invalid deadlines, busy replies and hook errors", async () => {
const gate = createPluginIdleDrain();
expect(await gate.prepare(hold())).toBe("present");
expect(await gate.prepare({ ...hold(), expiresAt: Infinity }, async () => "none")).toBe("unknown");
expect(await gate.prepare(hold(), async () => "present")).toBe("present");
expect(await gate.prepare(hold(), async () => { throw new Error("private detail"); })).toBe("unknown");
gate.begin()();
});
it("releases only the matching owner and resumes on expiry without another request", async () => {
vi.useFakeTimers();
const gate = createPluginIdleDrain();
const lease = hold();
let signal!: AbortSignal;
expect(await gate.prepare(lease, async (value) => { signal = value; return "none"; })).toBe("none");
gate.release("stale-owner");
expect(signal.aborted).toBe(false);
expect(await gate.prepare({ ...lease, ownerId: "other-owner" }, async () => "none")).toBe("unknown");
await vi.advanceTimersByTimeAsync(10_000);
expect(signal.aborted).toBe(true);
gate.begin()();
});
it("holds admission while the hook runs and rejects a late acknowledgement", async () => {
vi.useFakeTimers();
const gate = createPluginIdleDrain();
let resolve!: (state: "none") => void;
const pending = gate.prepare(hold(), () => new Promise((done) => { resolve = done; }));
expect(() => gate.begin()).toThrow("held");
await vi.advanceTimersByTimeAsync(10_000);
expect(await gate.prepare(hold(), async () => "none")).toBe("present");
resolve("none");
expect(await pending).toBe("unknown");
gate.begin()();
});
});
+67
View File
@@ -0,0 +1,67 @@
export type PluginIdleHold = { ownerId: string; expiresAt: number };
export type PluginIdleWork = "none" | "present" | "unknown";
/** Worker admission stays closed for the exact bounded hold. A caller timeout
* never releases a token: only the actual operation's settlement does. */
export function createPluginIdleDrain() {
let active = 0;
let preparing = 0;
let hold: (PluginIdleHold & { controller: AbortController; ready: boolean }) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
function release(ownerId: string): void {
if (hold?.ownerId !== ownerId) return;
const prior = hold;
hold = null;
clearTimeout(timer);
prior.controller.abort();
}
function current() {
if (hold && hold.expiresAt <= Date.now()) release(hold.ownerId);
return hold;
}
function begin(): () => void {
if (current()) throw new Error("Plugin is held for idle sleep");
active++;
let done = false;
return () => { if (!done) { done = true; active--; } };
}
async function prepare(
requested: PluginIdleHold,
inspect?: (signal: AbortSignal) => Promise<PluginIdleWork>,
): Promise<PluginIdleWork> {
if (!requested || typeof requested.ownerId !== "string" || !requested.ownerId ||
!Number.isFinite(requested.expiresAt) || requested.expiresAt <= Date.now() ||
requested.expiresAt > Date.now() + 300_000) return "unknown";
if (!inspect) return "present";
const prior = current();
if (prior) return prior.ownerId === requested.ownerId && prior.expiresAt === requested.expiresAt && prior.ready
? "none" : "unknown";
if (active !== 0 || preparing !== 0) return "present";
const candidate = { ...requested, controller: new AbortController(), ready: false };
hold = candidate;
timer = setTimeout(() => release(candidate.ownerId), requested.expiresAt - Date.now());
timer.unref?.();
preparing++;
try {
const result = await inspect(candidate.controller.signal);
if (current() !== candidate || active !== 0) return "unknown";
if (result !== "none") {
release(candidate.ownerId);
return result === "present" ? "present" : "unknown";
}
candidate.ready = true;
return "none";
} catch {
if (hold === candidate) release(candidate.ownerId);
return "unknown";
} finally {
preparing--;
}
}
return { begin, prepare, release, close: () => { if (hold) release(hold.ownerId); } };
}
+4
View File
@@ -1330,6 +1330,8 @@ export interface HostToWorkerMethods {
health: [params: Record<string, never>, result: PluginHealthDiagnostics];
/** @see PLUGIN_SPEC.md §12.5 */
shutdown: [params: Record<string, never>, result: void];
prepareIdleSleep: [params: { ownerId: string; expiresAt: number }, result: { ownerId: string; expiresAt: number; backgroundWork: "none" | "present" | "unknown" }];
releaseIdleSleep: [params: { ownerId: string }, result: void];
/** @see PLUGIN_SPEC.md §13.3 */
validateConfig: [params: ValidateConfigParams, result: PluginConfigValidationResult];
/** @see PLUGIN_SPEC.md §13.4 */
@@ -1471,6 +1473,8 @@ export const HOST_TO_WORKER_REQUIRED_METHODS: readonly HostToWorkerMethodName[]
/** Optional methods the worker MAY implement. */
export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] = [
"prepareIdleSleep",
"releaseIdleSleep",
"validateConfig",
"configChanged",
"onEvent",
+43 -8
View File
@@ -1,6 +1,7 @@
import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared";
import { environmentCreationCleanupErrorData } from "./environment-creation-cleanup.js";
import { environmentSyncErrorData } from "./environment-sync-error.js";
import { createPluginIdleDrain } from "./idle-drain.js";
/**
* Worker-side RPC host — runs inside the child process spawned by the host.
*
@@ -335,6 +336,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let running = true;
let initialized = false;
const idleDrain = createPluginIdleDrain();
const unsettledHostCalls = new Map<string | number, () => void>();
let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record<string, unknown> = {};
// The company whose config was last applied via configChanged. Used to fail
@@ -397,6 +400,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
nextOutboundId = 1;
}
const id = nextOutboundId++;
// Keep the token after an RPC timeout. Only an actual host response
// proves that a host-side write has finished.
unsettledHostCalls.set(id, idleDrain.begin());
const timeout = timeoutMs ?? rpcTimeoutMs;
let settled = false;
@@ -439,6 +445,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
};
sendMessage(request);
} catch (err) {
unsettledHostCalls.get(id)?.();
unsettledHostCalls.delete(id);
settle(reject, err instanceof Error ? err : new Error(String(err)));
}
});
@@ -1563,8 +1571,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
*/
async function handleHostRequest(request: JsonRpcRequest): Promise<void> {
const { id, method, params } = request;
let done: (() => void) | undefined;
try {
if (method !== "prepareIdleSleep" && method !== "releaseIdleSleep" && method !== "shutdown") {
done = idleDrain.begin();
}
const invoke = () => dispatchMethod(method, params);
const result = request.paperclipInvocation
? await invocationContextStorage.run(request.paperclipInvocation, invoke)
@@ -1584,6 +1595,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
method === "environmentAcquireLease" || method === "environmentDestroyLease"
? environmentCreationCleanupErrorData(err)
: method === "environmentSyncOut" ? environmentSyncErrorData(err) : undefined));
} finally {
done?.();
}
}
@@ -1592,6 +1605,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
*/
async function dispatchMethod(method: string, params: unknown): Promise<unknown> {
switch (method) {
case "prepareIdleSleep": {
const hold = params as { ownerId: string; expiresAt: number };
const backgroundWork = !initialized || sessionEventCallbacks.size > 0
? "present"
: await idleDrain.prepare(hold, plugin.definition.onIdleDrain?.bind(plugin.definition));
return { ...hold, backgroundWork };
}
case "releaseIdleSleep":
idleDrain.release((params as { ownerId: string }).ownerId);
return;
case "initialize":
return handleInitialize(params as InitializeParams);
@@ -1746,6 +1769,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged");
if (plugin.definition.onHealth) supportedMethods.push("health");
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
if (plugin.definition.onIdleDrain) supportedMethods.push("prepareIdleSleep", "releaseIdleSleep");
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects");
if (plugin.definition.onRouteAiConnection) supportedMethods.push("routeAiConnection");
@@ -2228,6 +2252,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const id = response.id;
if (id === null || id === undefined) return;
unsettledHostCalls.get(id)?.();
unsettledHostCalls.delete(id);
const pending = pendingRequests.get(id);
if (!pending) return;
@@ -2284,16 +2311,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
} else if (isJsonRpcNotification(message)) {
// Dispatch host→worker push notifications
const notif = message as JsonRpcNotification & { method: string; params?: unknown };
const runNotification = (fn: () => void | Promise<void>) => {
if (notif.paperclipInvocation) {
return invocationContextStorage.run(notif.paperclipInvocation, fn);
}
return fn();
const runNotification = async (fn: () => void | Promise<void>) => {
const done = idleDrain.begin();
try {
await (notif.paperclipInvocation ? invocationContextStorage.run(notif.paperclipInvocation, fn) : fn());
} finally { done(); }
};
if (notif.method === "agents.sessions.event" && notif.params) {
if (notif.method === "releaseIdleSleep" && notif.params) {
idleDrain.release((notif.params as { ownerId: string }).ownerId);
} else if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
if (cb) void runNotification(() => cb(event)).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle session event notification: ${err instanceof Error ? err.message : String(err)}`,
});
});
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
@@ -2312,6 +2346,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
function cleanup(): void {
running = false;
idleDrain.close();
// Close readline
if (readline) {
@@ -12,6 +12,7 @@ describe("tool app gallery URL matching", () => {
expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets");
expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail");
expect(getAppDefinitionForUrl("https://mcp.neon.tech/mcp")?.slug).toBe("neon");
expect(getAppDefinitionForUrl("https://app.withgauge.com/mcp")?.slug).toBe("gauge");
expect(getAppDefinitionForUrl("https://www.superagent.sh/mcp")?.slug).toBe("superagent");
});
@@ -71,23 +71,24 @@ import a69 from "./app-definitions/honcho.json" with { type: "json" };
import a70 from "./app-definitions/neon.json" with { type: "json" };
import a71 from "./app-definitions/superagent.json" with { type: "json" };
import a72 from "./app-definitions/telem.json" with { type: "json" };
import a73 from "./app-definitions/gmail.json" with { type: "json" };
import a74 from "./app-definitions/google-drive.json" with { type: "json" };
import a75 from "./app-definitions/google-docs.json" with { type: "json" };
import a76 from "./app-definitions/google-sheets.json" with { type: "json" };
import a77 from "./app-definitions/google-slides.json" with { type: "json" };
import a78 from "./app-definitions/google-calendar.json" with { type: "json" };
import a79 from "./app-definitions/google-chat.json" with { type: "json" };
import a80 from "./app-definitions/google-people.json" with { type: "json" };
import a81 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a82 from "./app-definitions/openai.json" with { type: "json" };
import a83 from "./app-definitions/openrouter.json" with { type: "json" };
import a84 from "./app-definitions/xai.json" with { type: "json" };
import a85 from "./app-definitions/google.json" with { type: "json" };
import a86 from "./app-definitions/bedrock.json" with { type: "json" };
import a87 from "./app-definitions/responses-api.json" with { type: "json" };
import a88 from "./app-definitions/messages-api.json" with { type: "json" };
import a89 from "./app-definitions/chat-completions-api.json" with { type: "json" };
import a90 from "./app-definitions/local.json" with { type: "json" };
import a73 from "./app-definitions/gauge.json" with { type: "json" };
import a74 from "./app-definitions/gmail.json" with { type: "json" };
import a75 from "./app-definitions/google-drive.json" with { type: "json" };
import a76 from "./app-definitions/google-docs.json" with { type: "json" };
import a77 from "./app-definitions/google-sheets.json" with { type: "json" };
import a78 from "./app-definitions/google-slides.json" with { type: "json" };
import a79 from "./app-definitions/google-calendar.json" with { type: "json" };
import a80 from "./app-definitions/google-chat.json" with { type: "json" };
import a81 from "./app-definitions/google-people.json" with { type: "json" };
import a82 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a83 from "./app-definitions/openai.json" with { type: "json" };
import a84 from "./app-definitions/openrouter.json" with { type: "json" };
import a85 from "./app-definitions/xai.json" with { type: "json" };
import a86 from "./app-definitions/google.json" with { type: "json" };
import a87 from "./app-definitions/bedrock.json" with { type: "json" };
import a88 from "./app-definitions/responses-api.json" with { type: "json" };
import a89 from "./app-definitions/messages-api.json" with { type: "json" };
import a90 from "./app-definitions/chat-completions-api.json" with { type: "json" };
import a91 from "./app-definitions/local.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91] as AppDefinition[];
+55 -5
View File
@@ -273,7 +273,7 @@ describe("AppDefinition catalog", () => {
"google-workspace-search",
]),
);
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(52);
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(53);
expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([
"g2",
"vercel",
@@ -437,15 +437,15 @@ describe("AppDefinition catalog", () => {
expect(channel("slack")?.guidanceMd).toContain("reactions");
expect(channel("slack")?.guidanceMd).toContain("direct messages");
});
it("keeps a complete, unique, dated evidence ledger for all 55 researched MCP providers", () => {
it("keeps a complete, unique, dated evidence ledger for all 56 researched MCP providers", () => {
// Ledger-wide date reflects the last full re-verification (2026-08-26);
// later provider additions carry their own research evidence, but
// bumping the shared date would overstate freshness for the other providers.
expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26");
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(55);
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(56);
expect(
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
).toHaveProperty("size", 55);
).toHaveProperty("size", 56);
for (const entry of SELF_SERVE_MCP_RESEARCH.entries) {
expect(new URL(entry.docsUrl).protocol).toBe("https:");
expect(new URL(entry.serverUrl).protocol).toBe("https:");
@@ -845,7 +845,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(68);
expect(APP_STORE_DEFINITIONS).toHaveLength(69);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
@@ -1130,6 +1130,56 @@ describe("AppDefinition catalog", () => {
expect(method.warnings?.length).toBe(2);
}
});
it("connects Gauge's hosted server with browser sign-in or an organization API key", () => {
const gauge = APP_DEFINITIONS.find((app) => app.slug === "gauge")!;
expect(gauge).toMatchObject({
name: "Gauge",
categories: ["analytics"],
urlPatterns: ["https://app.withgauge.com/*"],
docsUrl: "https://docs.withgauge.com/help/mcp",
branding: { logoUrl: "/brands/apps/gauge.png" },
redirectConstraints: "https-or-loopback-http",
});
expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "gauge")).toBe(true);
expect(gauge.methods.map((method) => method.key)).toEqual(["mcp-oauth", "mcp-api-key"]);
const [oauth, apiKey] = gauge.methods;
expect(oauth).toMatchObject({
transport: "mcp_remote",
auth: "oauth",
ownershipModes: ["dcr"],
riskTier: "S3",
// Gauge advertises identity scopes too; only the reviewed MCP scope is
// requested, and the organization is chosen on Gauge's consent screen.
defaults: { serverUrl: "https://app.withgauge.com/mcp", scopesHint: ["mcp"] },
});
expect(apiKey).toMatchObject({
transport: "mcp_remote",
auth: "api_key",
ownershipModes: ["customer"],
riskTier: "S3",
defaults: { serverUrl: "https://app.withgauge.com/mcp" },
keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " },
});
expect(apiKey!.credentialFields).toEqual([
expect.objectContaining({ key: "authorization", type: "password", secret: true, required: true }),
]);
// The key form renders credential helper text, not method warnings, so the
// publish advice must live in the helper.
expect(apiKey!.credentialFields?.[0]?.helperMd).toContain("set publish actions to Ask first");
for (const method of gauge.methods) {
// One shared profile: no capability picker, but the access step shows
// its description (with the publish warning) before sign-in.
expect(method.capabilityProfile).toMatchObject({
key: "write",
description: expect.stringContaining("publish to your connected CMS"),
});
expect(method.tenantFields ?? []).toEqual([]);
expect(method.warnings).toEqual([
expect.stringContaining("one Gauge organization"),
expect.stringContaining("publish to your connected CMS"),
]);
}
});
it("connects Superagent's hosted server with an organization API key only", () => {
const superagent = APP_DEFINITIONS.find((app) => app.slug === "superagent")!;
expect(superagent).toMatchObject({
@@ -0,0 +1,94 @@
{
"schemaVersion": 1,
"slug": "gauge",
"name": "Gauge",
"description": "Track how AI answers mention your brand, research keywords and traffic, and run content workflows.",
"categories": [
"analytics"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/gauge.png"
},
"urlPatterns": [
"https://app.withgauge.com/*"
],
"docsUrl": "https://docs.withgauge.com/help/mcp",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://app.withgauge.com/mcp",
"scopesHint": [
"mcp"
]
},
"guidanceMd": "Connect Gauge in the browser and choose the organization this connection may use. Write tools start enabled and remain governed by Paperclip's action policies.",
"riskTier": "S3",
"label": "Sign in with Gauge",
"consoleLinks": {
"docs": "https://docs.withgauge.com/help/mcp"
},
"warnings": [
"A Gauge account. Each connection reaches one Gauge organization: sign-in uses the organization chosen during consent, and an API key uses the organization that created it.",
"Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended."
],
"capabilityProfile": {
"key": "write",
"label": "Read and write",
"description": "Read AI visibility, SEO and traffic data, and run content workflows. Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended."
}
},
{
"key": "mcp-api-key",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use a Gauge organization API key when browser sign-in is not suitable.",
"defaults": {
"serverUrl": "https://app.withgauge.com/mcp"
},
"guidanceMd": "In Gauge, open Settings → Integrations → API Keys, generate a key for Paperclip, and paste it below.",
"riskTier": "S3",
"label": "Use an API key",
"credentialFields": [
{
"key": "authorization",
"label": "Gauge API key",
"type": "password",
"required": true,
"placeholder": "Paste your Gauge API key",
"secret": true,
"helperMd": "Gauge API keys are not scoped: a key reaches every MCP tool in the organization that created it, including publishing to a connected CMS, and requests are attributed to the organization rather than a user. Generate a separate key for Paperclip so it can be deleted independently, and set publish actions to Ask first before agents run unattended. Paperclip cannot narrow an existing key."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://docs.withgauge.com/help/mcp",
"docs": "https://docs.withgauge.com/help/mcp"
},
"warnings": [
"A Gauge account. Each connection reaches one Gauge organization: sign-in uses the organization chosen during consent, and an API key uses the organization that created it.",
"Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended."
],
"capabilityProfile": {
"key": "write",
"label": "Read and write",
"description": "Read AI visibility, SEO and traffic data, and run content workflows. Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended."
}
}
]
}
@@ -60,6 +60,7 @@
{"slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3"},
{ "slug": "neon", "name": "Neon", "wave": 4, "status": "self_serve", "docsUrl": "https://neon.com/docs/ai/neon-mcp-server", "serverUrl": "https://mcp.neon.tech/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Neon account. The hosted server grants broad project and database management, so use a development project and review write actions before connecting production data.", "riskTier": "S4" },
{ "slug": "superagent", "name": "Superagent", "wave": 4, "status": "self_serve", "docsUrl": "https://www.superagent.sh/docs/mcp", "serverUrl": "https://www.superagent.sh/mcp", "authMode": "api_key", "prerequisite": "Superagent report and triage tools consume organization credits, and deleting a finding is permanent. Set those actions to Ask first before agents run unattended.", "riskTier": "S4" },
{ "slug": "telem", "name": "Telem.AI", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.telem.ai", "serverUrl": "https://mcp.telem.ai/mcp", "authMode": "api_key", "prerequisite": "A Telem API key. Create one in the Telem console at app.telem.ai.", "riskTier": "S2" }
{ "slug": "telem", "name": "Telem.AI", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.telem.ai", "serverUrl": "https://mcp.telem.ai/mcp", "authMode": "api_key", "prerequisite": "A Telem API key. Create one in the Telem console at app.telem.ai.", "riskTier": "S2" },
{ "slug": "gauge", "name": "Gauge", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.withgauge.com/help/mcp", "serverUrl": "https://app.withgauge.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Gauge account. Each connection reaches one Gauge organization: sign-in uses the organization chosen during consent, and an API key uses the organization that created it.", "riskTier": "S3" }
]
}
+31 -1
View File
@@ -1017,6 +1017,7 @@ const categoryBySlug = {
egnyte: "content",
embat: "commerce",
fireflies: "productivity",
gauge: "analytics",
"hugging-face": "ai",
jira: "productivity",
kernel: "developer",
@@ -1100,6 +1101,7 @@ const apiKeySpec = {
prefix: "Bearer ",
placeholder: "Paste your Coda API token",
},
gauge: { name: "Authorization", prefix: "Bearer ", placeholder: "Paste your Gauge API key" },
kernel: {
name: "X-API-Key",
prefix: null,
@@ -1229,6 +1231,34 @@ const specialMethodsFor = (entry) => {
}] } : {}),
}),
];
// Gauge's sign-in picks one organization during consent; an API key is bound
// to the organization that created it and carries no user identity.
if (entry.slug === "gauge") {
const warning =
"Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended.";
// The access step renders the capability profile before sign-in, so the
// publish warning is visible on the default browser path too.
const capabilityProfile = {
key: "write",
label: "Read and write",
description: `Read AI visibility, SEO and traffic data, and run content workflows. ${warning}`,
};
return [
oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
guidanceMd:
"Connect Gauge in the browser and choose the organization this connection may use. Write tools start enabled and remain governed by Paperclip's action policies.",
warnings: [entry.prerequisite, warning],
capabilityProfile,
}),
apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, {
whenToUse: "Use a Gauge organization API key when browser sign-in is not suitable.",
guidanceMd:
"In Gauge, open Settings → Integrations → API Keys, generate a key for Paperclip, and paste it below.",
warnings: [entry.prerequisite, warning],
capabilityProfile,
}),
];
}
// Superagent's hosted server advertises protected-resource metadata, but its
// authorization server publishes no OAuth metadata, so organization API keys
// are the only working credential.
@@ -1750,7 +1780,7 @@ for (const entry of researchManifest.entries) {
schemaVersion: 1,
slug: entry.slug,
name: entry.name,
description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers.", telem: "Search the web and read pages across many search providers with one API key." })[entry.slug] ?? (entry.slug === "fireflies"
description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", gauge: "Track how AI answers mention your brand, research keywords and traffic, and run content workflows.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers.", telem: "Search the web and read pages across many search providers with one API key." })[entry.slug] ?? (entry.slug === "fireflies"
? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines."
: `Connect ${entry.name}'s provider-hosted MCP server.`),
categories: [categoryBySlug[entry.slug] ?? "other"],
@@ -16,6 +16,7 @@ import { createDb, companies, agents, agentTaskSessions, heartbeatRuns, companyM
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
import { quotaCredentialRecoveryPath, quotaCredentialRecovery, quotaCredentialHash } from "../services/quota-credential-recovery.js";
import { readAiConnectionConfigurationFailure } from "../services/ai-connection-configuration-failure.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { syncConnectionCredentialBindings } from "../services/connection-credential-bindings.js";
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
@@ -60,6 +61,25 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it("marks explicit AI selection rejections without changing their HTTP contract", async () => {
const owner = "selection-blocker-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const expectSelectionFailure = async (promise: Promise<unknown>, reason: string) => {
const error = await promise.catch((error: unknown) => error);
expect(error).toMatchObject({ status: 422, details: { code: reason } });
expect(readAiConnectionConfigurationFailure(error)).toBe(reason);
};
await expectSelectionFailure(service.select({ ...input, userId: null }), "ai_connection_responsible_user_missing");
await expectSelectionFailure(service.select({ ...input, userId: owner, adapterType: "codex_local" }), "ai_connection_incompatible");
await expectSelectionFailure(service.select({ ...input, userId: owner }), "ai_connection_default_missing");
await expectSelectionFailure(service.select({ ...input, userId: owner, binding: {
...binding, mode: "delegated", connectionId: randomUUID(), grantId: randomUUID(),
} }), "ai_connection_missing");
const account = await create(owner, "Selection blocker account");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
await expectSelectionFailure(service.select({ ...input, userId: owner }), "ai_connection_unavailable");
});
it("persists first-time recovery directories before consuming a single-use token", async () => {
const owner = "quota-durable-path";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
@@ -0,0 +1,27 @@
// Use the real SDK so host tests cross a process and the NDJSON boundary.
void Promise.all([
import("../../../../packages/plugins/sdk/src/define-plugin.ts"),
import("../../../../packages/plugins/sdk/src/worker-rpc-host.ts"),
]).then(([{ definePlugin }, { startWorkerRpcHost }]) => {
let context;
startWorkerRpcHost({ plugin: definePlugin({
async setup(ctx) {
context = ctx;
ctx.events.on("fixture.write", async () => {
await ctx.state.set({ scopeKind: "instance", stateKey: "event" }, { value: 1 });
});
},
async onIdleDrain() { return "none"; },
async onHealth() {
if (process.env.IDLE_TEST_WRITE === "1") {
await context.state.set({ scopeKind: "instance", stateKey: "fixture" }, { value: 1 });
}
return { status: "ok" };
},
async onEnvironmentProbe(params) {
if (params.config.crash) process.exit(1);
await new Promise((resolve) => setTimeout(resolve, params.config.delayMs));
return { ok: true, summary: "fixture complete" };
},
}) });
});
@@ -2,6 +2,8 @@ import { ensureNativeCompletionContract } from "../services/native-runtime/compl
import { readNativePlanWait, hasCommittedNativePlanWait } from "../services/native-runtime/native-plan-wait.js";
import { nativeSha256 } from "../services/native-runtime/canonical.js";
import { buildQuestionResponseDeliveryEnvelope } from "../services/question-response-delivery.js";
import * as aiConnectionRuntime from "../services/ai-connection-runtime.js";
import { unprocessable } from "../errors.js";
import * as executionContinuation from "../services/execution-continuation.js";
import * as environmentOrchestrator from "../services/environment-run-orchestrator.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
@@ -49,6 +51,7 @@ import {
chatExternalPrincipals,
chatMessageLinks,
chatPublications,
companyMemberships,
companySecretBindings,
companySecrets,
companySkills,
@@ -1528,6 +1531,53 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
it.each(["missing_default", "database_error", "unmarked_http_error", "provider_error"] as const)(
"preserves AI configuration recovery and reports only unexpected causes: %s", async (cause) => {
const { companyId, agentId, issueId, runId, wakeupRequestId } = await seedQueuedIssueRunFixture();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
await db.update(agents).set({ runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" },
} }).where(eq(agents.id, agentId));
const prepare = vi.spyOn(aiConnectionRuntime, "prepareManagedAiRuntime");
if (cause !== "missing_default") prepare.mockRejectedValueOnce(
cause === "unmarked_http_error"
? unprocessable("Connect an account and choose your personal default", { code: "ai_connection_default_missing" })
: new Error(cause),
);
try {
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
await heartbeat.waitForRunExecutionDrain(runId);
await waitForPendingRunFailureReports();
expect(prepare).toHaveBeenCalled();
const failed = await heartbeat.getRun(runId);
expect(failed).toMatchObject({ status: "failed", errorCode: "configuration_incomplete",
resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
});
if (cause === "missing_default") {
expect(failed?.error).toBe("Connect an account and choose your personal default");
expect(failed?.resultJson?.configurationIncomplete).toMatchObject({ selectionFailure: "ai_connection_default_missing" });
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
} else {
expect(failed?.resultJson?.configurationIncomplete).not.toHaveProperty("selectionFailure");
expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ runId, errorCode: "configuration_incomplete" }));
}
expect(mockAdapterExecute).not.toHaveBeenCalled();
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(issue).toMatchObject({ status: "blocked", executionRunId: null });
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(action).toMatchObject({ status: "active", kind: "configuration_validation", cause: "configuration_incomplete", ownerType: "board" });
const [wakeup] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeupRequestId));
expect(wakeup.status).toBe("failed");
} finally {
prepare.mockRestore();
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
}
},
);
it.each(["timeout", "upstream", "cleanup_pending", "cleanup_pending_edited", "cleanup_pending_exhausted", "edited", "exhausted", "new_message", "no_claim", "reassigned", "superseded", "stopped"])(
"settles explicit retry admission through real executor cleanup: %s", async scenario => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
@@ -49,6 +49,36 @@ describe("idle admission", () => {
expect(idleWorkSnapshot().active).toBe(0);
});
it("tracks real Vite middleware without confusing Connect mount paths with Express routes", async () => {
const { createServer } = await import("vite");
const vite = await createServer({
configFile: false,
appType: "custom",
server: { middlewareMode: true, hmr: false, watch: null },
optimizeDeps: { noDiscovery: true, include: [] },
});
const server = app(), done = deferred();
vite.middlewares.use("/vite-work", async (_req, res) => {
res.statusCode = 202;
res.end();
await done.promise;
});
server.use(vite.middlewares);
try {
expect(() => trackIdleRequestHandlers(server)).not.toThrow();
await request(server).get("/vite-work").expect(202);
expect(idleWorkSnapshot().active).toBe(1);
startTaskDrain({ purpose: "idle", ttlMs: 60_000 });
await request(server).get("/vite-work").expect(503);
done.resolve();
await new Promise(resolve => setImmediate(resolve));
expect(idleWorkSnapshot().active).toBe(0);
} finally {
done.resolve();
await vite.close();
}
});
it("does not mistake a client disconnect for completed accepted work", async () => {
const server = app();
const entered = deferred(), done = deferred(), closed = deferred();
@@ -245,6 +245,22 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp
expect(await read()).toEqual(none);
});
it("accepts only exact worker drain receipts and still checks durable work", async () => {
const [{ id }] = await db.execute<{ id: string }>(sql`INSERT INTO plugins (plugin_key, package_name, version, manifest_json)
VALUES ('demo.idle', 'demo-idle', '1.0.0', '{}'::jsonb) RETURNING id`);
const inspect = vi.fn(async () => ({ backgroundWork: "none" as const, pluginIds: [id!] }));
const scan = () => readIdleSleepSafety(db, owned, () => now, ownerId, emptyLocal, inspect);
expect(await scan()).toEqual(none);
expect(inspect).toHaveBeenCalledWith({ ownerId, expiresAt: owned().expiresAt!.getTime() });
await db.execute(sql`INSERT INTO plugins (plugin_key, package_name, version, manifest_json)
VALUES ('demo.unknown', 'demo-unknown', '1.0.0', '{}'::jsonb)`);
expect(await scan()).toEqual(present);
await db.execute(sql`UPDATE plugins SET status = 'disabled' WHERE plugin_key = 'demo.unknown'`);
const { companyId } = await seed();
await db.insert(issues).values({ companyId, title: "Still pending", status: "todo" });
expect(await scan()).toEqual(present);
});
it("fails closed when the installed schema is older than the report", async () => {
await db.execute(sql`ALTER TABLE heartbeat_runs RENAME COLUMN cost_accounting_pending TO hidden_pending`);
try { expect(await read()).toEqual(unknown); }
@@ -20,6 +20,7 @@ import {
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { issueService } from "../services/issues.js";
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
import {
getEmbeddedPostgresTestSupport,
@@ -194,6 +195,168 @@ describeEmbeddedPostgres("inbox archive routes", () => {
.expect(({ body }) => expect(body).toEqual({ ok: true, userId: seeded.responsibleUserId }));
});
it.each([false, true])("allows manual archive during human completion (existing archive: %s)", async (alreadyArchived) => {
const seeded = await seed();
const svc = issueService(db);
if (alreadyArchived) {
await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId);
}
const app = appFor({
type: "board",
source: "session",
userId: seeded.responsibleUserId,
companyIds: [seeded.companyId],
memberships: [{ companyId: seeded.companyId, membershipRole: "operator", status: "active" }],
isInstanceAdmin: false,
});
let manualArchive: Promise<{ status: number }> | undefined;
let completed: Awaited<ReturnType<typeof svc.update>>;
let manualStatus: number | undefined;
try {
completed = await db.transaction(async (tx) => {
await tx.execute(sql`set local statement_timeout = '5s'`);
// Human completion locks the issue before it writes the archive.
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, seeded.issueId)).for("update");
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
manualArchive = request(app).post(`/api/issues/${seeded.issueId}/inbox-archive`).send({}).then((response) => response);
await expect.poll(async () => {
const blocked = await db.execute(sql`
select 1 from pg_stat_activity
where datname = current_database() and ${pid} = any(pg_blocking_pids(pid))
`);
return blocked.length;
}, { timeout: 3_000 }).toBeGreaterThan(0);
// The manual request is now waiting on our parent row. Previously it
// already owned the archive insert, so this completion formed a cycle.
return svc.update(seeded.issueId, {
status: "done",
actorUserId: seeded.responsibleUserId,
companyGuard: seeded.companyId,
}, tx, []);
});
} finally {
// Do not leave an in-flight route writing into the next test's fixture.
if (manualArchive) manualStatus = (await manualArchive).status;
}
expect(manualStatus).toBe(200);
expect(completed!.status).toBe("done");
const archives = await db.select().from(issueInboxArchives);
expect(archives).toHaveLength(1);
expect(archives[0]).toMatchObject({
companyId: seeded.companyId,
issueId: seeded.issueId,
userId: seeded.responsibleUserId,
archivedByActorType: "user",
archivedByAgentId: null,
archivedByRunId: null,
});
const audits = await db.select().from(activityLog).where(eq(activityLog.action, "issue.inbox_archived"));
expect(audits).toHaveLength(2);
expect(audits.some((audit) => audit.details?.source === "issue_status_done")).toBe(true);
expect(archives[0].archivedAt.getTime()).toBeGreaterThanOrEqual(completed!.completedAt!.getTime());
// The status route records this activity after the completion transaction.
await db.insert(activityLog).values({
companyId: seeded.companyId,
actorType: "user",
actorId: seeded.responsibleUserId,
action: "issue.updated",
entityType: "issue",
entityId: seeded.issueId,
details: { status: "done", _previous: { status: "todo" } },
});
await request(app).get(`/api/companies/${seeded.companyId}/issues`)
.query({ touchedByUserId: seeded.responsibleUserId, inboxArchivedByUserId: seeded.responsibleUserId, status: "done" })
.expect(200)
.expect(({ body }) => expect(body.map((issue: { id: string }) => issue.id)).not.toContain(seeded.issueId));
});
it("keeps caller rollback atomic without blocking a different user's archive", async () => {
const seeded = await seed();
const svc = issueService(db);
const rollback = new Error("Roll back the first user's archive");
await expect(db.transaction(async (tx) => {
await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, new Date(), undefined, tx);
// An exclusive parent lock would unnecessarily serialize these users.
await db.transaction(async (otherTx) => {
await otherTx.execute(sql`set local statement_timeout = '3s'`);
await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.targetUserId, new Date(), undefined, otherTx);
});
throw rollback;
})).rejects.toBe(rollback);
const archives = await db.select().from(issueInboxArchives);
expect(archives).toHaveLength(1);
expect(archives[0].userId).toBe(seeded.targetUserId);
});
it.each([-1, 0, 1])("keeps the newest archive time and matching attribution (offset: %s)", async (offset) => {
const seeded = await seed();
const svc = issueService(db);
const firstTime = new Date("2000-01-02T00:00:00.000Z");
const nextTime = new Date(firstTime.getTime() + offset * 1_000);
const first = await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, firstTime, {
archivedByActorType: "agent",
archivedByAgentId: seeded.agentId,
archivedByRunId: seeded.runId,
});
const next = await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, nextTime);
expect(next.id).toBe(first.id);
expect(next).toMatchObject(offset < 0 ? first : {
archivedAt: nextTime,
archivedByActorType: "user",
archivedByAgentId: null,
archivedByRunId: null,
});
expect(await db.select().from(issueInboxArchives)).toEqual([next]);
await svc.unarchiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId);
const afterUndo = await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, nextTime);
expect(afterUndo).toMatchObject({ archivedAt: nextTime, archivedByActorType: "user" });
expect(afterUndo.id).not.toBe(first.id);
});
it("holds the issue company stable until its archive transaction ends", async () => {
const seeded = await seed();
const otherCompany = await seed();
const svc = issueService(db);
let move: Promise<unknown> | undefined;
try {
await db.transaction(async (tx) => {
await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, new Date(), undefined, tx);
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
move = db.update(issues).set({ companyId: otherCompany.companyId })
.where(eq(issues.id, seeded.issueId)).then((rows) => rows);
await expect.poll(async () => {
const blocked = await db.execute(sql`
select 1 from pg_stat_activity
where datname = current_database() and ${pid} = any(pg_blocking_pids(pid))
`);
return blocked.length;
}, { timeout: 3_000 }).toBeGreaterThan(0);
const [stillOwned] = await tx.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(stillOwned.companyId).toBe(seeded.companyId);
});
} finally {
await move;
}
const [moved] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(moved.companyId).toBe(otherCompany.companyId);
});
it("rejects missing and cross-company issues before archiving", async () => {
const seeded = await seed();
const svc = issueService(db);
const otherCompany = await seed();
for (const [companyId, issueId] of [
[otherCompany.companyId, seeded.issueId],
[seeded.companyId, randomUUID()],
]) {
await expect(svc.archiveInbox(companyId, issueId, seeded.responsibleUserId))
.rejects.toMatchObject({ status: 404, message: "Issue not found" });
}
expect(await db.select().from(issueInboxArchives)).toHaveLength(0);
});
it("silently archives an issue for the board user who moves it to done", async () => {
const seeded = await seed();
const app = appFor({
@@ -2,6 +2,7 @@ import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
@@ -76,7 +77,7 @@ describe("instance settings routes", () => {
return { errorHandler, instanceSettingsRoutes };
});
function createApp(actor: any) {
function createApp(actor: any, pluginWorkers?: PluginWorkerManager) {
const { errorHandler, instanceSettingsRoutes } = routeModules.value;
const app = express();
app.use(express.json());
@@ -84,7 +85,7 @@ describe("instance settings routes", () => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes(mockDb as any));
app.use("/api", instanceSettingsRoutes(mockDb as any, pluginWorkers));
app.use(errorHandler);
return app;
}
@@ -972,7 +973,7 @@ describe("instance settings routes", () => {
await request(createApp(adminActor)).post("/api/instance/task-drain").send({ purpose: "idle", ttlMs: 60_000 }).expect(200);
expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ purpose: "idle", ttlMs: 60_000 });
await request(createApp(adminActor)).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId);
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, undefined, undefined);
});
it.each([{}, { ttlMs: null }, { ttlMs: 1 }, { ttlMs: 300_001 }])("rejects an unbounded or invalid idle TTL: %j", async body => {
@@ -999,10 +1000,34 @@ describe("instance settings routes", () => {
const res = await request(createApp(adminActor)).get("/api/instance/task-drain?idleSleepSafety=1");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ...idleStatus, idleSleepSafety: report });
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, undefined, undefined);
expect(mockReadIdleSleepSafety.mock.calls[0][1]()).toEqual(idleStatus);
});
it("uses the live worker manager for inspection and releases only the owned hold", async () => {
const inspect = vi.fn(async () => ({ backgroundWork: "none", pluginIds: [] }));
const release = vi.fn();
const workers = {
inspectIdleSleep: inspect,
releaseIdleSleep: release,
} as unknown as PluginWorkerManager;
const ownerId = "fixture-idle-owner";
const lease = { ownerId, expiresAt: Date.now() + 60_000 };
mockHeartbeatService.getTaskDrainStatus.mockReturnValue({ ...idleStatus, draining: true, ownerId });
mockReadIdleSleepSafety.mockImplementation(async (_db, _status, _now, owner, _local, inspectPlugins) => {
expect(owner).toBe(ownerId);
return { version: 1, backgroundWork: (await inspectPlugins(lease)).backgroundWork };
});
const app = createApp(adminActor, workers);
await request(app).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200);
expect(inspect).toHaveBeenCalledWith(lease);
expect(inspect.mock.contexts[0]).toBe(workers);
await request(app).delete("/api/instance/task-drain?ownerId=stale-owner").expect(409);
expect(release).not.toHaveBeenCalled();
await request(app).delete(`/api/instance/task-drain?ownerId=${ownerId}`).expect(200);
expect(release).toHaveBeenCalledOnce();
});
it.each([
["company member", nonAdminActor],
["agent", { type: "agent", agentId: "agent-1", companyId: "company-1" }],
@@ -0,0 +1,98 @@
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { setTimeout as delay } from "node:timers/promises";
import { afterEach, describe, expect, it } from "vitest";
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
import { startTaskDrain, stopTaskDrain } from "../services/task-admission.js";
const manifest = { id: "test.idle", apiVersion: 1 as const, version: "1.0.0", displayName: "Idle fixture",
description: "Idle fixture", author: "Paperclip", categories: ["automation" as const], capabilities: [], entrypoints: { worker: "worker.cjs" } };
function worker(write = false, hostHandler = async () => undefined) {
return createPluginWorkerHandle("test.idle", {
entrypointPath: fileURLToPath(new URL("./fixtures/plugin-worker-idle.cjs", import.meta.url)),
execArgv: ["--import", createRequire(import.meta.url).resolve("tsx")],
manifest, config: {}, instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, apiVersion: 1,
env: { IDLE_TEST_WRITE: write ? "1" : "0" },
hostHandlers: { "state.set": hostHandler, "events.subscribe": async () => ({}) },
});
}
function hold() {
const lease = startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
return { ownerId: lease.ownerId!, expiresAt: lease.expiresAt!.getTime() };
}
describe("plugin manager idle receipts", () => {
afterEach(() => stopTaskDrain());
it("retains timed-out worker operations and admits work after the owned hold releases", async () => {
const handle = worker();
await handle.start();
try {
await expect(handle.call("environmentProbe", { driverKey: "fixture", companyId: "fixture", environmentId: "fixture", config: { delayMs: 200 } }, 10)).rejects.toThrow("timed out");
const lease = hold();
expect(await handle.prepareIdleSleep!(lease)).toBe("present");
await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none");
await expect(handle.call("health", {})).rejects.toThrow("held");
expect(await handle.prepareIdleSleep!({ ...lease, ownerId: "stale" })).toBe("unknown");
stopTaskDrain(); handle.releaseIdleSleep!();
expect(await handle.call("health", {})).toMatchObject({ status: "ok" });
expect(await handle.prepareIdleSleep!(hold())).toBe("none");
stopTaskDrain();
// No intervening request or explicit worker release: the next controller
// attempt must discard the worker manager's previous ownership record.
expect(await handle.prepareIdleSleep!(hold())).toBe("none");
} finally { stopTaskDrain(); await handle.stop(); }
});
it("keeps a host write counted after the caller stops waiting", async () => {
let finish!: () => void;
const handle = worker(true, () => new Promise<void>((resolve) => { finish = resolve; }));
await handle.start();
try {
await expect(handle.call("health", {}, 30)).rejects.toThrow("timed out");
const lease = hold();
expect(await handle.prepareIdleSleep!(lease)).toBe("present");
finish();
await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none");
} finally { stopTaskDrain(); await handle.stop(); }
});
it("lets a queued notification finish its write while idle preparation is in flight", async () => {
let finish!: () => void;
let writes = 0;
const handle = worker(false, () => new Promise<void>((resolve) => {
writes++;
finish = resolve;
}));
await handle.start();
try {
// Finish setup and its subscription receipt before queuing the event.
await handle.call("health", {});
handle.notify("onEvent", { event: { eventType: "fixture.write" } });
// No await: the host has set its hold before it can read the event's
// worker-to-host write. The worker must still be allowed to complete it.
const lease = hold();
const preparation = handle.prepareIdleSleep!(lease);
expect(await preparation).not.toBe("none");
await expect.poll(() => writes).toBe(1);
expect(await handle.prepareIdleSleep!(lease)).toBe("present");
finish();
await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none");
} finally { finish?.(); stopTaskDrain(); await handle.stop(); }
});
it("resumes crash recovery after an idle hold without discarding the restart", async () => {
const handle = worker();
await handle.start();
handle.on("crash", () => { hold(); });
try {
await expect(handle.call("environmentProbe", { driverKey: "fixture", companyId: "fixture", environmentId: "fixture", config: { crash: true } })).rejects.toThrow();
await delay(1_600);
expect(handle.status).toBe("backoff");
expect(handle.diagnostics().nextRestartAt).not.toBeNull();
stopTaskDrain();
await expect.poll(() => handle.status, { timeout: 5_000 }).toBe("running");
// Recovery restores service, but lost completion receipts remain unsafe.
expect(await handle.prepareIdleSleep!(hold())).toBe("unknown");
} finally { stopTaskDrain(); await handle.stop(); }
});
});
@@ -0,0 +1,89 @@
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { definePlugin, type PluginDefinition } from "../../../packages/plugins/sdk/src/define-plugin.js";
import { startWorkerRpcHost } from "../../../packages/plugins/sdk/src/worker-rpc-host.js";
function fixture(definition: PluginDefinition, rpcTimeoutMs = 30_000) {
const stdin = new PassThrough();
const stdout = new PassThrough();
const worker = startWorkerRpcHost({ plugin: definePlugin(definition), stdin, stdout, rpcTimeoutMs });
const messages: any[] = [];
stdout.on("data", (chunk) => { for (const line of String(chunk).trim().split("\n")) messages.push(JSON.parse(line)); });
let id = 0;
async function call(method: string, params: unknown = {}) {
const callId = ++id;
stdin.write(JSON.stringify({ jsonrpc: "2.0", id: callId, method, params }) + "\n");
await expect.poll(() => messages.find((message) => message.id === callId && !message.method)).toBeDefined();
return messages.find((message) => message.id === callId && !message.method);
}
const initialize = () => call("initialize", { manifest: { id: "test.idle", apiVersion: 1 }, config: {}, apiVersion: 1 });
return { call, initialize, messages, stdin, close: () => worker.stop() };
}
describe("plugin idle RPC", () => {
const hold = () => ({ ownerId: "idle-owner", expiresAt: Date.now() + 30_000 });
it("seals an idle worker, fences stale releases, and restores normal calls", async () => {
const f = fixture({ async setup() {}, async onIdleDrain() { return "none"; } });
try {
expect((await f.initialize()).result.supportedMethods).toContain("prepareIdleSleep");
const lease = hold();
expect((await f.call("prepareIdleSleep", lease)).result).toEqual({ ...lease, backgroundWork: "none" });
expect((await f.call("health")).error).toBeDefined();
await f.call("releaseIdleSleep", { ownerId: "stale" });
expect((await f.call("health")).error).toBeDefined();
await f.call("releaseIdleSleep", lease);
expect((await f.call("health")).result.status).toBe("ok");
} finally { f.close(); }
});
it("does not mistake a caller timeout for a completed worker handler", async () => {
let finish!: () => void;
const f = fixture({ async setup() {}, async onIdleDrain() { return "none"; },
async onHealth() { await new Promise<void>((resolve) => { finish = resolve; }); return { status: "ok" }; } });
try {
await f.initialize();
const accepted = f.call("health");
await expect.poll(() => finish).toBeDefined();
expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("present");
finish(); await accepted;
expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("none");
} finally { f.close(); }
});
it("retains timed-out worker-to-host writes until the host's late receipt", async () => {
let write!: () => Promise<unknown>;
const f = fixture({ async setup(ctx) { write = () => ctx.state.set({ scopeKind: "instance", stateKey: "fixture" }, { value: 1 }); },
async onIdleDrain() { return "none"; } }, 10);
try {
await f.initialize();
await expect(write()).rejects.toThrow("timed out");
const request = f.messages.find((message) => message.method === "state.set");
expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("present");
f.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: null }) + "\n");
expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("none");
} finally { f.close(); }
});
it.each([false, true])("logs session callback failures (async: %s)", async (asynchronous) => {
let subscribe!: () => Promise<unknown>;
const f = fixture({ async setup(ctx) {
subscribe = () => ctx.agents.sessions.sendMessage("fixture-session", "fixture-company", {
prompt: "fixture",
onEvent: asynchronous
? async () => { throw new Error("fixture callback failure"); }
: () => { throw new Error("fixture callback failure"); },
});
} });
try {
await f.initialize();
const subscription = subscribe();
const request = f.messages.find((message) => message.method === "agents.sessions.sendMessage");
f.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: {} }) + "\n");
await subscription;
f.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "agents.sessions.event", params: { sessionId: "fixture-session" } }) + "\n");
await expect.poll(() => f.messages.find((message) => message.method === "log" &&
message.params.level === "error" && message.params.message.includes("fixture callback failure"))).toBeDefined();
} finally { f.close(); }
});
});
@@ -5214,7 +5214,7 @@ describeEmbeddedPostgres("tool access service", () => {
"telem",
]),
);
expect(res.body.apps).toHaveLength(68);
expect(res.body.apps).toHaveLength(69);
for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider");
}
+1 -1
View File
@@ -834,7 +834,7 @@ export async function createApp(
api.use(resourceMembershipRoutes(db));
api.use(primaryAgentRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
api.use(instanceSettingsRoutes(db, workerManager));
if (opts.databaseBackupService) {
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
}
+4 -2
View File
@@ -53,14 +53,16 @@ export const idleAdmissionMiddleware: RequestHandler = (req, res, next) => {
// preserving router objects and error-handler arity. Waiting only for finish
// loses async work after res.json(), including the disconnected-client case.
// Keep this adapter isolated and exercise it against real Express in tests.
type Layer = { handle: Function & { stack?: Layer[] }; route?: { stack: Layer[] } };
type Layer = { handle: Function & { stack?: Layer[] }; route?: { stack: Layer[] } | string };
export function trackIdleRequestHandlers(app: Application): void {
const seen = new Set<Layer>();
const visit = (stack: Layer[], route = false) => {
for (const layer of stack) {
if (seen.has(layer)) continue;
seen.add(layer);
if (layer.route) { visit(layer.route.stack, true); continue; }
// Connect/Vite layers use a mount-path string for route; only Express
// Route objects contain a nested handler stack.
if (layer.route && typeof layer.route !== "string") { visit(layer.route.stack, true); continue; }
if (layer.handle.stack) { visit(layer.handle.stack); continue; }
const original = layer.handle;
if (original === idleAdmissionMiddleware) continue;
+5 -2
View File
@@ -15,6 +15,7 @@ import {
} from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
import {
@@ -119,7 +120,7 @@ function withTaskDrainTransition<T>(run: () => Promise<T>): Promise<T> {
return turn;
}
export function instanceSettingsRoutes(db: Db) {
export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManager) {
const router = Router();
const svc = instanceSettingsService(db);
const environments = environmentService(db);
@@ -304,7 +305,8 @@ export function instanceSettingsRoutes(db: Db) {
// members may read process counters, but not instance-wide work state.
assertCanManageInstanceSettings(req);
const idleSleepSafety = await readIdleSleepSafety(db, () => heartbeat.getTaskDrainStatus(), Date.now,
typeof req.query.ownerId === "string" ? req.query.ownerId : undefined);
typeof req.query.ownerId === "string" ? req.query.ownerId : undefined, undefined,
pluginWorkers?.inspectIdleSleep?.bind(pluginWorkers));
res.json({ ...heartbeat.getTaskDrainStatus(), idleSleepSafety });
return;
}
@@ -418,6 +420,7 @@ export function instanceSettingsRoutes(db: Db) {
),
);
heartbeat.stopTaskDrain();
pluginWorkers?.releaseIdleSleep?.();
// See the POST handler above for why a publish failure here is
// swallowed instead of failing the route: the audit record already
// committed, so a publish failure here must not undo a drain-stop
@@ -283,6 +283,65 @@ describeEmbeddedPostgres("reportRunFailure", () => {
expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" }));
});
function aiSelectionRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
return buildRun({
errorCode: "configuration_incomplete",
executionStage: "preparing",
resultJson: {
configurationIncomplete: {
reason: "ai_connection_unavailable",
selectionFailure: "ai_connection_default_missing",
},
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
},
...overrides,
});
}
it.each([
"ai_connection_responsible_user_missing", "ai_connection_default_missing",
"ai_connection_missing", "ai_connection_incompatible", "ai_connection_unavailable",
])("keeps the proven AI selection blocker local: %s", async (selectionFailure) => {
await seedCompanyAndAgent();
const run = aiSelectionRun();
(run.resultJson!.configurationIncomplete as Record<string, unknown>).selectionFailure = selectionFailure;
const saved = structuredClone(run);
await reportRunFailure(db, run, { phase: "setup" });
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
expect(run).toEqual(saved);
});
it.each([undefined, null, {}, [], "", "database_error", "ai_connection_busy"])(
"reports AI connection wrappers without a known selection cause: %j", async (selectionFailure) => {
await seedCompanyAndAgent();
const run = aiSelectionRun();
(run.resultJson!.configurationIncomplete as Record<string, unknown>).selectionFailure = selectionFailure;
await reportRunFailure(db, run, { phase: "setup" });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
},
);
it.each([
{ runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: {} } },
{ status: "timed_out" }, { errorCode: "setup_failed" }, { executionStage: "executing" },
{ executionStage: null }, { exitCode: 1 }, { exitCode: 0 }, { signal: "SIGTERM" },
{ resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" } } },
{ resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } },
{ resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "resume", providerWorkStarted: false } } },
{ resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "bootstrap" } } },
{ resultJson: { configurationIncomplete: { reason: "controller_environment_unsupported", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } },
])("reports AI configuration without pre-provider selection proof: %j", async (overrides) => {
await seedCompanyAndAgent();
await reportRunFailure(db, aiSelectionRun(overrides), { phase: "setup" });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
it.each([undefined, "execute"] as const)("reports AI selection failures outside setup: %s", async (phase) => {
await seedCompanyAndAgent();
await reportRunFailure(db, aiSelectionRun(), { phase });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
function missingSecretRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
return buildRun({
errorCode: "configuration_incomplete",
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { unprocessable } from "../errors.js";
import {
aiConnectionConfigurationFailure,
isAiConnectionConfigurationReason,
readAiConnectionConfigurationFailure,
} from "./ai-connection-configuration-failure.js";
describe("AI selection failure provenance", () => {
it("preserves HTTP behavior while retaining only an owned bounded reason", () => {
const error = aiConnectionConfigurationFailure("ai_connection_default_missing", "Choose an account", { connectionId: "synthetic-connection" });
expect(error).toMatchObject({ status: 422, message: "Choose an account", details: {
code: "ai_connection_default_missing", connectionId: "synthetic-connection",
} });
expect(readAiConnectionConfigurationFailure(error)).toBe("ai_connection_default_missing");
expect(Object.keys(error)).toEqual(["status", "details"]);
});
it.each([
new Error("Connect an account and choose your personal default"),
unprocessable("Choose an account", { code: "ai_connection_default_missing" }),
{ status: 422, details: { code: "ai_connection_default_missing" } },
Object.assign(new Error("database failed"), { code: "configuration_incomplete" }),
null,
])("does not infer provenance from an error name, message, or HTTP shape: %j", (error) => {
expect(readAiConnectionConfigurationFailure(error)).toBeNull();
});
it.each([null, undefined, {}, [], "ai_connection_busy", "provider_error", "toString"])(
"rejects unknown or malformed persisted reasons: %j", (value) => {
expect(isAiConnectionConfigurationReason(value)).toBe(false);
},
);
});
@@ -0,0 +1,31 @@
import { unprocessable } from "../errors.js";
const reasons = [
"ai_connection_responsible_user_missing",
"ai_connection_default_missing",
"ai_connection_missing",
"ai_connection_incompatible",
"ai_connection_unavailable",
] as const;
type AiConnectionConfigurationReason = typeof reasons[number];
const failures = new WeakMap<Error, AiConnectionConfigurationReason>();
/** Preserve the existing HTTP error while recording an explicit selection rejection. */
export function aiConnectionConfigurationFailure(
reason: AiConnectionConfigurationReason,
message: string,
details: Record<string, unknown> = {},
) {
const error = unprocessable(message, { ...details, code: reason });
failures.set(error, reason);
return error;
}
/** Only owned producers supply this evidence; matching names or HTTP codes do not. */
export function readAiConnectionConfigurationFailure(error: unknown): AiConnectionConfigurationReason | null {
return error instanceof Error ? failures.get(error) ?? null : null;
}
export function isAiConnectionConfigurationReason(value: unknown): value is AiConnectionConfigurationReason {
return typeof value === "string" && reasons.some((reason) => reason === value);
}
+8 -25
View File
@@ -39,6 +39,7 @@ import {
type AiConnectionLoginIntent,
} from "@paperclipai/shared";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { aiConnectionConfigurationFailure } from "./ai-connection-configuration-failure.js";
import { quotaCredentialRecovery, quotaCredentialHash } from "./quota-credential-recovery.js";
import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
@@ -266,15 +267,9 @@ export function aiConnectionService(db: Db) {
input.acpxAgent,
)
)
throw unprocessable(
"Select an AI connection compatible with this harness and model",
{ code: "ai_connection_incompatible" },
);
throw aiConnectionConfigurationFailure("ai_connection_incompatible", "Select an AI connection compatible with this harness and model");
if (binding.mode === "responsible_user" && !userId)
throw unprocessable(
"This run needs a responsible user to select an AI connection",
{ code: "ai_connection_responsible_user_missing" },
);
throw aiConnectionConfigurationFailure("ai_connection_responsible_user_missing", "This run needs a responsible user to select an AI connection");
if (userId && !(await membership(companyId, userId)))
throw forbidden("The responsible user is not an active company member");
const defaultRow =
@@ -298,10 +293,7 @@ export function aiConnectionService(db: Db) {
? defaultRow?.grantId
: binding.grantId;
if (!grantId)
throw unprocessable(
"Connect an account and choose your personal default",
{ code: "ai_connection_default_missing" },
);
throw aiConnectionConfigurationFailure("ai_connection_default_missing", "Connect an account and choose your personal default");
const row = (await rows(companyId)).find(
(r) =>
r.grant.id === grantId &&
@@ -309,9 +301,7 @@ export function aiConnectionService(db: Db) {
r.connection.id === binding.connectionId),
);
if (!row)
throw unprocessable("The selected AI connection is unavailable", {
code: "ai_connection_missing",
});
throw aiConnectionConfigurationFailure("ai_connection_missing", "The selected AI connection is unavailable");
const { connection, grant } = row;
const metadata = aiConnectionMetadataSchema.safeParse(connection.config.ai);
if (
@@ -320,13 +310,9 @@ export function aiConnectionService(db: Db) {
(binding.mode !== "responsible_user" && metadata.data.method !== binding.method) ||
!isAiConnectionCompatible(metadata.data, input.adapterType, input.model, input.runnerProvider, input.acpxAgent)
)
throw unprocessable("The selected AI connection is incompatible", {
code: "ai_connection_incompatible",
});
throw aiConnectionConfigurationFailure("ai_connection_incompatible", "The selected AI connection is incompatible");
if (aiSubscriptionNeedsIsolatedLogin(connection.config))
throw unprocessable("Reconnect this subscription with a separate sign-in to protect your existing terminal login.", {
code: "ai_connection_unavailable", connectionId: connection.id,
});
throw aiConnectionConfigurationFailure("ai_connection_unavailable", "Reconnect this subscription with a separate sign-in to protect your existing terminal login.", { connectionId: connection.id });
if (
grant.status !== "active" ||
!connection.enabled ||
@@ -337,10 +323,7 @@ export function aiConnectionService(db: Db) {
connection.config.aiLegacyAdoption === true
))
)
throw unprocessable("Reconnect or validate the selected AI account", {
code: "ai_connection_unavailable",
connectionId: connection.id,
});
throw aiConnectionConfigurationFailure("ai_connection_unavailable", "Reconnect or validate the selected AI account", { connectionId: connection.id });
if (
grant.kind === "user" &&
!(await membership(companyId, grant.subjectUserId))
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,286 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { HttpError } from "../../errors.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { createRunDispatch, type PostCommitEffect } from "../../modules/run-dispatch/index.js";
import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js";
import * as legacy from "../heartbeat.js";
import { createHeartbeatRunState } from "./run-state.js";
import * as extracted from "./retries.js";
import type { HeartbeatRetryDependencies } from "./retries.js";
type Run = typeof heartbeatRuns.$inferSelect;
type Agent = typeof agents.$inferSelect;
function callbacks() {
return {
getRun: vi.fn<HeartbeatRetryDependencies["getRun"]>(async () => null),
getAgent: vi.fn<HeartbeatRetryDependencies["getAgent"]>(async () => undefined),
resolveSessionBeforeForWakeup: vi.fn(async () => null),
resolveResponsibleUserIdForRunContext: vi.fn(async () => "retry-user"),
getAgentInvokability: vi.fn<HeartbeatRetryDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
appendRunEvent: vi.fn<HeartbeatRetryDependencies["appendRunEvent"]>(async () => undefined),
escalatePlanApprovalResumeFailureNeedsAttention: vi.fn(async () => null),
recordPlanApprovalResumeFailureRetry: vi.fn(async () => null),
setRunStatusIfRunning: vi.fn<HeartbeatRetryDependencies["setRunStatusIfRunning"]>(async () => ({ updated: false, run: null })),
setWakeupStatus: vi.fn(async () => undefined),
releaseIssueExecutionAndPromote: vi.fn(async () => undefined),
finalizeAgentStatus: vi.fn(async () => undefined),
getWorktreeExecutionCutoff: vi.fn(async (): Promise<Date | null> => null),
applyRunDispatchPostCommitEffects: vi.fn<(effects: PostCommitEffect[]) => void>(),
runDispatch: {
evaluateScheduledRetryGate: vi.fn<HeartbeatRetryDependencies["runDispatch"]["evaluateScheduledRetryGate"]>(async () => ({ allowed: true })),
promoteDueScheduledRetries: vi.fn<HeartbeatRetryDependencies["runDispatch"]["promoteDueScheduledRetries"]>(async () => ({ promoted: 0, runIds: [], postCommitEffects: [] })),
promoteScheduledRetry: vi.fn<HeartbeatRetryDependencies["runDispatch"]["promoteScheduledRetry"]>(async () => ({ outcome: "not_promoted" })),
},
} satisfies HeartbeatRetryDependencies;
}
// These tests must never query: only the injected lifecycle callbacks may run.
function guardedDatabase() {
const access = vi.fn(() => { throw new Error("Unexpected retry database access"); });
return { db: new Proxy({}, { get: access }) as Db, access };
}
function unitRun(): Run {
const run: Partial<Run> = {
id: "run", companyId: "company", agentId: "agent", status: "running",
scopeKind: "issue", issueId: "issue",
wakeupRequestId: "wake", scheduledRetryAttempt: 0,
contextSnapshot: { issueId: "issue", timerClaimWasFirstHeartbeat: true },
};
return run as Run;
}
function deferral() {
return new extracted.WorkspaceBusyDeferral({
holder: { runId: "holder", agentId: "other-agent", issueId: "other-issue", issueIdentifier: "T-2" },
projectWorkspaceId: "workspace", deferralAttempt: 0, wasIssueAssignee: true,
});
}
describe("heartbeat retry module callbacks", () => {
it("preserves legacy helper and error-class identity", () => {
expect(legacy.computeBoundedTransientHeartbeatRetrySchedule).toBe(extracted.computeBoundedTransientHeartbeatRetrySchedule);
expect(legacy.computeWorkspaceBusyRetryDelayMs).toBe(extracted.computeWorkspaceBusyRetryDelayMs);
expect(legacy.BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS).toBe(extracted.BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS);
expect(legacy.WorkspaceBusyDeferral).toBe(extracted.WorkspaceBusyDeferral);
expect(deferral()).toBeInstanceOf(legacy.WorkspaceBusyDeferral);
});
it("constructs without database access or lifecycle effects", () => {
const database = guardedDatabase();
const deps = callbacks();
extracted.createHeartbeatRetries(database.db, deps);
expect(database.access).not.toHaveBeenCalled();
for (const callback of Object.values(deps)) {
if (typeof callback === "function") expect(callback).not.toHaveBeenCalled();
}
for (const callback of Object.values(deps.runDispatch)) expect(callback).not.toHaveBeenCalled();
});
it.each(["missing_run", "missing_agent"] as const)("keeps %s lookup behavior and requests the full source result", async (outcome) => {
const database = guardedDatabase();
const deps = callbacks();
if (outcome === "missing_agent") deps.getRun.mockResolvedValue(unitRun());
expect(await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetry("run")).toEqual({ outcome });
expect(deps.getRun).toHaveBeenCalledWith("run", { unsafeFullResultJson: true });
expect(deps.getAgent).toHaveBeenCalledTimes(outcome === "missing_agent" ? 1 : 0);
expect(database.access).not.toHaveBeenCalled();
});
it("records exhaustion before escalating a plan-resume failure", async () => {
const database = guardedDatabase();
const deps = callbacks();
const run = unitRun();
const retry = await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetryForRun(
run, { adapterType: "codex_local" } as Agent,
{ retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, maxAttempts: 0 },
);
expect(retry).toMatchObject({ outcome: "retry_exhausted", attempt: 1, maxAttempts: 0 });
expect(deps.appendRunEvent).toHaveBeenCalledWith(run, expect.objectContaining({
retryExhaustion: { retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, scheduledRetryAttempt: 0, maxAttempts: 0 },
}));
expect(deps.escalatePlanApprovalResumeFailureNeedsAttention).toHaveBeenCalledAfter(deps.appendRunEvent);
expect(database.access).not.toHaveBeenCalled();
});
it("passes the service's worktree cutoff and publishes only returned effects", async () => {
const database = guardedDatabase();
const deps = callbacks();
const now = new Date("2026-10-08T12:00:00Z");
const cutoff = new Date("2026-10-08T11:00:00Z");
const effect: PostCommitEffect = {
kind: "run_queued", runId: "retry", companyId: "company", agentId: "agent",
invocationSource: "automation", triggerDetail: "system", wakeupRequestId: "wake",
};
deps.getWorktreeExecutionCutoff.mockResolvedValue(cutoff);
deps.runDispatch.promoteDueScheduledRetries.mockResolvedValue({ promoted: 1, runIds: ["retry"], postCommitEffects: [effect] });
expect(await extracted.createHeartbeatRetries(database.db, deps).promoteDueScheduledRetries(now)).toEqual({ promoted: 1, runIds: ["retry"] });
expect(deps.runDispatch.promoteDueScheduledRetries).toHaveBeenCalledWith({ now, cutoff });
expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledWith([effect]);
expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledAfter(deps.runDispatch.promoteDueScheduledRetries);
expect(database.access).not.toHaveBeenCalled();
});
it("does not publish effects when promotion rejects", async () => {
const deps = callbacks();
deps.runDispatch.promoteDueScheduledRetries.mockRejectedValue(new Error("promotion failed"));
await expect(extracted.createHeartbeatRetries(guardedDatabase().db, deps).promoteDueScheduledRetries()).rejects.toThrow("promotion failed");
expect(deps.applyRunDispatchPostCommitEffects).not.toHaveBeenCalled();
});
it.each(["workspace", "connection"] as const)("leaves a %s cancellation race winner alone", async (kind) => {
const database = guardedDatabase();
const deps = callbacks();
const retries = extracted.createHeartbeatRetries(database.db, deps);
if (kind === "workspace") await retries.finalizeWorkspaceBusyDeferral(unitRun(), deferral());
else await retries.finalizeAiConnectionBusyDeferral(unitRun(), new HttpError(409, "busy"), true);
expect(deps.setRunStatusIfRunning).toHaveBeenCalledTimes(1);
expect(deps.setWakeupStatus).not.toHaveBeenCalled();
expect(deps.getAgent).not.toHaveBeenCalled();
expect(deps.releaseIssueExecutionAndPromote).not.toHaveBeenCalled();
expect(deps.finalizeAgentStatus).not.toHaveBeenCalled();
expect(database.access).not.toHaveBeenCalled();
});
it.each(["workspace", "connection"] as const)("settles the agent after a %s deferral cannot keep its issue lock", async (kind) => {
const database = guardedDatabase();
const deps = callbacks();
const run = { ...unitRun(), status: "cancelled" };
deps.setRunStatusIfRunning.mockResolvedValue({ updated: true, run });
deps.releaseIssueExecutionAndPromote.mockRejectedValue(new Error("release failed"));
const retries = extracted.createHeartbeatRetries(database.db, deps);
if (kind === "workspace") await retries.finalizeWorkspaceBusyDeferral(run, deferral());
else await expect(retries.finalizeAiConnectionBusyDeferral(run, new HttpError(409, "busy"), true)).rejects.toThrow("release failed");
expect(deps.setWakeupStatus).toHaveBeenCalledWith("wake", "cancelled", expect.any(Object));
expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(run);
expect(deps.finalizeAgentStatus).toHaveBeenCalledWith("agent", "cancelled", null, { wasFirstHeartbeat: true });
expect(deps.finalizeAgentStatus).toHaveBeenCalledAfter(deps.releaseIssueExecutionAndPromote);
expect(database.access).not.toHaveBeenCalled();
});
});
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Skipping retry PostgreSQL tests: ${support.reason ?? "unsupported host"}`);
describePostgres("heartbeat retry module database wiring", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
const now = new Date("2026-10-08T12:00:00Z");
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("heartbeat-retries-");
db = createDb(database.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await database?.cleanup(); });
afterEach(async () => {
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agents);
await db.delete(companies);
});
function boundRetries() {
const state = createHeartbeatRunState(db);
const deps = {
...callbacks(), getRun: state.getRun, getAgent: state.getAgent,
resolveSessionBeforeForWakeup: state.resolveSessionBeforeForWakeup,
getAgentInvokability: (agent: Agent | null | undefined) => evaluateAgentInvokabilityFromDb(db, agent),
runDispatch: createRunDispatch(db),
};
return { retries: extracted.createHeartbeatRetries(db, deps), deps };
}
async function fixture() {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Retries", issuePrefix: `R${companyId.slice(0, 8)}` });
const [agent] = await db.insert(agents).values({ companyId, name: "Retry agent", adapterType: "codex_local", status: "active" }).returning();
const [issue] = await db.insert(issues).values({ companyId, title: "Retry task", status: "in_progress", assigneeAgentId: agent.id }).returning();
const [run] = await db.insert(heartbeatRuns).values({
companyId, agentId: agent.id, scopeKind: "issue", issueId: issue.id,
status: "failed", errorCode: "codex_transient_upstream", finishedAt: now,
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
contextSnapshot: { issueId: issue.id },
}).returning();
await db.update(issues).set({ executionRunId: run.id, checkoutRunId: run.id }).where(eq(issues.id, issue.id));
return { companyId, agent, issue, run };
}
it("reuses one successor across concurrent factories and keeps writes committed before callbacks", async () => {
const { run, agent, issue, companyId } = await fixture();
const first = boundRetries(), second = boundRetries();
for (const binding of [first, second]) {
binding.deps.appendRunEvent.mockImplementation(async (_source, event) => {
const retryId = event.payload?.retryRunId;
expect(typeof retryId).toBe("string");
expect(await binding.deps.getRun(retryId as string)).toMatchObject({ retryOfRunId: run.id });
});
}
const results = await Promise.all([
first.retries.scheduleBoundedRetryForRun(run, agent, { now, random: () => 0 }),
second.retries.scheduleBoundedRetryForRun(run, agent, { now, random: () => 0 }),
]);
expect(results.every((result) => result.outcome === "scheduled")).toBe(true);
const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.retryOfRunId, run.id)));
expect(successors).toHaveLength(1);
const successor = successors[0];
expect(successor).toMatchObject({
status: "scheduled_retry", issueId: issue.id, responsibleUserId: "retry-user",
scheduledRetryAttempt: 1, scheduledRetryReason: "transient_failure",
});
expect(successor.contextSnapshot).toMatchObject({ retryOfRunId: run.id, codexTransientFallbackMode: "same_session" });
expect(first.deps.resolveResponsibleUserIdForRunContext).toHaveBeenCalledWith(run, expect.objectContaining({ retryOfRunId: run.id }));
const [locked] = await db.select().from(issues).where(eq(issues.id, issue.id));
expect(locked).toMatchObject({ executionRunId: successor.id, checkoutRunId: null });
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, successor.id))).toHaveLength(1);
expect(await boundRetries().retries.scheduleBoundedRetry(run.id, { now })).toMatchObject({ outcome: "scheduled", run: { id: successor.id }, reusedExisting: true });
expect(first.deps.appendRunEvent).toHaveBeenCalledTimes(1);
expect(second.deps.appendRunEvent).toHaveBeenCalledTimes(1);
});
it("promotes only due retries and publishes after commit", async () => {
const { run } = await fixture();
const { retries, deps } = boundRetries();
const scheduled = await retries.scheduleBoundedRetry(run.id, { now, delayMs: 60_000 });
expect(scheduled.outcome).toBe("scheduled");
if (scheduled.outcome !== "scheduled") throw new Error("Expected retry");
expect(await retries.promoteDueScheduledRetries(now)).toEqual({ promoted: 0, runIds: [] });
deps.applyRunDispatchPostCommitEffects.mockClear();
const committedStatuses: string[] = [];
const reads: Promise<void>[] = [];
deps.applyRunDispatchPostCommitEffects.mockImplementation(() => {
// Read outside the dispatch transaction after the callback is invoked.
// The assertion below waits for that read without making publication async.
reads.push(deps.getRun(scheduled.run.id).then((row) => { committedStatuses.push(row!.status); }));
});
expect(await retries.promoteDueScheduledRetries(scheduled.dueAt)).toEqual({ promoted: 1, runIds: [scheduled.run.id] });
await Promise.all(reads);
expect(committedStatuses).toEqual(["queued"]);
expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledWith([expect.objectContaining({ kind: "run_queued", runId: scheduled.run.id })]);
});
it("binds retry-now to the requested issue and persists its actor before promotion", async () => {
const selected = await fixture(), other = await fixture();
const { retries, deps } = boundRetries();
const scheduled = await retries.scheduleBoundedRetry(selected.run.id, { now, delayMs: 60_000 });
const foreign = await retries.scheduleBoundedRetry(other.run.id, { now, delayMs: 60_000 });
if (scheduled.outcome !== "scheduled" || foreign.outcome !== "scheduled") throw new Error("Expected retries");
expect(await retries.retryScheduledRetryNow({ issueId: selected.issue.id, actor: { actorType: "user", actorId: "retry-user" }, now })).toMatchObject({
outcome: "promoted", scheduledRetry: { runId: scheduled.run.id, status: "queued" },
});
expect(await deps.getRun(scheduled.run.id)).toMatchObject({ contextSnapshot: {
retryNowRequestedByActorType: "user", retryNowRequestedByActorId: "retry-user", retryNowRequestedAt: now.toISOString(),
} });
expect(await deps.getRun(foreign.run.id)).toMatchObject({ status: "scheduled_retry", scheduledRetryAt: foreign.dueAt });
expect(await retries.retryScheduledRetryNow({ issueId: selected.issue.id, now })).toMatchObject({ outcome: "already_promoted" });
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,222 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import {
agents,
agentTaskSessions,
companies,
createDb,
heartbeatRuns,
issues,
} from "@paperclipai/db";
import { eq, type SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "../../__tests__/helpers/embedded-postgres.js";
import * as legacy from "../heartbeat.js";
import * as extracted from "./run-state.js";
// SQL_ASCII needs a separate database cluster. Inspect its selected SQL here;
// the PostgreSQL tests below exercise the session queries and transactions.
function readingDatabase(serverEncoding: string, ...results: unknown[][]) {
const scopes: unknown[][] = [];
const columns: Record<string, unknown>[] = [];
const execute = vi.fn(async () => [{ server_encoding: serverEncoding }]);
const select = vi.fn((projection: Record<string, unknown> = {}) => {
columns.push(projection);
const rows = results.shift() ?? [];
const query = {
from: () => query,
where: (condition: SQL) => {
scopes.push(new PgDialect().sqlToQuery(condition).params);
return query;
},
orderBy: () => query,
limit: () => query,
then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(rows).then(resolve),
};
return query;
});
return { db: { execute, select } as unknown as Db, execute, select, columns, scopes };
}
describe("heartbeat run state module", () => {
it("preserves the legacy public helper identities", () => {
expect(legacy.resolveNextSessionState).toBe(extracted.resolveNextSessionState);
expect(legacy.buildExplicitResumeSessionOverride).toBe(extracted.buildExplicitResumeSessionOverride);
expect(legacy.normalizeAdapterRunUsage).toBe(extracted.normalizeAdapterRunUsage);
expect(legacy.resolveLedgerScopeForRun).toBe(extracted.resolveLedgerScopeForRun);
expect(legacy.summarizeHeartbeatRunListResultJson).toBe(extracted.summarizeHeartbeatRunListResultJson);
});
it("constructs without queries and binds session lookups to each database", async () => {
const first = readingDatabase("UTF8", [{ sessionDisplayId: "first" }]);
const second = readingDatabase("UTF8", [{ sessionDisplayId: "second" }]);
const firstState = extracted.createHeartbeatRunState(first.db);
const secondState = extracted.createHeartbeatRunState(second.db);
expect(first.select).not.toHaveBeenCalled();
expect(first.execute).not.toHaveBeenCalled();
expect(second.select).not.toHaveBeenCalled();
expect(second.execute).not.toHaveBeenCalled();
const [one, two] = await Promise.all([
firstState.getTaskSession("company-one", "agent-one", "codex_local", "task-one"),
secondState.getTaskSession("company-two", "agent-two", "claude_local", "task-two"),
]);
expect(one?.sessionDisplayId).toBe("first");
expect(two?.sessionDisplayId).toBe("second");
expect(first.scopes[0]).toEqual(["company-one", "agent-one", "codex_local", "task-one"]);
expect(second.scopes[0]).toEqual(["company-two", "agent-two", "claude_local", "task-two"]);
});
it("keeps encoding caches local to each factory and preserves full-result reads", async () => {
const ascii = readingDatabase("SQL_ASCII");
const utf8 = readingDatabase("UTF8");
const asciiState = extracted.createHeartbeatRunState(ascii.db);
const utf8State = extracted.createHeartbeatRunState(utf8.db);
await Promise.all([asciiState.getRun("ascii-one"), asciiState.getRun("ascii-two"), utf8State.getRun("utf8")]);
const projectedSql = (column: unknown) => new PgDialect().sqlToQuery((column as SQL.Aliased).sql).sql;
expect(ascii.execute).toHaveBeenCalledTimes(1);
expect(utf8.execute).toHaveBeenCalledTimes(1);
expect(projectedSql(ascii.columns[0].resultJson)).toBe("NULL");
expect(projectedSql(utf8.columns[0].resultJson)).toContain("pg_column_size");
await asciiState.getRun("evidence", { includeExecutionEvidence: true });
expect(projectedSql(ascii.columns[2].resultJson)).toContain("startupPreparationSettledAt");
const raw = readingDatabase("SQL_ASCII");
await extracted.createHeartbeatRunState(raw.db).getRun("raw", { unsafeFullResultJson: true });
expect(raw.execute).not.toHaveBeenCalled();
expect(raw.columns[0].resultJson).toBe(heartbeatRuns.resultJson);
});
});
const postgresSupport = await getEmbeddedPostgresTestSupport();
const describePostgres = postgresSupport.supported ? describe : describe.skip;
if (!postgresSupport.supported) {
console.warn(`Skipping run-state PostgreSQL tests: ${postgresSupport.reason ?? "unsupported host"}`);
}
describePostgres("heartbeat run state database wiring", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("heartbeat-run-state-");
db = createDb(database.connectionString);
}, 20_000);
afterAll(async () => {
await database?.cleanup();
});
async function fixture() {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Run state", issuePrefix: `S${companyId.slice(0, 8)}` });
const [agent] = await db.insert(agents).values({
companyId, name: "Session agent", adapterType: "codex_local",
}).returning();
const [issue] = await db.insert(issues).values({ companyId, title: "Session task", assigneeAgentId: agent.id }).returning();
return { companyId, agent, issue, state: extracted.createHeartbeatRunState(db) };
}
it("resumes only the selected agent's run and task session", async () => {
const { companyId, agent, issue, state } = await fixture();
const foreign = await fixture();
const [run] = await db.insert(heartbeatRuns).values({
companyId, agentId: agent.id, invocationSource: "assignment", status: "failed",
contextSnapshot: { issueId: issue.id }, sessionIdAfter: "our-session",
}).returning();
const [foreignRun] = await db.insert(heartbeatRuns).values({
companyId: foreign.companyId, agentId: foreign.agent.id, invocationSource: "assignment",
contextSnapshot: { issueId: foreign.issue.id }, sessionIdAfter: "foreign-session",
}).returning();
await state.upsertTaskSession({
companyId, agentId: agent.id, adapterType: agent.adapterType, taskKey: issue.id,
sessionParamsJson: { sessionId: "our-session", cwd: "/saved-workspace" },
sessionDisplayId: "our-session", lastRunId: run.id, lastError: null,
});
expect(await state.resolveExplicitResumeSessionOverride(agent, { resumeFromRunId: foreignRun.id }, issue.id)).toBeNull();
expect(await state.resolveExplicitResumeSessionOverride(agent, { resumeFromRunId: run.id }, issue.id)).toMatchObject({
resumeFromRunId: run.id, issueId: issue.id, taskKey: issue.id,
sessionDisplayId: "our-session", sessionParams: { sessionId: "our-session", cwd: "/saved-workspace" },
});
expect(await state.resolveSessionBeforeForWakeup(agent, issue.id)).toBe("our-session");
expect(await state.getRuntimeStateWithSessions(agent.id)).toMatchObject({
companyId, sessionDisplayId: "our-session", sessionParamsJson: { sessionId: "our-session" },
});
expect((await state.listRuns(companyId)).map((row) => row.id)).toEqual([run.id]);
});
it("subtracts prior cumulative usage while excluding the current run", async () => {
const { companyId, agent, state } = await fixture();
await db.insert(heartbeatRuns).values({
companyId, agentId: agent.id, invocationSource: "assignment", sessionIdAfter: "cumulative",
createdAt: new Date("2026-01-01T00:00:00Z"),
usageJson: { rawInputTokens: 800, rawCachedInputTokens: 100, rawOutputTokens: 80 },
});
const [current] = await db.insert(heartbeatRuns).values({
companyId, agentId: agent.id, invocationSource: "assignment", sessionIdAfter: "cumulative",
createdAt: new Date("2026-01-02T00:00:00Z"),
usageJson: { rawInputTokens: 1000, rawCachedInputTokens: 125, rawOutputTokens: 100 },
}).returning();
const rawUsage = { inputTokens: 1000, cachedInputTokens: 125, outputTokens: 100 };
const input = { agentId: agent.id, runId: current.id, sessionId: "cumulative", rawUsage };
expect(await state.resolveNormalizedUsageForSession({ ...input, usageBasis: "session_cumulative" })).toEqual({
normalizedUsage: { inputTokens: 200, cachedInputTokens: 25, outputTokens: 20 },
previousRawUsage: { inputTokens: 800, cachedInputTokens: 100, outputTokens: 80 },
derivedFromSessionTotals: true,
});
expect(await state.resolveNormalizedUsageForSession({ ...input, usageBasis: "per_run" })).toEqual({
normalizedUsage: rawUsage, previousRawUsage: null, derivedFromSessionTotals: false,
});
});
it("uses the selected session's usage and summary for compaction", async () => {
const { companyId, agent, issue, state } = await fixture();
const [run] = await db.insert(heartbeatRuns).values({
companyId, agentId: agent.id, invocationSource: "assignment", sessionIdAfter: "compact",
usageJson: { rawInputTokens: 90, rawCachedInputTokens: 20, rawInputIncludesCached: false },
resultJson: { summary: "Continue our task", providerPrivateData: "omit this" },
}).returning();
const foreign = await fixture();
await db.insert(heartbeatRuns).values({
companyId: foreign.companyId, agentId: foreign.agent.id, invocationSource: "assignment",
sessionIdAfter: "compact", resultJson: { summary: "Foreign task" },
});
const decision = await state.evaluateSessionCompaction({
agent: { ...agent, runtimeConfig: { heartbeat: { sessionCompaction: { maxRawInputTokens: 100 } } } },
sessionId: "compact", issueId: issue.id,
});
expect(decision).toMatchObject({ rotate: true, reason: "session raw input reached 110 tokens (threshold 100)", previousRunId: run.id });
expect(decision.handoffMarkdown).toContain("Continue our task");
expect(decision.handoffMarkdown).not.toContain("Foreign task");
expect(decision.handoffMarkdown).not.toContain("omit this");
});
it.each(["superseded", "cancelled"] as const)("fences %s conversation session writes and clears", async (scenario) => {
const { companyId, agent, issue, state } = await fixture();
await db.update(issues).set({
conversationAgentId: agent.id, conversationUserId: "test-user", conversationState: "active",
conversationSessionGeneration: 2,
}).where(eq(issues.id, issue.id));
const [run] = await db.insert(heartbeatRuns).values({
companyId, agentId: agent.id, invocationSource: "assignment", status: "succeeded",
contextSnapshot: { conversationSessionGeneration: 2 },
}).returning();
const input = {
companyId, agentId: agent.id, adapterType: agent.adapterType, taskKey: issue.id,
sessionParamsJson: { sessionId: "retained" }, sessionDisplayId: "retained", lastRunId: run.id, lastError: null,
};
const saved = await state.upsertTaskSession(input);
expect(saved?.sessionDisplayId).toBe("retained");
if (scenario === "superseded") {
await db.update(issues).set({ conversationSessionGeneration: 3 }).where(eq(issues.id, issue.id));
} else {
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, run.id));
}
expect(await state.upsertTaskSession({ ...input, sessionDisplayId: "stale" })).toBeNull();
expect(await state.clearTaskSessions(companyId, agent.id, { taskKey: issue.id, expectedRunId: run.id })).toBe(0);
const [retained] = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, saved!.id));
expect(retained.sessionDisplayId).toBe("retained");
});
});
File diff suppressed because it is too large. Load diff
+18 -3
View File
@@ -17,6 +17,11 @@ export interface IdleSleepSafety {
backgroundWork: "none" | "present" | "unknown";
}
export type InspectIdlePlugins = (hold: { ownerId: string; expiresAt: number }) => Promise<{
backgroundWork: "none" | "present" | "unknown";
pluginIds: string[];
}>;
// These are deliberately conservative. Completed agent runs and workspace
// operations are history, not reasons to keep an otherwise idle instance up.
// Other background features remain awake until their work and inbound events
@@ -91,18 +96,28 @@ export async function readIdleSleepSafety(
now: () => number = Date.now,
ownerId?: string,
inspectLocalWork: () => Promise<IdleLocalWork> = readIdleLocalWork,
inspectPlugins?: InspectIdlePlugins,
): Promise<IdleSleepSafety> {
const unknown: IdleSleepSafety = { version: 1, backgroundWork: "unknown" };
const before = getDrainStatus();
const localBefore = idleWorkSnapshot();
if (!sameQuietHold(before, before, now())) return unknown;
try {
let quietPlugins: string[] = [];
if (inspectPlugins) {
if (!ownerId || ownerId !== before.ownerId || before.expiresAt === null || localBefore.active !== 0) return unknown;
const pluginWork = await inspectPlugins({ ownerId, expiresAt: before.expiresAt.getTime() });
if (pluginWork.backgroundWork !== "none") return { version: 1, backgroundWork: pluginWork.backgroundWork };
quietPlugins = pluginWork.pluginIds;
}
const blocked = await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
const checks = WORK_CHECKS.map((query) => sql`EXISTS (${sql.raw(query)})`);
// Version labels and manifest declarations do not prove arbitrary worker
// code has no background activity. No plugin approvals are supported.
checks.push(sql`EXISTS (SELECT 1 FROM plugins WHERE status <> 'disabled')`);
// Only the current worker's owned runtime drain can exempt a plugin.
// Missing workers, old SDKs, and newly installed plugins still block.
checks.push(quietPlugins.length === 0
? sql`EXISTS (SELECT 1 FROM plugins WHERE status <> 'disabled')`
: sql`EXISTS (SELECT 1 FROM plugins WHERE status <> 'disabled' AND id NOT IN (${sql.join(quietPlugins.map((id) => sql`${id}::uuid`), sql`, `)}))`);
const rows = await tx.execute<{ blocked: boolean }>(sql`SELECT ${sql.join(checks, sql` OR `)} AS blocked`);
return rows.length === 1 && typeof rows[0]?.blocked === "boolean" ? rows[0].blocked : undefined;
}, { isolationLevel: "repeatable read", accessMode: "read only" });
+55 -25
View File
@@ -25,6 +25,7 @@ import {
isNull,
like,
lt,
lte,
ne,
notExists,
notInArray,
@@ -7951,35 +7952,64 @@ export function issueService(db: Db) {
},
dbOrTx: any = db,
) {
const now = new Date();
const [row] = await dbOrTx
.insert(issueInboxArchives)
.values({
companyId,
issueId,
userId,
archivedByActorType: attribution?.archivedByActorType ?? "user",
archivedByAgentId: attribution?.archivedByAgentId ?? null,
archivedByRunId: attribution?.archivedByRunId ?? null,
archivedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
issueInboxArchives.companyId,
issueInboxArchives.issueId,
issueInboxArchives.userId,
],
set: {
archivedAt,
const runArchive = async (tx: typeof dbOrTx) => {
// Completion locks the issue before archiving. Take the FK's parent lock
// first too, or an insert can hold the archive key while waiting on that
// issue and deadlock with completion's archive UPSERT. SHARE also holds
// companyId stable; different users can still archive concurrently.
const [issue] = await tx
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
.for("share");
if (!issue) throw notFound("Issue not found");
const now = new Date();
const [row] = await tx
.insert(issueInboxArchives)
.values({
companyId,
issueId,
userId,
archivedByActorType: attribution?.archivedByActorType ?? "user",
archivedByAgentId: attribution?.archivedByAgentId ?? null,
archivedByRunId: attribution?.archivedByRunId ?? null,
archivedAt,
updatedAt: now,
},
})
.returning();
return row;
})
.onConflictDoUpdate({
target: [
issueInboxArchives.companyId,
issueInboxArchives.issueId,
issueInboxArchives.userId,
],
set: {
archivedAt,
archivedByActorType: attribution?.archivedByActorType ?? "user",
archivedByAgentId: attribution?.archivedByAgentId ?? null,
archivedByRunId: attribution?.archivedByRunId ?? null,
updatedAt: now,
},
// A request that waited behind completion must not replace the newer
// archive with its earlier request time and resurface the done task.
setWhere: lte(issueInboxArchives.archivedAt, archivedAt),
})
.returning();
if (row) return row;
// ON CONFLICT holds this row lock even when setWhere skips the update.
// Return the newer state, including its matching actor attribution.
const [existing] = await tx
.select()
.from(issueInboxArchives)
.where(and(
eq(issueInboxArchives.companyId, companyId),
eq(issueInboxArchives.issueId, issueId),
eq(issueInboxArchives.userId, userId),
));
if (!existing) throw new Error("Inbox archive conflict row missing");
return existing;
};
return dbOrTx === db ? db.transaction(runArchive) : runArchive(dbOrTx);
}
const service = {
+115 -2
View File
@@ -20,6 +20,7 @@
import { fork, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { beginIdleTrackedWork, readTaskDrain, trackIdleWork } from "./task-admission.js";
import { EventEmitter } from "node:events";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
@@ -676,6 +677,8 @@ interface ExecuteLogRoute {
* with exponential backoff automatically when `autoRestart` is enabled.
*/
export interface PluginWorkerHandle {
prepareIdleSleep?(hold: { ownerId: string; expiresAt: number }): Promise<"none" | "present" | "unknown">;
releaseIdleSleep?(): void;
/** The plugin ID this worker serves. */
readonly pluginId: string;
@@ -793,6 +796,10 @@ export interface WorkerDiagnostics {
* for starting/stopping all workers and routing RPC calls.
*/
export interface PluginWorkerManager {
inspectIdleSleep?(hold: { ownerId: string; expiresAt: number }): Promise<{
backgroundWork: "none" | "present" | "unknown"; pluginIds: string[];
}>;
releaseIdleSleep?(): void;
/**
* Register and start a worker for a plugin.
*
@@ -888,6 +895,30 @@ export function createPluginWorkerHandle(
// Pending RPC requests awaiting a response
const pendingRequests = new Map<string | number, PendingRequest>();
// Unlike RPC promises, these survive timeouts. A late response is the
// completion receipt. A worker crash cannot manufacture one.
const unsettledCalls = new Map<string | number, () => void>();
let activeHostHandlers = 0;
let idleHold: { ownerId: string; expiresAt: number; ready: boolean } | null = null;
function releaseIdleSleep(): void {
const prior = idleHold;
idleHold = null;
if (prior && childProcess?.stdin?.writable) {
try {
sendMessage({ jsonrpc: JSONRPC_VERSION, method: "releaseIdleSleep", params: { ownerId: prior.ownerId } });
} catch { /* Worker-side expiry still bounds a lost release. */ }
}
}
function idleSleepHeld(): boolean {
if (!idleHold) return false;
const drain = readTaskDrain(new Date());
if (drain?.ownerId !== idleHold.ownerId || drain.expiresAt?.getTime() !== idleHold.expiresAt) {
releaseIdleSleep();
}
return idleHold !== null;
}
let nextRequestId = 1;
const activeInvocations = new Map<string, ActiveInvocation>();
// Host-owned execute routes, keyed by the host-issued invocation id. Only an
@@ -1020,6 +1051,8 @@ export function createPluginWorkerHandle(
function setStatus(newStatus: WorkerStatus): void {
const prev = status;
if (prev === newStatus) return;
const done = beginIdleTrackedWork();
done();
status = newStatus;
log.debug({ from: prev, to: newStatus }, "worker status change");
emitter.emit("status", { pluginId, status: newStatus, previousStatus: prev });
@@ -1093,6 +1126,8 @@ export function createPluginWorkerHandle(
return;
}
unsettledCalls.get(id)?.();
unsettledCalls.delete(id);
const pending = pendingRequests.get(id);
if (!pending) {
log.warn({ id }, "received response for unknown request id");
@@ -2695,6 +2730,23 @@ export function createPluginWorkerHandle(
* Handle a JSON-RPC request from the worker (worker→host call).
*/
async function handleWorkerRequest(request: JsonRpcRequest): Promise<void> {
const done = beginIdleTrackedWork();
activeHostHandlers++;
try {
// A queued notification may already be running in the worker while its
// prepare request is in flight. Let its writes finish until that worker
// has positively acknowledged that all accepted handlers have drained.
if (idleSleepHeld() && idleHold?.ready) {
try {
sendMessage(createErrorResponse(request.id, PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, "Plugin is held for idle sleep"));
} catch { /* Worker may have exited after sending its request. */ }
return;
}
await dispatchWorkerRequest(request);
} finally { activeHostHandlers--; done(); }
}
async function dispatchWorkerRequest(request: JsonRpcRequest): Promise<void> {
const method = request.method as WorkerToHostMethodName;
const handler = options.hostHandlers[method] as
| ((params: unknown, context?: WorkerHostCallContext) => Promise<unknown>)
@@ -3072,6 +3124,12 @@ export function createPluginWorkerHandle(
backoffTimer = setTimeout(async () => {
backoffTimer = null;
nextRestartAt = null;
// A temporary idle hold must not consume the crash-recovery attempt.
// Keep the bounded backoff until admission reopens.
if (readTaskDrain(new Date())?.ownerId) {
scheduleRestart();
return;
}
try {
await startInternal();
} catch (err) {
@@ -3293,6 +3351,11 @@ export function createPluginWorkerHandle(
executeLogSink?: ExecuteLogSink,
): Promise<HostToWorkerMethods[M][1]> {
const rpcPromise = new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
const idleControl = method === "prepareIdleSleep" || method === "releaseIdleSleep";
if (!idleControl && method !== "shutdown" && idleSleepHeld()) {
reject(new Error("Plugin is held for idle sleep"));
return;
}
if (!childProcess?.stdin?.writable) {
reject(
new Error(
@@ -3303,6 +3366,7 @@ export function createPluginWorkerHandle(
}
const id = nextRequestId++;
if (!idleControl) unsettledCalls.set(id, beginIdleTrackedWork());
const timeout = resolveRpcCallTimeoutMs(timeoutMs, rpcTimeoutMs);
const invocationScope = deriveInvocationScope(method, params);
const invocation = invocationScope ? registerInvocation(invocationScope) : null;
@@ -3368,6 +3432,8 @@ export function createPluginWorkerHandle(
} catch (err) {
clearTimeout(timer);
pendingRequests.delete(id);
unsettledCalls.get(id)?.();
unsettledCalls.delete(id);
clearInvocation(invocation);
clearExecuteRoute(invocation?.id);
reject(
@@ -3394,6 +3460,38 @@ export function createPluginWorkerHandle(
// -----------------------------------------------------------------------
const handle: PluginWorkerHandle = {
releaseIdleSleep,
async prepareIdleSleep(hold) {
const drain = readTaskDrain(new Date());
if (drain?.ownerId !== hold.ownerId || drain.expiresAt?.getTime() !== hold.expiresAt ||
hold.expiresAt <= Date.now()) return "unknown";
// Clear a previous hold even when this idle worker received no ordinary
// call between two controller attempts.
idleSleepHeld();
if (status !== "running" || totalCrashes > 0) return "unknown";
if (!supportedMethods.includes("prepareIdleSleep")) return "present";
if (unsettledCalls.size || activeHostHandlers || loginPtyRoutesByHostRouteId.size ||
liveDuplexRoutes.size || openingDuplexRoutes.size || terminalDuplexRoutes.size) return "present";
if (idleHold && (idleHold.ownerId !== hold.ownerId || idleHold.expiresAt !== hold.expiresAt)) return "unknown";
const candidate = idleHold ??= { ...hold, ready: false };
try {
const result = await callInternal("prepareIdleSleep", hold, Math.min(5_000, hold.expiresAt - Date.now()));
if (!idleSleepHeld() || idleHold !== candidate || status !== "running" || unsettledCalls.size || activeHostHandlers ||
result.ownerId !== hold.ownerId || result.expiresAt !== hold.expiresAt) {
if (idleHold === candidate) releaseIdleSleep();
return "unknown";
}
if (result.backgroundWork === "none") {
candidate.ready = true;
return "none";
}
releaseIdleSleep();
return result.backgroundWork === "present" ? "present" : "unknown";
} catch {
if (idleHold === candidate) releaseIdleSleep();
return "unknown";
}
},
get pluginId() {
return pluginId;
},
@@ -3459,6 +3557,7 @@ export function createPluginWorkerHandle(
notify(method: string, params: unknown) {
if (status !== "running") return;
if (idleSleepHeld()) throw new Error("Plugin is held for idle sleep");
const invocationScope = deriveInvocationScope(method, params);
// Notifications have no response to settle on, so the invocation scope
// is GC'd by TTL. Call-path invocations are registered without a TTL and
@@ -3637,6 +3736,20 @@ export function createPluginWorkerManager(
);
return {
async inspectIdleSleep(hold) {
if (startupLocks.size) return { backgroundWork: "unknown", pluginIds: [] };
const snapshot = [...workers.entries()];
const results = await Promise.all(snapshot.map(async ([, worker]) =>
worker.prepareIdleSleep ? worker.prepareIdleSleep(hold) : "unknown"));
if (startupLocks.size || snapshot.length !== workers.size || snapshot.some(([id, worker]) => workers.get(id) !== worker || worker.status !== "running")) {
return { backgroundWork: "unknown", pluginIds: [] };
}
const backgroundWork = results.includes("present") ? "present" : results.includes("unknown") ? "unknown" : "none";
return { backgroundWork, pluginIds: backgroundWork === "none" ? snapshot.map(([id]) => id) : [] };
},
releaseIdleSleep() {
for (const worker of workers.values()) worker.releaseIdleSleep?.();
},
async startWorker(
pluginId: string,
options: WorkerStartOptions,
@@ -3690,7 +3803,7 @@ export function createPluginWorkerManager(
log.info({ pluginId }, "starting plugin worker");
// Set the lock before awaiting start() to prevent concurrent spawns
const startPromise = handle.start().then(() => handle).finally(() => {
const startPromise = trackIdleWork(handle.start()).then(() => handle).finally(() => {
startupLocks.delete(pluginId);
});
startupLocks.set(pluginId, startPromise);
@@ -3706,7 +3819,7 @@ export function createPluginWorkerManager(
}
log.info({ pluginId }, "stopping plugin worker");
await handle.stop();
await trackIdleWork(handle.stop());
workers.delete(pluginId);
},
+18
View File
@@ -12,6 +12,7 @@ import {
import { logger } from "../middleware/logger.js";
import { isUnexpectedRunCancellation } from "./run-cancellation.js";
import { readConnectionFailure, type ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure";
import { isAiConnectionConfigurationReason } from "./ai-connection-configuration-failure.js";
type HeartbeatRun = typeof heartbeatRuns.$inferSelect;
@@ -47,6 +48,22 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
/** An owned AI selection rejection before dispatch, not its broad catch wrapper. */
function isAiConnectionSelectionBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
// Resumed native input may already have provider effects, even during preparation.
if (run.runtimeMode === "native" && asRecord(run.runnerProfileJson)?.nativeExecutionInput !== undefined) return false;
if (
run.status !== "failed" || run.errorCode !== "configuration_incomplete" ||
run.executionStage !== "preparing" || options.phase !== "setup" ||
run.exitCode != null || run.signal != null
) return false;
const configuration = asRecord(run.resultJson?.configurationIncomplete);
const recovery = asRecord(run.resultJson?.executionRecovery);
return configuration?.reason === "ai_connection_unavailable" &&
isAiConnectionConfigurationReason(configuration.selectionFailure) &&
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** A known owner action before dispatch, not a secret-provider or runtime failure. */
function isMissingSecretConfigurationBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
if (
@@ -162,6 +179,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
if (!isRunFailureStatus(run.status)) return Promise.resolve();
if (run.status === "cancelled" && !isUnexpectedRunCancellation(run)) return Promise.resolve();
if (isMissingSecretConfigurationBlocker(run, options)) return Promise.resolve();
if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve();
if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve();
if (isGitConnectionFailure(run, options)) return Promise.resolve();
const runStatus = run.status;
+24
View File
@@ -1291,6 +1291,30 @@ contract tests separately verify that the advertised tool carries the documentat
for fresh and resumed native executions. This tests the current documentation
placement; it is not a statistical comparison with the former prompt arrangement.
The separate explicit-only `question-resume` suite uses the same neutral two-question
request on native Codex and ACPX Claude. It qualifies the user journey through
either the provider's built-in question or Paperclip's semantic question tool.
It does not replace or regrade `question-tool-documentation`.
The oracle classifies each wait from its exact task/agent/run identity. Provider
questions require the server-generated request key and retain the paused run.
Semantic questions require an applied `request_human_input` receipt and one new
response wake bound to that interaction and source run. Only a verified provider
choice may carry the adapter's optional Other field. A second substantive question
is still rejected; the subsequent reference question must be text-only. Both real
board answers must remain bound to unchanged forms, and one saved document must
include both answers. Existing lifecycle, ownership, premature-output, terminal
state and no-extra-task checks apply. Three checkpoints are required even when
both answers resume one run; missing native inputs or unexplained runs fail.
Select `question-resume.runner-acpx-claude.local.question-answer-resume` for the
bounded Claude qualification. The suite is excluded from `--all`; it allows one
attempt, one to three actual runs, a ten-minute cell deadline and verified
1,000-cent company/agent hard stops. The historical failure where the optional
Other field stopped the driver before any answer remains a FAIL. A new behavior
pass does not establish semantic-tool selection, documentation placement, default
hiring behavior or general reliability.
Continuation screenshots wait for the correct task heading and fully revealed
conversation before capture. A loading screen or wrong task fails capture.
Browser-only regressions exercise delayed rendering without provider calls:
+3 -3
View File
@@ -146,10 +146,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116,
4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116,
]);
expect(validateRunnerCatalog()).toHaveLength(722);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(722);
expect(validateRunnerCatalog()).toHaveLength(724);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(724);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(48);
+12 -1
View File
@@ -16,7 +16,7 @@ import { apiResponseReadingTask } from "./api-response-reading.js";
import { taskTitleTasks, taskTitleDefinitionDigest, TASK_TITLE_BUDGET_CENTS } from "./task-titles.js";
import { blockerTasks, blockerProfile } from "./blocker-cases.js";
import { accountingTasks } from "./accounting-cases.js";
import { continuationTasks } from "./continuation-cases.js";
import { continuationTasks, questionResumeTask } from "./continuation-cases.js";
import { contextIntegrityTasks, paperclipDocumentTask } from "./context-integrity-cases.js";
import { productionDefaultHireProfile, stockHarnessSourceDigest, stockHarnessSkillSources } from "./stock-harness.js";
import { lifecycleLiveTasks, lifecycleLiveDefinitionDigest } from "./lifecycle-live-cases.js";
@@ -1304,6 +1304,16 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
excludedExecutionIds: ["neutral", "challenge"].map(variant => `lifecycle-baseline.runner-codex.local.lifecycle-repair-${variant}`),
definitionMetadata: { version: 5, clarificationGrading: "wrapped-information-fields-list", narrativeDigest: lifecycleLiveDefinitionDigest, grading: "durable-state-and-attributed-narrative", scheduling: "explicit-only" },
},
{
id: "question-resume", label: "Native question and answer continuation", manualOnly: true,
description: "Two real UI answers, exact provider-pause or semantic-wake identity, and one saved document.",
groups: ["native", "local"], environments: [localEnvironment],
profiles: runnerProfiles.filter(p => ["runner-codex", "runner-acpx-claude"].includes(p.id)).map(productionStoryProfile),
tasks: [questionResumeTask], expectedMatrixSize: 2,
definitionMetadata: { version: 1, grading: "bound-question-path-and-two-durable-answers", instructions: "unchanged-production",
scheduling: "explicit-only", automaticRetryPolicy: "single_attempt", companyAndAgentBudgetCents: 1_000,
providerRuns: "1-3; each semantic answer starts one exactly bound wake; provider answers retain their paused run" },
},
{
id: "continuation", label: "Task continuation",
description: "Human direction, approval boundaries, untrusted evidence, and completed actions across turns.",
@@ -1714,6 +1724,7 @@ export function validateRunnerCatalog(): MatrixExecution[] {
...accountingTasks,
...lifecycleLiveTasks,
...continuationTasks,
questionResumeTask,
...everydayTasks,
...taskTitleTasks,
...runnerTasks,
+14 -3
View File
@@ -10,9 +10,9 @@ export const CONTINUATION_CASES = [
"question-tool-documentation",
"provider-question-bridge",
] as const;
export type ContinuationCase = (typeof CONTINUATION_CASES)[number];
export type ContinuationCase = (typeof CONTINUATION_CASES)[number] | "question-answer-resume";
export function continuationScenario(id: string, nonce: string) {
if (!CONTINUATION_CASES.includes(id as ContinuationCase))
if (id !== "question-answer-resume" && !CONTINUATION_CASES.includes(id as (typeof CONTINUATION_CASES)[number]))
throw new Error(`Unknown continuation case: ${id}`);
const marker = `AMBER${nonce.replace(/[^a-z0-9]/gi, "")}`;
const old = `COBALT${nonce.replace(/[^a-z0-9]/gi, "")}`;
@@ -21,9 +21,11 @@ export function continuationScenario(id: string, nonce: string) {
const childTitle = `Follow-up ${marker}`;
const document =
"Save the final note as a task document. No other deliverables or child tasks are needed.";
const questionPrompt = "Help me write a one-sentence welcome note for a club meetup. First let me choose Morning or Afternoon using clickable choices. After I choose, ask me for a reference to include using an open text field. Ask only one question at a time and wait for my answers. Then save the note as a task document, including the selected time and my reference exactly as supplied, and finish. Do not create any other tasks or deliverables.";
const prompts: Record<ContinuationCase, string> = {
"question-answer-resume": questionPrompt,
"provider-question-bridge": `Use your built-in AskUserQuestion tool (not Paperclip's request_human_input) to ask which reference to include, with two choices: ${marker} and ${old}. Wait for my real answer, then save a one-sentence welcome note including only my selected reference as a task document and finish. No other tasks or deliverables are needed.`,
"question-tool-documentation": `Help me write a one-sentence welcome note for a club meetup. First let me choose Morning or Afternoon using clickable choices. After I choose, ask me for a reference to include using an open text field. Ask only one question at a time and wait for my answers. Then save the note as a task document, including the selected time and my reference exactly as supplied, and finish. Do not create any other tasks or deliverables.`,
"question-tool-documentation": questionPrompt,
"answer-updates-scope": `I need a one-sentence welcome note containing ${old}. Before writing it, ask me one open-ended structured question about any changes I want. Then apply my answer and finish. ${document}`,
"clarification-not-approval": `I need a one-sentence welcome note. First ask me one open-ended structured question for the word to include. After my answer, propose your approach and wait for my explicit approval before writing the note. ${document}`,
"revision-preserves-approval": `Propose an approach for a one-sentence welcome note containing ${old}, and save that approach as the plan document. Wait for my explicit approval before writing the note. ${document}`,
@@ -85,3 +87,12 @@ export function continuationScreenshotFile(
? "final-state.png"
: `question-continuation-${phase}.png`;
}
/** Separate behavior contract; leave the semantic documentation case unchanged. */
export const questionResumeTask: RunnerTaskFixture = {
...continuationTasks.find(t => t.id === "question-tool-documentation")!,
id: "question-answer-resume", label: "Two answered questions across native paths",
expectedRunCount: 3, minimumExpectedRunCount: 1,
buildTitle: nonce => `Question resume ${nonce}`,
buildPrompt: nonce => continuationScenario("question-answer-resume", nonce).prompt,
};
+6 -3
View File
@@ -1,3 +1,4 @@
import { validResumeQuestionForm } from "./question-resume-scoring.js";
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
import { prepareLegacyContinuationSkill, prepareContinuationBudget } from "./continuation-fixtures.js";
@@ -106,7 +107,7 @@ export async function runContinuationFlow(input: {
return ready;
},
reject: (state) =>
state.runs.length > 12
state.runs.length > (scenario.id === "question-answer-resume" ? 3 : 12)
? "Bounded continuation run count exceeded"
: state.runs.some((r) =>
["failed", "timed_out", "cancelled"].includes(r.status),
@@ -185,7 +186,9 @@ export async function runContinuationFlow(input: {
);
expect(questions, "one real question must be shown").toHaveLength(1);
const set = chatQuestionPresentation(questions[0].payload);
if (scenario.id === "provider-question-bridge") {
if (scenario.id === "question-answer-resume") {
expect(validResumeQuestionForm(checkpoints.at(-1)!, questions[0], !!choice), "a verified native question path and the requested form").toBe(true);
} else if (scenario.id === "provider-question-bridge") {
expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true);
} else expect(set.questions, "ask only the requested next question").toHaveLength(1);
const before = new Set(runs.map((r) => r.id));
@@ -267,7 +270,7 @@ export async function runContinuationFlow(input: {
await input.restart();
await open();
}
if (scenario.id === "question-tool-documentation") {
if (["question-tool-documentation", "question-answer-resume"].includes(scenario.id)) {
await answer("Afternoon");
await snapshot("answered");
assertWaiting();
+3 -1
View File
@@ -1,3 +1,4 @@
import { gradeQuestionResume } from "./question-resume-scoring.js";
import { createHash } from "node:crypto";
import { gradeQuestionDocumentation } from "./question-documentation-scoring.js";
import type { ContinuationCase } from "./continuation-cases.js";
@@ -49,7 +50,7 @@ export function gradeContinuation(input: {
const before = input.checkpoints.filter((c) => c.phase !== "final");
check(
"recorded-continuation",
before.length > 0 && !!final && final.runs.length >= (input.id === "provider-question-bridge" ? 1 : 2),
before.length > 0 && !!final && final.runs.length >= (["provider-question-bridge", "question-answer-resume"].includes(input.id) ? 1 : 2),
"Initial and final turns must both be recorded.",
);
for (const c of before) {
@@ -135,5 +136,6 @@ export function gradeContinuation(input: {
"A real provider-native card must be answered and resume the same run to completion.");
}
if (input.id === "question-tool-documentation") checks.push(...gradeQuestionDocumentation(input.checkpoints, input.marker));
if (input.id === "question-answer-resume") checks.push(...gradeQuestionResume(input.checkpoints, input.marker));
return checks;
}
+131
View File
@@ -0,0 +1,131 @@
import { isDeepStrictEqual } from "node:util";
import type { ContinuationCheckpoint } from "./continuation-scoring.js";
import { isSingleClaudeQuestion } from "./runtime-question-readiness.js";
type Row = Record<string, any>;
export type QuestionPath = "provider" | "semantic";
const nonempty = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
const questions = (c?: ContinuationCheckpoint): Row[] =>
(c?.interactions ?? []).filter((i: any) => i.kind === "ask_user_questions") as Row[];
export const resumeQuestionFields = (i?: Row): Row[] =>
Array.isArray(i?.payload?.questionSet?.questions) ? i.payload.questionSet.questions : [];
/** Classify by server-owned identities and the applied semantic receipt. A
* missing receipt is unknown, never inferred from form shape or run count. */
export function questionPath(checkpoint: ContinuationCheckpoint | undefined, i?: Row): QuestionPath | null {
if (!checkpoint || !i || !nonempty(i.id) || !nonempty(i.sourceRunId) || !nonempty(i.idempotencyKey)) return null;
const matches = checkpoint.runs.filter(r => r.id === i.sourceRunId) as Row[];
const run = matches.length === 1 ? matches[0] : undefined;
if (!run || run.runtimeMode !== "native" || !nonempty(run.companyId) ||
run.companyId !== (checkpoint.issue as Row).companyId ||
i.companyId !== run.companyId || i.issueId !== checkpoint.issue.id || run.issueId !== i.issueId ||
!nonempty(checkpoint.issue.assigneeAgentId) || i.createdByAgentId !== checkpoint.issue.assigneeAgentId ||
run.agentId !== checkpoint.issue.assigneeAgentId) return null;
const request = i.payload?.runtimeRequestId;
if (request !== undefined) {
return nonempty(request) && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(request) &&
i.idempotencyKey === `paperclip-runner-question:${run.id}:${request}` &&
i.continuationPolicy === "none" && run.status === "running" ? "provider" : null;
}
const receipt = run.resultJson?.semanticToolReceipts?.[i.idempotencyKey];
const created = receipt?.result?.interaction;
return run.status === "succeeded" && i.continuationPolicy === "wake_assignee" &&
receipt?.operationId === "request_human_input" && receipt.result?.disposition === "applied" &&
created?.id === i.id && created?.sourceRunId === run.id && created?.issueId === i.issueId &&
created?.companyId === i.companyId && created?.kind === "ask_user_questions" &&
isDeepStrictEqual(created?.payload?.questionSet, i.payload?.questionSet) ? "semantic" : null;
}
export function validResumeQuestionForm(checkpoint: ContinuationCheckpoint, i: Row, choice: boolean): boolean {
const path = questionPath(checkpoint, i);
const fields = resumeQuestionFields(i);
if (!path || fields.some(f => !nonempty(f.id)) || new Set(fields.map(f => f.id)).size !== fields.length) return false;
if (!choice) return fields.length === 1 && fields[0].answerMode === "text" &&
!fields[0].options?.length && !fields[0].customAnswer;
return fields[0]?.answerMode === "single_select" &&
(fields.length === 1 || (path === "provider" && isSingleClaudeQuestion(fields)));
}
function sameQuestion(a: Row, b?: Row) {
return !!b && ["id", "kind", "sourceRunId", "issueId", "companyId", "createdByAgentId", "idempotencyKey", "continuationPolicy"]
.every(k => a[k] === b[k]) && isDeepStrictEqual(a.payload, b.payload);
}
function answeredByUser(i?: Row) {
return i?.status === "answered" && nonempty(i.resolvedByUserId) && !i.resolvedByAgentId;
}
function resumedRun(before: ContinuationCheckpoint | undefined, after: ContinuationCheckpoint | undefined, i?: Row) {
if (!before || !after || !i) return false;
const path = questionPath(before, i);
const prior = new Set(before.runs.map(r => r.id));
const next = after.runs.filter(r => !prior.has(r.id)) as Row[];
if (prior.size !== before.runs.length || new Set(after.runs.map(r => r.id)).size !== after.runs.length ||
before.runs.some(r => !after.runs.some(n => n.id === r.id))) return false;
if (path === "provider") return next.length === 0 &&
after.runs.some(r => r.id === i.sourceRunId && ["running", "succeeded"].includes(r.status));
if (path !== "semantic" || next.length !== 1) return false;
const context = next[0].contextSnapshot;
return context?.issueId === i.issueId && context?.sourceRunId === i.sourceRunId &&
context?.interactionId === i.id && context?.interactionKind === "ask_user_questions" &&
context?.interactionStatus === "answered";
}
/** Behavioral qualification supports either valid native path. It does not
* replace the separate documentation-placement/semantic-tool qualification. */
export function gradeQuestionResume(checkpoints: ContinuationCheckpoint[], marker: string) {
const initial = checkpoints.find(c => c.phase === "initial");
const middle = checkpoints.find(c => c.phase === "answered");
const final = checkpoints.find(c => c.phase === "final");
const first = questions(initial)[0];
const second = questions(middle).find(i => i.status === "pending");
const choice = resumeQuestionFields(first)[0];
const text = resumeQuestionFields(second)[0];
const options: Row[] = choice?.options ?? [];
const afternoon = options.find(o => /^afternoon\b/i.test(String(o.label).trim()));
const checks = [{
id: "question-resume.forms",
passed: !!initial && !!middle && questions(initial).length === 1 && first?.status === "pending" &&
validResumeQuestionForm(initial, first, true) && options.length >= 2 &&
options.every(o => nonempty(o.id)) && new Set(options.map(o => o.id)).size === options.length &&
options.some(o => /^morning\b/i.test(String(o.label).trim())) && !!afternoon &&
questions(middle).length === 2 && questions(middle).filter(i => i.status === "pending").length === 1 &&
nonempty(second?.id) && second?.id !== first?.id && validResumeQuestionForm(middle, second, false),
detail: "One choice plus only a verified provider Other companion, then a distinct text-only question.",
}];
for (const [before, after, i, field, answer] of [
[initial, middle, first, choice, "choice"], [middle, final, second, text, "text"],
] as const) {
const committed = questions(after).filter(q => q.id === i?.id);
const retained = questions(final).filter(q => q.id === i?.id);
const response: Row[] = committed[0]?.result?.answers ?? [];
const primary = response.filter(a => a.questionId === field?.id);
const optionalIds = resumeQuestionFields(i).slice(1).map(f => f.id);
const validAnswer = primary.length === 1 && (answer === "choice"
? primary[0].optionIds?.length === 1 && primary[0].optionIds[0] === afternoon?.id && !primary[0].otherText
: !primary[0].optionIds?.length && primary[0].otherText === `Use ${marker}.`);
checks.push({
id: `question-resume.${answer}.answer`,
passed: !!i && committed.length === 1 && retained.length === 1 &&
sameQuestion(i, committed[0]) && sameQuestion(i, retained[0]) && answeredByUser(committed[0]) &&
answeredByUser(retained[0]) && isDeepStrictEqual(committed[0].result, retained[0].result) &&
validAnswer && response.every(a => a.questionId === field?.id ||
(optionalIds.includes(a.questionId) && !a.optionIds?.length && !a.otherText)),
detail: "The real board answer must remain bound to the unchanged question, with no fabricated or substituted response.",
}, {
id: `question-resume.${answer}.path`,
passed: resumedRun(before, after, i),
detail: `Observed path: ${questionPath(before, i) ?? "unknown"}; provider answers resume the same run; semantic answers require one exactly bound response wake.`,
});
}
checks.push({
id: "question-resume.complete-evidence",
passed: checkpoints.length === 3 && !!initial && !!middle && !!final && initial.runs.length === 1 &&
questions(final).length === 2 && final.runs.length >= 1 && final.runs.length <= 3 &&
final.runs.every((r: Row) => r.companyId === (final.issue as Row).companyId &&
r.issueId === final.issue.id && r.agentId === final.issue.assigneeAgentId &&
typeof r.runnerProfileJson?.nativeExecutionInput?.task?.prompt === "string") &&
final.documents.length === 1 && !!final.documents[0].latestRevisionId &&
final.documents[0].body.includes(`Use ${marker}.`) && /\bafternoon\b/i.test(final.documents[0].body),
detail: "Retain all three checkpoints, every native input and exactly one saved document containing the choice and the complete submitted reference.",
});
return checks.map(c => ({ ...c, passed: Boolean(c.passed) }));
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { gradeQuestionResume, questionPath, validResumeQuestionForm, type QuestionPath } from "./question-resume-scoring.js";
import { gradeContinuation } from "./continuation-scoring.js";
import { gradeLifecycleBaseline } from "./lifecycle-baseline.js";
import { continuationScenario } from "./continuation-cases.js";
import { runnerMatrix } from "./catalog.js";
type Row = Record<string, any>;
function recording(paths: QuestionPath[] = ["provider", "semantic"]) {
const runs: Row[] = [];
const cards: Row[] = [];
const points: any[] = [];
const newRun = (previous?: Row) => ({ id: `run-${runs.length + 1}`, status: "running", runtimeMode: "native",
companyId: "company", issueId: "task", agentId: "agent", resultJson: { semanticToolReceipts: {} },
runnerProfileJson: { nativeExecutionInput: { task: { prompt: "actual native task input" } } },
contextSnapshot: previous ? { issueId: "task", sourceRunId: previous.sourceRunId,
interactionId: previous.id, interactionKind: "ask_user_questions", interactionStatus: "answered" } : {},
});
runs.push(newRun());
const snap = (phase: string, documents: Row[] = []) => {
const active = runs.find(r => r.status === "running");
points.push(structuredClone({ phase, issue: { id: "task", companyId: "company", assigneeAgentId: "agent",
status: phase === "final" ? "done" : active ? "in_progress" : "in_review" },
lifecycle: { executionRunId: active?.id ?? null, scheduledRetry: null, activeRecoveryAction: null, monitorNextCheckAt: null },
runs, interactions: cards, children: [], attachments: [], comments: [], documents }));
};
for (let n = 0; n < 2; n++) {
const run = runs.at(-1)!;
const provider = paths[n] === "provider";
const request = `request-${n}`;
const idempotencyKey = provider ? `paperclip-runner-question:${run.id}:${request}` : `question-${n}`;
const field = n === 0 ? { id: "field-1-question_0-hash", answerMode: "single_select", options: [
{ id: "am", label: "Morning" }, { id: "pm", label: "Afternoon" },
] } : { id: "reference", answerMode: "text" };
const questions: Row[] = [field];
if (provider && n === 0) questions.push({ id: "field-2-question_0_custom-hash", answerMode: "text", header: "Other", required: false });
const card: Row = { id: `card-${n}`, kind: "ask_user_questions", status: "pending",
companyId: "company", issueId: "task", createdByAgentId: "agent", sourceRunId: run.id, idempotencyKey,
continuationPolicy: provider ? "none" : "wake_assignee",
payload: { ...(provider ? { runtimeRequestId: request } : {}), questionSet: { questions } } };
cards.push(card);
if (!provider) {
run.status = "succeeded";
run.resultJson.semanticToolReceipts[idempotencyKey] = { operationId: "request_human_input",
result: { disposition: "applied", interaction: structuredClone(card) } };
}
snap(n === 0 ? "initial" : "answered");
card.status = "answered"; card.resolvedByUserId = "local-board";
card.result = { answers: [n === 0 ? { questionId: field.id, optionIds: ["pm"] } :
{ questionId: field.id, optionIds: [], otherText: "Use AMBERnonce." }] };
if (!provider) runs.push(newRun(card));
}
runs.at(-1)!.status = "succeeded";
snap("final", [{ key: "welcome-note", latestRevisionId: "revision", body: "Welcome to the afternoon meetup. Use AMBERnonce." }]);
return points;
}
const failed = (r: any[]) => gradeQuestionResume(r, "AMBERnonce").filter(c => !c.passed).map(c => c.id);
const question = (r: any[], n = 0) => r[n].interactions[n];
describe("native question/resume path qualification", () => {
it("keeps the new journey explicit-only and the original documentation cells intact", () => {
const cells = runnerMatrix.filter(c => c.suite.id === "question-resume");
expect(cells.map(c => c.profile.id).sort()).toEqual(["runner-acpx-claude", "runner-codex"]);
for (const c of cells) {
expect(c.suite.manualOnly).toBe(true);
expect(c.task).toMatchObject({ expectedRunCount: 3, minimumExpectedRunCount: 1, automaticRetryPolicy: "single_attempt" });
expect(c.task.buildPrompt("nonce")).toBe(continuationScenario("question-tool-documentation", "nonce").prompt);
}
expect(runnerMatrix.filter(c => c.suite.id === "continuation")).toHaveLength(23);
});
it.each([
["provider", "provider"], ["provider", "semantic"], ["semantic", "provider"], ["semantic", "semantic"],
] as QuestionPath[][])("accepts exactly bound %s then %s journeys", (a, b) => {
const r = recording([a,b]);
expect(failed(r)).toEqual([]);
expect(gradeContinuation({ ...continuationScenario("question-answer-resume", "nonce"), runtimeMode: "native", checkpoints: r }).filter(c => !c.passed)).toEqual([]);
expect(gradeLifecycleBaseline(r).filter(c => !c.passed)).toEqual([]);
});
it.each([
"Welcome to the afternoon meetup, AMBERnonce.",
"Welcome to the afternoon meetup. Use AMBERnonce",
])("rejects a shortened supplied reference in the saved document: %s", body => {
const r = recording();
r[2].documents[0].body = body;
expect(failed(r)).toContain("question-resume.complete-evidence");
});
it("does not admit an optional Other field on the semantic path", () => {
const r = recording(["semantic", "semantic"]);
const i = question(r);
i.payload.questionSet.questions.push({ id: "field-2-question_0_custom-hash", answerMode: "text", header: "Other", required: false });
expect(validResumeQuestionForm(r[0], i, true)).toBe(false);
});
it.each(["sourceRunId", "issueId", "companyId", "createdByAgentId", "idempotencyKey"])("rejects a provider card with mismatched %s", key => {
const r = recording(); question(r)[key] = "unrelated";
expect(failed(r)).toContain("question-resume.choice.path");
});
it("rejects a consistent foreign-company card/run pair on this task", () => {
const r = recording(); question(r).companyId = "foreign"; r[0].runs[0].companyId = "foreign";
expect(questionPath(r[0], question(r))).toBeNull();
});
it.each(["missing-request", "wrong-request", "terminal-run", "wrong-policy", "legacy-run"])("rejects invalid provider binding: %s", kind => {
const r = recording(); const i = question(r);
if (kind === "missing-request") delete i.payload.runtimeRequestId;
if (kind === "wrong-request") i.payload.runtimeRequestId = "unrelated";
if (kind === "terminal-run") r[0].runs[0].status = "succeeded";
if (kind === "wrong-policy") i.continuationPolicy = "wake_assignee";
if (kind === "legacy-run") r[0].runs[0].runtimeMode = "legacy";
expect(questionPath(r[0], i)).toBeNull();
});
it.each(["missing", "wrong-operation", "rejected", "wrong-interaction", "wrong-source", "wrong-form"])("rejects missing or mismatched semantic receipt: %s", kind => {
const r = recording(["semantic", "semantic"]); const i = question(r);
const receipt = r[0].runs[0].resultJson.semanticToolReceipts[i.idempotencyKey];
if (kind === "missing") delete r[0].runs[0].resultJson.semanticToolReceipts[i.idempotencyKey];
if (kind === "wrong-operation") receipt.operationId = "write_document";
if (kind === "rejected") receipt.result.disposition = "rejected";
if (kind === "wrong-interaction") receipt.result.interaction.id = "unrelated";
if (kind === "wrong-source") receipt.result.interaction.sourceRunId = "unrelated";
if (kind === "wrong-form") receipt.result.interaction.payload.questionSet.questions = [];
expect(questionPath(r[0], i)).toBeNull();
});
it.each(["required-other", "extra-question", "text-with-options", "two-early-cards"])("rejects the wrong form: %s", kind => {
const r = recording(); const fields = question(r).payload.questionSet.questions;
if (kind === "required-other") fields[1].required = true;
if (kind === "extra-question") fields.push({ id: "extra", answerMode: "text" });
if (kind === "text-with-options") question(r,1).payload.questionSet.questions[0].options = [{ id: "other" }];
if (kind === "two-early-cards") r[0].interactions.push(structuredClone(question(r,1)));
expect(failed(r)).toContain("question-resume.forms");
});
it.each(["agent-answer", "wrong-choice", "wrong-text", "duplicate-answer", "replaced-card", "changed-form", "changed-answer", "unanswered"])("rejects corrupt answer evidence: %s", kind => {
const r = recording(); const first = r[1].interactions[0]; const last = r[2].interactions[1];
if (kind === "agent-answer") { first.resolvedByUserId = null; first.resolvedByAgentId = "agent"; }
if (kind === "wrong-choice") first.result.answers[0].optionIds = ["am"];
if (kind === "wrong-text") last.result.answers[0].otherText = "wrong";
if (kind === "duplicate-answer") first.result.answers.push(structuredClone(first.result.answers[0]));
if (kind === "replaced-card") last.id = "replacement";
if (kind === "changed-form") last.payload.questionSet.questions[0].prompt = "Changed question";
if (kind === "changed-answer") r[2].interactions[0].result.answers[0].optionIds = ["am"];
if (kind === "unanswered") last.status = "pending";
expect(failed(r).some(id => id.endsWith(".answer"))).toBe(true);
});
it("rejects replacing a paused provider run with a fresh run", () => {
const r = recording(); r[1].runs.push({ ...structuredClone(r[1].runs[0]), id: "replacement" });
expect(failed(r)).toContain("question-resume.choice.path");
});
it.each(["interactionId", "sourceRunId", "issueId", "interactionStatus"])("rejects the wrong semantic response wake %s", key => {
const r = recording(); r[2].runs[1].contextSnapshot[key] = "unrelated";
expect(failed(r)).toContain("question-resume.text.path");
});
it.each(["lost-run", "duplicate-run", "extra-run", "missing-input", "missing-final", "missing-middle", "missing-output", "wrong-output"])("rejects incomplete continuation evidence: %s", kind => {
const r = recording();
if (kind === "lost-run") r[2].runs.shift();
if (kind === "duplicate-run") r[2].runs.push(structuredClone(r[2].runs[0]));
if (kind === "extra-run") r[2].runs.push({ ...structuredClone(r[2].runs[0]), id: "extra" });
if (kind === "missing-input") delete r[2].runs[0].runnerProfileJson;
if (kind === "missing-output") r[2].documents = [];
if (kind === "wrong-output") r[2].documents[0].body = "Welcome AMBERnonce.";
if (kind === "missing-final") r.pop();
if (kind === "missing-middle") r.splice(1,1);
expect(failed(r).length).toBeGreaterThan(0);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+6
View File
@@ -140,6 +140,12 @@
"catalogVisible": true,
"localAsset": "/brands/apps/executor.png"
},
{
"slug": "gauge",
"provider": "Gauge",
"catalogVisible": true,
"localAsset": "/brands/apps/gauge.png"
},
{
"slug": "github",
"provider": "GitHub",
@@ -2728,6 +2728,30 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("renders reviewer and approver controls for a monitor-only execution policy", async () => {
const policy = {
monitor: {
nextCheckAt: "2026-10-08T16:00:00.000Z",
notes: "Check pull request",
scheduledBy: "assignee",
kind: "external_service",
serviceName: "github-pr",
},
} as IssueExecutionPolicy;
const root = renderProperties(container, {
issue: createIssue({ executionPolicy: policy }),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(findRowTrigger(container, "Reviewers")?.textContent).toContain("None");
expect(findRowTrigger(container, "Approvers")?.textContent).toContain("None");
expect(container.querySelector('[data-testid="monitor-row-trigger"]')).not.toBeNull();
act(() => root.unmount());
});
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
+3
View File
@@ -209,6 +209,7 @@ export function Sidebar() {
</>
) : null}
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
@@ -216,6 +217,7 @@ export function Sidebar() {
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
errorBehavior="hidden"
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
@@ -239,6 +241,7 @@ export function Sidebar() {
</SidebarSection>
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
+3
View File
@@ -253,6 +253,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
@@ -260,6 +261,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
errorBehavior="hidden"
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
@@ -302,6 +304,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
)}
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
+4
View File
@@ -74,6 +74,10 @@ const APP_COPY: Record<string, AppCopy> = {
tagline: "Explore product usage, errors, flags, and experiments.",
short: "Sign in with PostHog. Project pinning and access controls are optional.",
},
gauge: {
tagline: "Track your brand in AI answers and run content workflows.",
short: "Sign in with Gauge and pick an organization. API keys are optional.",
},
neon: {
tagline: "Manage Postgres projects, branches, and queries.",
short: "Sign in with Neon. Project pinning and read-only mode are optional.",
+46
View File
@@ -36,11 +36,57 @@ describe("reading execution policy defaults", () => {
});
});
// Persisted monitor-only policies can omit fields supplied by schema defaults.
const monitorOnlyPolicy = {
monitor: {
nextCheckAt: "2026-10-08T16:00:00.000Z",
notes: "Check pull request",
scheduledBy: "assignee",
kind: "external_service",
serviceName: "github-pr",
externalRef: "https://github.com/example/repo/pull/1",
maxAttempts: 5,
},
} as IssueExecutionPolicy;
describe("stageParticipantValues", () => {
it.each(["review", "approval"] as const)("reads %s participants from a monitor-only policy", (stageType) => {
expect(issueExecutionPolicySchema.safeParse(monitorOnlyPolicy).success).toBe(true);
expect(stageParticipantValues(monitorOnlyPolicy, stageType)).toEqual([]);
});
});
describe("buildExecutionPolicy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("adds review and approval stages to a monitor-only policy without losing its monitor", () => {
const policy = buildExecutionPolicy({
existingPolicy: monitorOnlyPolicy,
reviewerValues: [`agent:${AGENT_ID}`],
approverValues: ["user:local-board"],
});
expect(issueExecutionPolicySchema.safeParse(policy).success).toBe(true);
expect(policy?.monitor).toEqual(monitorOnlyPolicy.monitor);
expect(stageParticipantValues(policy, "review")).toEqual([`agent:${AGENT_ID}`]);
expect(stageParticipantValues(policy, "approval")).toEqual(["user:local-board"]);
});
it("keeps a monitor-only policy when there are no review or approval stages", () => {
expect(buildExecutionPolicy({
existingPolicy: monitorOnlyPolicy,
reviewerValues: [],
approverValues: [],
})).toEqual({
mode: "normal",
commentRequired: true,
stages: [],
monitor: monitorOnlyPolicy.monitor,
});
});
it("generates schema-valid UUIDs when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {
getRandomValues: (bytes: Uint8Array) => {
+42
View File
@@ -65,6 +65,7 @@ const BOX = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "box")!;
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
const NEON = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "neon")!;
const SUPERAGENT = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "superagent")!;
const GAUGE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gauge")!;
const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!;
const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!;
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
@@ -2056,6 +2057,47 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
}));
});
it("defaults Gauge to browser sign-in and connects an organization API key once entered", async () => {
mockParams.appKey = "gauge";
listGalleryMock.mockResolvedValueOnce({ apps: [GAUGE] });
await render();
// The publish warning must be visible on the default sign-in path, before
// the operator opens Advanced or switches to the API-key method.
expect(container.textContent).toContain("can publish to your connected CMS, including live");
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
await openAccessAdvanced();
expect(radioContaining("Sign in with Gauge")?.getAttribute("aria-checked")).toBe("true");
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
await act(async () => {
radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("set publish actions to Ask first");
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
expect(keyInput).toBeTruthy();
expect(buttonByText("Connect")?.disabled).toBe(true);
await act(async () => {
setInputValue(keyInput!, "gauge-test-key");
});
await flushReact();
const submit = buttonByText("Connect");
expect(submit?.disabled).toBe(false);
await act(async () => {
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
galleryKey: "gauge",
connectionMethodKey: "mcp-api-key",
credentialValues: { "credentials.authorization": "gauge-test-key" },
}));
});
it("enables Neon's Connect button only once the API key is entered, with pin and read-only optional", async () => {
mockParams.appKey = "neon";
listGalleryMock.mockResolvedValueOnce({ apps: [NEON] });
+8 -1
View File
@@ -781,6 +781,12 @@ type PluginLauncherOutletProps = {
className?: string;
itemClassName?: string;
errorClassName?: string;
/**
* `hidden` suppresses the inline error and keeps rendering the last loaded
* launchers, so ambient chrome (the sidebar) stays quiet while the server is
* unreachable.
*/
errorBehavior?: "inline" | "hidden";
};
export function PluginLauncherOutlet({
@@ -790,6 +796,7 @@ export function PluginLauncherOutlet({
className,
itemClassName,
errorClassName,
errorBehavior = "inline",
}: PluginLauncherOutletProps) {
const { activateLauncher } = usePluginLauncherRuntime();
const { launchers, contributionsByPluginId, errorMessage } = usePluginLaunchers({
@@ -799,7 +806,7 @@ export function PluginLauncherOutlet({
enabled: !!context.companyId,
});
if (errorMessage) {
if (errorMessage && errorBehavior === "inline") {
return (
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", errorClassName)}>
Plugin launchers unavailable: {errorMessage}
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { PluginUiContribution } from "@/api/plugins";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { PluginLauncherOutlet, PluginLauncherProvider } from "./launchers";
import { _resetPluginModuleLoader, PluginSlotOutlet, registerPluginReactComponent } from "./slots";
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: null }) }));
const contribution: PluginUiContribution = {
pluginId: "fixture", pluginKey: "fixture.sidebar", displayName: "Fixture", version: "1",
uiEntryFile: "index.js",
slots: [
{ type: "sidebar", id: "nav", displayName: "Nav", exportName: "SidebarItem" },
{ type: "sidebarPanel", id: "panel", displayName: "Panel", exportName: "SidebarPanel" },
],
launchers: [{
id: "open", displayName: "Fixture launcher", placementZone: "sidebar",
action: { type: "navigate", target: "/fixture" },
}],
};
const context = { companyId: "company-1", companyPrefix: "PAP" };
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
async function render(errorBehavior?: "inline" | "hidden") {
await act(async () => root.render(
<QueryClientProvider client={client}>
<MemoryRouter>
<PluginLauncherProvider>
<PluginSlotOutlet slotTypes={["sidebar"]} context={context} errorBehavior={errorBehavior} />
<PluginLauncherOutlet placementZones={["sidebar"]} context={context} errorBehavior={errorBehavior} />
<PluginSlotOutlet slotTypes={["sidebarPanel"]} context={context} errorBehavior={errorBehavior} />
</PluginLauncherProvider>
</MemoryRouter>
</QueryClientProvider>,
));
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// Module and session fetches stay pending; components are registered directly.
vi.stubGlobal("__paperclipPluginBridge__", {});
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
vi.spyOn(pluginsApi, "listUiContributions").mockRejectedValue(new TypeError("Failed to fetch"));
});
afterEach(async () => {
await act(async () => root.unmount()); container.remove(); client.clear(); _resetPluginModuleLoader();
vi.unstubAllGlobals(); vi.restoreAllMocks();
});
it("shows inline errors by default when contributions fail to load", async () => {
await render();
await vi.waitFor(() => expect(container.textContent).toContain("unavailable"));
expect(container.textContent).toContain("Plugin extensions unavailable: Failed to fetch");
expect(container.textContent).toContain("Plugin launchers unavailable: Failed to fetch");
});
it("hides errors and keeps the last loaded slots and launchers when errorBehavior is hidden", async () => {
registerPluginReactComponent("fixture.sidebar", "SidebarItem", () => <span>Fixture sidebar item</span>);
registerPluginReactComponent("fixture.sidebar", "SidebarPanel", () => <span>Fixture sidebar panel</span>);
client.setQueryData(queryKeys.plugins.uiContributions, [contribution]);
await render("hidden");
expect(container.textContent).toContain("Fixture sidebar item");
expect(container.textContent).toContain("Fixture sidebar panel");
await act(async () => {
await client.refetchQueries({ queryKey: queryKeys.plugins.uiContributions });
// React Query notifies observers on a timer; let the error render land.
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(client.getQueryState(queryKeys.plugins.uiContributions)?.status).toBe("error");
expect(container.textContent).not.toContain("unavailable");
expect(container.textContent).toContain("Fixture sidebar item");
expect(container.textContent).toContain("Fixture sidebar panel");
expect(container.textContent).toContain("Fixture launcher");
});
+8 -1
View File
@@ -905,6 +905,12 @@ type PluginSlotOutletProps = {
itemClassName?: string;
errorClassName?: string;
missingBehavior?: "hidden" | "placeholder";
/**
* `hidden` suppresses the inline error and keeps rendering the last loaded
* slots, so ambient chrome (the sidebar) stays quiet while the server is
* unreachable.
*/
errorBehavior?: "inline" | "hidden";
};
export function PluginSlotOutlet({
@@ -915,6 +921,7 @@ export function PluginSlotOutlet({
itemClassName,
errorClassName,
missingBehavior = "hidden",
errorBehavior = "inline",
}: PluginSlotOutletProps) {
const { slots, errorMessage } = usePluginSlots({
slotTypes,
@@ -922,7 +929,7 @@ export function PluginSlotOutlet({
companyId: context.companyId,
});
if (errorMessage) {
if (errorMessage && errorBehavior === "inline") {
return (
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", errorClassName)}>
Plugin extensions unavailable: {errorMessage}