[codex] Add built-in agents and Reflection Coach bundle (#9206)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators need first-party agent capabilities for repeatable company work, not just manually created one-off agents. > - Built-in agents need to behave like normal company-scoped agents while preserving approval gates, permissions, budgets, and audit trails. > - Reflection and coaching work also needs bundled instructions, skill content, and a routine so the feature can be installed and reset predictably. > - The API, database, UI, portability, and tests all need to agree on the built-in lifecycle from not provisioned through setup, approval, ready, paused, and reset. > - This pull request adds built-in agent provisioning and the Reflection Coach bundle end-to-end. > - The benefit is a safer first-party path for Paperclip-managed agents without bypassing the same governance model used for operator-created agents. ## Linked Issues or Issue Description No public GitHub issue was found for this exact built-in agent and Reflection Coach bundle work. Problem/motivation: - Paperclip did not have a first-party built-in agent lifecycle for product-owned agents. - Bundled agent resources such as default instructions, skills, and routines needed managed ownership and reset semantics. - Approval-gated companies needed built-in setup to preserve requested adapter, budget, manager, and permission state through board approval. - The board UI needed clear built-in badges, setup affordances, readiness state, and bundle status without exposing secrets. Proposed solution: - Add a company-scoped built-in agent registry, provisioning/reset/reconcile/status APIs, and Reflection Coach bundled resources. - Track bundled managed resources in the database with idempotent migration behavior. - Reuse existing agent approval, authorization, budget, and activity-log paths instead of creating a bypass. - Add UI setup, badges, gates, bundle panels, and route coverage for built-in agents. Duplicate search: - Searched GitHub PRs for `built-in agents Reflection Coach repo:paperclipai/paperclip`; only this PR was returned. - Searched GitHub issues for the same query; no public issues were returned. ## What Changed - Added built-in agent definitions, lifecycle state derivation, provisioning, reset, reconcile, status, and routine-control routes. - Added the `built_in_managed_resources` migration and schema exports for bundled instructions, skill, and routine ownership. - Added the Reflection Coach built-in bundle with default instructions, skill catalog content, routine template, default permissions, and managed-resource drift handling. - Added approval-aware provisioning behavior that preserves requested adapter config, budgets, manager assignment, and built-in permissions through hire approval. - Added authorization and mutation gates for built-in agent and skill changes, including consented Reflection Coach change paths. - Added UI surfaces for built-in agent setup, roster/detail badges, readiness gates, bundle status, routine controls, and route filtering. - Added company import/export and validator coverage for built-in managed resources and low-trust/red-team presets. - Addressed Greptile follow-ups for pending approval reconciliation, consent-gate error propagation, config-read authorization fallback, approval-path manager preservation, and non-model adapter provisioning. ## Verification Local verification: - `git diff --check public/master..HEAD` passed. - `pnpm check:token-gates` passed with all gates clean. - `pnpm exec vitest run ui/src/components/ConfigureBuiltInAgentModal.test.tsx` passed: 1 file, 4 tests. - `pnpm exec vitest run ui/src/components/EntityRow.test.tsx ui/src/pages/Agents.test.tsx ui/src/components/BuiltInAgentGate.test.tsx ui/src/components/ConfigureBuiltInAgentModal.test.tsx ui/src/components/BuiltInBundlePanel.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx ui/src/pages/Routines.test.tsx` passed: 7 files, 64 tests. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/built-in-agents.test.ts src/__tests__/authorization-service.test.ts src/__tests__/company-skills-routes.test.ts` passed: 3 files, 91 tests. - `pnpm --filter @paperclipai/db check:migrations` passed. - `pnpm -r typecheck` passed after the rebase; `pnpm --filter ui typecheck` passed after the final UI review fix. Remote verification on latest head `1c61f693a4ec881d739022b0e75a8ca8bf8c2cd8`: - Merge state: `CLEAN`. - Greptile: `5/5`, zero unresolved Greptile threads. - PR check rollup: all checks successful, neutral, or skipped as expected. - Passing gates include Build, Typecheck + Release Registry, all server shards, all workspace shards, all serialized server suites, e2e, Canary Dry Run, policy, review, verify, Socket, Superagent, and Snyk. ## Risks - This adds a new managed-resource table and migration; the migration uses idempotent create/add/index guards and passed migration safety checks. - Built-in agent provisioning touches approval and authorization paths; tests cover pending approval preservation, stale retry rejection, consent gates, and config-read fallback behavior. - Reflection Coach creates managed instructions, skill, and routine resources; drift/reset behavior is covered by service tests and redacted API responses. - Non-model adapter setup now provisions a `needs_setup` built-in row before command/endpoint fields are complete; this matches the server lifecycle and is covered by the setup modal regression test. ## Model Used OpenAI Codex coding agent based on GPT-5. Exact hosted model ID, context-window size, and reasoning-mode labels are not exposed in this runtime; tool use, shell execution, GitHub CLI/API access, and local code editing were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
53d09d4c34
commit
8b6a06ee25
|
|
@ -208,6 +208,11 @@ async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Pr
|
|||
return text ? JSON.parse(text) as T : (null as T);
|
||||
}
|
||||
|
||||
function isPortableAgent(agent: { metadata?: Record<string, unknown> | null }) {
|
||||
const marker = agent.metadata?.paperclipBuiltInAgent;
|
||||
return typeof marker !== "object" || marker === null;
|
||||
}
|
||||
|
||||
async function runCliJson<T>(
|
||||
args: string[],
|
||||
opts: TestPaperclipEnv & { apiBase?: string; includeConfigArg?: boolean },
|
||||
|
|
@ -560,7 +565,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||
expect(importedExisting.company.action).toBe("unchanged");
|
||||
expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);
|
||||
|
||||
const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
|
||||
const twiceImportedAgents = await api<Array<{ id: string; name: string; metadata?: Record<string, unknown> | null }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/agents`,
|
||||
);
|
||||
|
|
@ -573,9 +578,10 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||
`/api/companies/${importedNew.company.id}/issues`,
|
||||
);
|
||||
const twiceImportedMatchingIssues = twiceImportedIssues.filter((issue) => issue.title === sourceIssue.title);
|
||||
const twiceImportedPortableAgents = twiceImportedAgents.filter(isPortableAgent);
|
||||
|
||||
expect(twiceImportedAgents).toHaveLength(2);
|
||||
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
||||
expect(twiceImportedPortableAgents).toHaveLength(2);
|
||||
expect(new Set(twiceImportedPortableAgents.map((agent) => agent.name)).size).toBe(2);
|
||||
expect(twiceImportedProjects).toHaveLength(2);
|
||||
expect(twiceImportedMatchingIssues).toHaveLength(2);
|
||||
expect(new Set(twiceImportedMatchingIssues.map((issue) => issue.identifier)).size).toBe(2);
|
||||
|
|
|
|||
|
|
@ -377,6 +377,7 @@ describe("renderCompanyImportPreview", () => {
|
|||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
permissionGrants: [],
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
|
|
@ -597,6 +598,7 @@ describe("import selection catalog", () => {
|
|||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
permissionGrants: [],
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
|
|
@ -757,6 +759,7 @@ describe("default adapter overrides", () => {
|
|||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
permissionGrants: [],
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
|
|
@ -776,6 +779,7 @@ describe("default adapter overrides", () => {
|
|||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
permissionGrants: [],
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ changes that behavior. Low-trust containment instead limits what the low-trust
|
|||
agent can read or mutate through the Paperclip API and prevents raw untrusted
|
||||
output from being automatically promoted into higher-trust agent context.
|
||||
|
||||
Low-trust agents cannot read or mutate agent configuration, instruction bundles,
|
||||
or company skill configuration through direct grants. Configuration changes from
|
||||
low-trust work must go through higher-trust review and promotion paths instead.
|
||||
|
||||
## Runtime Containment
|
||||
|
||||
Managed `low_trust_review` runs fail closed unless Paperclip can enforce the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
# Built-in Agents
|
||||
|
||||
Built-in agents are first-party, company-scoped agents that Paperclip can resolve by a stable registry key. They are normal rows in `agents`, but they carry immutable metadata under `metadata.paperclipBuiltInAgent` so services can find them without hardcoding a database id.
|
||||
|
||||
The first built-ins are `briefs` and `learning`. Operators can provision them from the API without going through board hire approval, but the route still requires the same `agents:create` permission as normal agent creation.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
The subsystem has four layers:
|
||||
|
||||
- Registry: `server/src/services/built-in-agents.ts` defines the static `BuiltInAgentDefinition` list.
|
||||
- Marker: `server/src/services/built-in-agent-metadata.ts` reads and writes `metadata.paperclipBuiltInAgent`.
|
||||
- Provisioning service: `builtInAgentService(db)` finds, creates, updates, resets, and requires built-ins per company.
|
||||
- Routes: `server/src/routes/built-in-agents.ts` exposes list, provision, and reset APIs.
|
||||
|
||||
Built-in agent state is derived from the marked agent row:
|
||||
|
||||
- `not_provisioned`: no active marked row exists for the company/key.
|
||||
- `needs_setup`: a row exists, but adapter config is incomplete for the adapter type.
|
||||
- `ready`: adapter config is complete and the agent is not paused.
|
||||
- `paused`: the marked row is paused. Scheduled/background work should log the paused warning and skip queueing work.
|
||||
|
||||
Use `builtInAgentService(db).requireBuiltInAgent(companyId, key)` from backend features that need a built-in agent before scheduling work. It throws HTTP 412 with `code: "built_in_agent_not_configured"` for missing or incomplete agents. Paused agents return the agent plus a `built_in_agent_paused` warning so callers can pass the warning through to logs or API responses without treating the agent as ready for scheduling.
|
||||
|
||||
## API
|
||||
|
||||
All routes are company-scoped:
|
||||
|
||||
- `GET /api/companies/:companyId/built-in-agents`
|
||||
Lists registry definitions with current company state.
|
||||
- `POST /api/companies/:companyId/built-in-agents/:key/provision`
|
||||
Creates or configures the built-in for the company. Body accepts optional `adapterType` and `adapterConfig`.
|
||||
- `POST /api/companies/:companyId/built-in-agents/:key/reset`
|
||||
Restores registry-owned display/default fields on the marked row while preserving operator adapter setup.
|
||||
|
||||
Provision and reset require `agents:create`. Provision intentionally skips `requireBoardApprovalForNewAgents` because built-ins are registry-owned system capacity, not ad hoc hires.
|
||||
|
||||
## Add a New Built-in Agent
|
||||
|
||||
1. Add a definition in `DEFINITIONS` inside `server/src/services/built-in-agents.ts`.
|
||||
2. Pick a stable lowercase `key` using only letters, numbers, `_`, and `-`. Do not rename keys after release.
|
||||
3. Set `displayName`, `shortPurpose`, `defaultInstructions`, `defaultRole`, and at least one `featureKeys` entry.
|
||||
4. Set `allowedAdapterTypes` to the smallest set that actually works for this built-in.
|
||||
5. Decide whether the built-in needs a nonzero `defaultBudgetMonthlyCents`.
|
||||
6. Add or update tests in `server/src/__tests__/built-in-agents.test.ts`.
|
||||
7. If the built-in is surfaced in UI or docs, add those changes in the same PR.
|
||||
8. Run the focused tests from the repo root with `pnpm --filter @paperclipai/server exec vitest run src/__tests__/built-in-agents.test.ts src/__tests__/built-in-agent-routes.test.ts`.
|
||||
|
||||
Do not write built-in markers directly through generic agent create/update routes. The agent service rejects marker add, remove, and mutation unless the built-in service explicitly opts in.
|
||||
|
||||
## Worked Example: `digest`
|
||||
|
||||
Hypothetical registry diff:
|
||||
|
||||
```diff
|
||||
const DEFINITIONS = validateBuiltInAgentDefinitions([
|
||||
{
|
||||
key: "learning",
|
||||
displayName: "Learning Agent",
|
||||
featureKeys: ["learning"],
|
||||
shortPurpose: "Maintains reusable company learning from completed work and recurring patterns.",
|
||||
defaultInstructions:
|
||||
"You are Paperclip's built-in Learning agent. Extract durable lessons from completed work, preserve useful patterns, and keep learning artifacts grounded in source context.",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
},
|
||||
+ {
|
||||
+ key: "digest",
|
||||
+ displayName: "Digest Agent",
|
||||
+ featureKeys: ["digest"],
|
||||
+ shortPurpose: "Summarizes recent company activity into a board-readable digest.",
|
||||
+ defaultInstructions:
|
||||
+ "You are Paperclip's built-in Digest agent. Produce short, sourced summaries of recent company activity, decisions, blockers, and next actions.",
|
||||
+ defaultRole: "general",
|
||||
+ allowedAdapterTypes: ["codex_local", "claude_local", "process"],
|
||||
+ defaultBudgetMonthlyCents: 0,
|
||||
+ },
|
||||
]);
|
||||
```
|
||||
|
||||
Add focused test coverage:
|
||||
|
||||
```ts
|
||||
expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual([
|
||||
"briefs",
|
||||
"digest",
|
||||
"learning",
|
||||
]);
|
||||
```
|
||||
|
||||
If a background job needs the agent:
|
||||
|
||||
```ts
|
||||
const { agent, warning } = await builtInAgentService(db).requireBuiltInAgent(companyId, "digest");
|
||||
if (warning) {
|
||||
logger.info({ warning }, "Skipping digest work because built-in agent is paused");
|
||||
return;
|
||||
}
|
||||
|
||||
await heartbeatService(db).wakeup(agent.id, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "Generate company digest",
|
||||
});
|
||||
```
|
||||
|
||||
If the agent is missing or not configured, the helper throws:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Built-in agent is not configured: digest",
|
||||
"code": "built_in_agent_not_configured",
|
||||
"details": {
|
||||
"code": "built_in_agent_not_configured",
|
||||
"key": "digest",
|
||||
"status": "needs_setup",
|
||||
"agentId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- Registry definition has a stable key and at least one feature key.
|
||||
- `allowedAdapterTypes` is intentionally narrow.
|
||||
- Provisioning does not require board hire approval.
|
||||
- Generic agent create/update cannot forge or remove the marker.
|
||||
- Routes remain company-scoped and write activity for mutations.
|
||||
- Background consumers use `requireBuiltInAgent(companyId, key)` instead of open-coding marker lookup.
|
||||
- Paused built-ins skip scheduled/background work and leave an inspectable log or warning.
|
||||
- Focused tests pass:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run src/__tests__/built-in-agents.test.ts src/__tests__/built-in-agent-routes.test.ts
|
||||
```
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- One active built-in row per company/key is allowed. Duplicate active markers are treated as a conflict and must be repaired manually.
|
||||
- Terminated built-in rows are ignored for lookup; provisioning can create a replacement.
|
||||
- `reset` restores registry-owned defaults but preserves adapter setup so operators do not lose local model or command configuration.
|
||||
- Unknown marker keys are ignored during startup reconciliation. This prevents removed experimental built-ins from breaking server boot.
|
||||
- Feature code should treat 412 `built_in_agent_not_configured` as an operator setup problem, not as a 500.
|
||||
|
|
@ -42,7 +42,7 @@ afterEach(async () => {
|
|||
const cleanup = cleanups.pop();
|
||||
await cleanup?.();
|
||||
}
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
|
|
|||
|
|
@ -617,6 +617,110 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays the built-in managed resources migration after the legacy 0136 journal entry",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const builtInResourcesHash = await migrationHash(
|
||||
"0140_built_in_managed_resources.sql",
|
||||
);
|
||||
const legacyBuiltInResourcesHash = createHash("sha256")
|
||||
.update("legacy 0136_built_in_managed_resources.sql")
|
||||
.digest("hex");
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await sql.unsafe(
|
||||
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${builtInResourcesHash}'`,
|
||||
);
|
||||
await sql.unsafe(
|
||||
`
|
||||
INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at")
|
||||
VALUES ('${legacyBuiltInResourcesHash}', 1783555200000)
|
||||
`,
|
||||
);
|
||||
await sql.unsafe(`
|
||||
ALTER TABLE "built_in_managed_resources"
|
||||
DROP CONSTRAINT IF EXISTS "built_in_managed_resources_company_id_companies_id_fk"
|
||||
`);
|
||||
await sql.unsafe(`DROP INDEX IF EXISTS "built_in_managed_resources_company_idx"`);
|
||||
await sql.unsafe(`DROP INDEX IF EXISTS "built_in_managed_resources_resource_idx"`);
|
||||
await sql.unsafe(`DROP INDEX IF EXISTS "built_in_managed_resources_company_bundle_resource_uq"`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
const pendingState = await inspectMigrations(connectionString);
|
||||
expect(pendingState).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: ["0140_built_in_managed_resources.sql"],
|
||||
reason: "pending-migrations",
|
||||
});
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const finalState = await inspectMigrations(connectionString);
|
||||
expect(finalState.status).toBe("upToDate");
|
||||
|
||||
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const rows = await verifySql.unsafe<{
|
||||
foreign_key_exists: boolean;
|
||||
company_index_exists: boolean;
|
||||
resource_index_exists: boolean;
|
||||
unique_index_exists: boolean;
|
||||
}[]>(`
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "pg_constraint" c
|
||||
JOIN "pg_class" t ON t.oid = c.conrelid
|
||||
JOIN "pg_namespace" n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND t.relname = 'built_in_managed_resources'
|
||||
AND c.conname = 'built_in_managed_resources_company_id_companies_id_fk'
|
||||
) AS "foreign_key_exists",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "pg_class" c
|
||||
JOIN "pg_namespace" n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'i'
|
||||
AND c.relname = 'built_in_managed_resources_company_idx'
|
||||
) AS "company_index_exists",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "pg_class" c
|
||||
JOIN "pg_namespace" n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'i'
|
||||
AND c.relname = 'built_in_managed_resources_resource_idx'
|
||||
) AS "resource_index_exists",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "pg_class" c
|
||||
JOIN "pg_namespace" n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'i'
|
||||
AND c.relname = 'built_in_managed_resources_company_bundle_resource_uq'
|
||||
) AS "unique_index_exists"
|
||||
`);
|
||||
expect(rows[0]).toEqual({
|
||||
foreign_key_exists: true,
|
||||
company_index_exists: true,
|
||||
resource_index_exists: true,
|
||||
unique_index_exists: true,
|
||||
});
|
||||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays migration 0134 without bumping issue updated_at for inbox archives",
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
CREATE TABLE IF NOT EXISTS "built_in_managed_resources" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"bundle_key" text NOT NULL,
|
||||
"resource_kind" text NOT NULL,
|
||||
"resource_key" text NOT NULL,
|
||||
"resource_id" uuid NOT NULL,
|
||||
"stock_version" text NOT NULL,
|
||||
"stock_hash" text NOT NULL,
|
||||
"defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "pg_constraint" c
|
||||
JOIN "pg_class" t ON t.oid = c.conrelid
|
||||
JOIN "pg_namespace" n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND t.relname = 'built_in_managed_resources'
|
||||
AND c.conname = 'built_in_managed_resources_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "built_in_managed_resources"
|
||||
ADD CONSTRAINT "built_in_managed_resources_company_id_companies_id_fk"
|
||||
FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "built_in_managed_resources_company_idx"
|
||||
ON "built_in_managed_resources" ("company_id");
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "built_in_managed_resources_resource_idx"
|
||||
ON "built_in_managed_resources" ("resource_kind", "resource_id");
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "built_in_managed_resources_company_bundle_resource_uq"
|
||||
ON "built_in_managed_resources" ("company_id", "bundle_key", "resource_kind", "resource_key");
|
||||
|
|
@ -967,6 +967,13 @@
|
|||
"when": 1783555203000,
|
||||
"tag": "0139_skill_studio_run_templates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 140,
|
||||
"version": "7",
|
||||
"when": 1783555300000,
|
||||
"tag": "0140_built_in_managed_resources",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const builtInManagedResources = pgTable(
|
||||
"built_in_managed_resources",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
bundleKey: text("bundle_key").notNull(),
|
||||
resourceKind: text("resource_kind").notNull(),
|
||||
resourceKey: text("resource_key").notNull(),
|
||||
resourceId: uuid("resource_id").notNull(),
|
||||
stockVersion: text("stock_version").notNull(),
|
||||
stockHash: text("stock_hash").notNull(),
|
||||
defaultsJson: jsonb("defaults_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("built_in_managed_resources_company_idx").on(table.companyId),
|
||||
resourceIdx: index("built_in_managed_resources_resource_idx").on(table.resourceKind, table.resourceId),
|
||||
companyBundleResourceUq: uniqueIndex("built_in_managed_resources_company_bundle_resource_uq").on(
|
||||
table.companyId,
|
||||
table.bundleKey,
|
||||
table.resourceKind,
|
||||
table.resourceKey,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -6,6 +6,7 @@ export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.j
|
|||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
|
||||
export { agents } from "./agents.js";
|
||||
export { builtInManagedResources } from "./built_in_managed_resources.js";
|
||||
export { agentMemberships } from "./agent_memberships.js";
|
||||
export { boardApiKeys } from "./board_api_keys.js";
|
||||
export { cliAuthChallenges } from "./cli_auth_challenges.js";
|
||||
|
|
|
|||
|
|
@ -849,7 +849,10 @@ export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number];
|
|||
|
||||
export const PERMISSION_KEYS = [
|
||||
"agents:create",
|
||||
"agents:configure",
|
||||
"agents:suggest-changes",
|
||||
"skills:create",
|
||||
"skills:suggest-changes",
|
||||
"environments:manage",
|
||||
"users:invite",
|
||||
"users:manage_permissions",
|
||||
|
|
|
|||
|
|
@ -1163,6 +1163,9 @@ export {
|
|||
agentSkillSyncSchema,
|
||||
type AgentSkillSync,
|
||||
createAgentSchema,
|
||||
builtInAgentEmptyMutationSchema,
|
||||
builtInAgentProvisionSchema,
|
||||
builtInAgentResetSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
agentInstructionsBundleModeSchema,
|
||||
|
|
@ -1182,6 +1185,8 @@ export {
|
|||
agentPermissionsSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
type CreateAgent,
|
||||
type BuiltInAgentProvision,
|
||||
type BuiltInAgentReset,
|
||||
type CreateAgentHire,
|
||||
type UpdateAgent,
|
||||
type UpdateAgentInstructionsBundle,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
import type { IssueCommentAuthorType } from "../constants.js";
|
||||
import type { IssueCommentAuthorType, PermissionKey } from "../constants.js";
|
||||
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
|
||||
|
||||
export interface CompanyPortabilityInclude {
|
||||
|
|
@ -145,6 +145,10 @@ export interface CompanyPortabilityAgentManifestEntry {
|
|||
adapterConfig: Record<string, unknown>;
|
||||
runtimeConfig: Record<string, unknown>;
|
||||
permissions: Record<string, unknown>;
|
||||
permissionGrants: Array<{
|
||||
permissionKey: PermissionKey;
|
||||
scope: Record<string, unknown> | null;
|
||||
}>;
|
||||
budgetMonthlyCents: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
enableExternalObjects: boolean;
|
||||
enableBuiltInAgents: boolean;
|
||||
enableGoalsSidebarLink: boolean;
|
||||
enableServerInfoDebugView: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
|
|
|
|||
|
|
@ -88,6 +88,24 @@ export const createAgentSchema = z.object({
|
|||
|
||||
export type CreateAgent = z.infer<typeof createAgentSchema>;
|
||||
|
||||
export const builtInAgentProvisionSchema = z.object({
|
||||
adapterType: agentAdapterTypeSchema.optional(),
|
||||
adapterConfig: adapterConfigSchema.optional(),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional(),
|
||||
}).strict();
|
||||
|
||||
export type BuiltInAgentProvision = z.infer<typeof builtInAgentProvisionSchema>;
|
||||
|
||||
export const builtInAgentEmptyMutationSchema = z.object({}).strict().default({});
|
||||
|
||||
export type BuiltInAgentEmptyMutation = z.infer<typeof builtInAgentEmptyMutationSchema>;
|
||||
|
||||
export const builtInAgentResetSchema = z.object({
|
||||
resources: z.array(z.enum(["agent", "instructions", "skill", "routine"])).optional(),
|
||||
}).strict().default({});
|
||||
|
||||
export type BuiltInAgentReset = z.infer<typeof builtInAgentResetSchema>;
|
||||
|
||||
export const createAgentHireSchema = createAgentSchema.extend({
|
||||
sourceIssueId: z.string().uuid().optional().nullable(),
|
||||
sourceIssueIds: z.array(z.string().uuid()).optional(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { PERMISSION_KEYS } from "../constants.js";
|
||||
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
|
||||
import {
|
||||
issueCommentAuthorTypeSchema,
|
||||
|
|
@ -70,6 +71,10 @@ export const portabilityAgentManifestEntrySchema = z.object({
|
|||
adapterConfig: z.record(z.string(), z.unknown()),
|
||||
runtimeConfig: z.record(z.string(), z.unknown()),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
permissionGrants: z.array(z.object({
|
||||
permissionKey: z.enum(PERMISSION_KEYS),
|
||||
scope: z.record(z.string(), z.unknown()).nullable().default(null),
|
||||
})).default([]),
|
||||
budgetMonthlyCents: z.number().int().nonnegative(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@ export {
|
|||
|
||||
export {
|
||||
createAgentSchema,
|
||||
builtInAgentEmptyMutationSchema,
|
||||
builtInAgentProvisionSchema,
|
||||
builtInAgentResetSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
agentRuntimeConfigSchema,
|
||||
|
|
@ -267,6 +270,8 @@ export {
|
|||
agentPermissionsSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
type CreateAgent,
|
||||
type BuiltInAgentProvision,
|
||||
type BuiltInAgentReset,
|
||||
type CreateAgentHire,
|
||||
type UpdateAgent,
|
||||
type UpdateAgentInstructionsBundle,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ describe("instance experimental settings validators", () => {
|
|||
expect(settings.enableWorktreeRunExecution).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults built-in agents off", () => {
|
||||
const settings = instanceExperimentalSettingsSchema.parse({});
|
||||
|
||||
expect(settings.enableBuiltInAgents).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts worktree run execution patches", () => {
|
||||
expect(
|
||||
patchInstanceExperimentalSettingsSchema.parse({
|
||||
|
|
@ -68,4 +74,14 @@ describe("instance experimental settings validators", () => {
|
|||
enableGoalsSidebarLink: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts built-in agents patches", () => {
|
||||
expect(
|
||||
patchInstanceExperimentalSettingsSchema.parse({
|
||||
enableBuiltInAgents: true,
|
||||
}),
|
||||
).toEqual({
|
||||
enableBuiltInAgents: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableExperimentalFileViewer: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
enableExternalObjects: z.boolean().default(false),
|
||||
enableBuiltInAgents: z.boolean().default(false),
|
||||
enableGoalsSidebarLink: z.boolean().default(false),
|
||||
enableServerInfoDebugView: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
name: reflection-coach
|
||||
description: Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap.
|
||||
key: paperclipai/bundled/paperclip-operations/reflection-coach
|
||||
recommendedForRoles:
|
||||
- manager
|
||||
- general
|
||||
tags:
|
||||
- paperclip
|
||||
- reflection
|
||||
- coaching
|
||||
- agents
|
||||
- skills
|
||||
---
|
||||
|
||||
# Reflection Coach
|
||||
|
||||
You are coaching another agent. You are **not** that agent. Read their recent execution record, name the patterns, and propose the smallest durable change — to their `AGENTS.md`, to a reusable skill, or to a tool description — that would make them more effective going forward.
|
||||
|
||||
This skill runs **on a target agent** and produces a reviewable proposal. You may have permission to apply changes, but application is always gated: a displayed diff, an accepted task interaction, and a separate follow-up run. You never propose and apply in the same run.
|
||||
|
||||
Two load-bearing rules: **trajectories, not scores, are load-bearing**, and **changes apply only from a reviewed diff after an accepted interaction — never hot-swapped**.
|
||||
|
||||
## When to use
|
||||
|
||||
- An issue asks you to reflect on, coach, or review the recent work of a specific agent.
|
||||
- A routine (e.g. `recent-agent-reflection`) hands you a bounded set of agents to review.
|
||||
- Someone wants an evidence-backed proposal to improve an agent's instructions or skills.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The target agent id is your own. Refuse — no self-reflection.
|
||||
- You are asked to rewrite product code or shared infra. That is out of scope.
|
||||
- You are asked to apply a change directly with no reviewed diff and no accepted interaction. Refuse and name the gate.
|
||||
|
||||
## Inputs
|
||||
|
||||
Required:
|
||||
|
||||
- `targetAgentId` — the agent you are coaching. Never coach yourself.
|
||||
- `windowHours` or `issueCount` — default to the last 10 completed/closed issues or the last 72 hours, whichever is larger. Cap at 25 issues to stay within budget.
|
||||
|
||||
Optional:
|
||||
|
||||
- `focus` — free-text hint ("verification misses", "late escalations"). Bias clustering toward this axis if given.
|
||||
- `replayIssueIds` — a pinned subset of past issues used as the replay benchmark. If absent, pick 3–5 representative recent issues from the window.
|
||||
|
||||
## Hard guardrails
|
||||
|
||||
Every proposal must satisfy all of these:
|
||||
|
||||
- **No same-run apply.** Discovery and application are separate runs. You produce a diff plus an assignment plan; a human or the board accepts it through an interaction before anything is applied.
|
||||
- **Size caps.** Skills ≤ 15KB. Tool descriptions ≤ 500 chars. `AGENTS.md` may grow by **at most +20%** per proposal. Want more? Split proposals.
|
||||
- **Trajectory-backed or drop it.** Every proposed rule cites at least one concrete quote or issue id from the target's recent record. No evidence, no rule.
|
||||
- **Not your code.** Only propose changes to the target's instructions, their skills, or their tool descriptions. Never to code they do not own or to shared infra.
|
||||
- **Benchmark-gated.** Name the replay cases the proposal must still resolve. If a rule would have broken a past success, drop it.
|
||||
- **No reflection on yourself.** If `targetAgentId == PAPERCLIP_AGENT_ID`, refuse and ask for another coach.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1) Confirm target and scope
|
||||
|
||||
```sh
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/<targetAgentId>" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
```
|
||||
|
||||
Record `name`, `role`, `reportsTo`, `adapterType`, `adapterConfig.instructionsFilePath` (where `AGENTS.md` lives), and current assigned skills via `GET /api/agents/<targetAgentId>/skills`. Refuse and exit if `targetAgentId == $PAPERCLIP_AGENT_ID`.
|
||||
|
||||
### 2) Pull the recent record
|
||||
|
||||
```sh
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=<targetAgentId>&status=done,in_review,blocked&limit=25" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
```
|
||||
|
||||
For each issue, pull the trajectory substrate — the issue body and its comments:
|
||||
|
||||
```sh
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/<issueId>" -H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/<issueId>/comments" -H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
```
|
||||
|
||||
Keep status transitions, blocker reasons, reviewer comments, approval outcomes, human corrections, and PR-link comments. Comments are the closest thing Paperclip has to an execution trace — treat them as first-class evidence.
|
||||
|
||||
### 3) Read the target's current guardrails
|
||||
|
||||
Before proposing anything, read what already exists so you don't restate it:
|
||||
|
||||
- Their `AGENTS.md` at `adapterConfig.instructionsFilePath`.
|
||||
- Their assigned skills (from step 1).
|
||||
- Any `MEMORY.md` / `memory/` files in their cwd if the adapter uses para-memory-files.
|
||||
|
||||
If a rule you were about to propose is already present, drop it. A failure pattern *despite* an existing rule is a different finding — record it as "existing rule X is not being followed" and propose how to make it stick (move to a skill, add a negative example, strengthen the trigger), not a duplicate.
|
||||
|
||||
### 4) Cluster the failures
|
||||
|
||||
Name each cluster from this taxonomy:
|
||||
|
||||
- **verifier-miss** — agent claimed done; reviewer rejected.
|
||||
- **avoidable-rework** — same issue reopened more than once.
|
||||
- **stale-context** — acted on an assumption already falsified in-thread.
|
||||
- **instruction-miss** — violated an existing rule in `AGENTS.md`.
|
||||
- **late-escalation** — stayed blocked too long without escalating.
|
||||
- **human-correction** — a user explicitly said to do X differently.
|
||||
- **tool-misuse** — hit the same tool-error pattern repeatedly.
|
||||
- **scope-creep** — changes beyond task scope.
|
||||
|
||||
For each cluster keep a list of `(issueId, commentId, one-line evidence quote)` tuples. **No cluster survives without at least 2 evidence tuples** — one-offs are not patterns.
|
||||
|
||||
### 5) Route each cluster to a target surface
|
||||
|
||||
- **Agent-specific, narrow, cheap to state** → `AGENTS.md` update. E.g. "always re-run failing tests before marking in_review."
|
||||
- **Generalizable, multi-step procedure with when-to-use logic** → new or updated reusable skill.
|
||||
- **Both** → update/create the skill AND add a pointer line in `AGENTS.md` so the agent knows when to reach for it. Common case for non-obvious procedures.
|
||||
- **Tool description** → only if the failure was "agent didn't know when to use tool X" and a ≤500-char description change fixes it.
|
||||
|
||||
Sanity check reuse honestly: a rule that applies to all coders belongs in a shared skill; a "reusable skill" that only fits one role belongs in that agent's `AGENTS.md`.
|
||||
|
||||
### 6) Draft the proposal document
|
||||
|
||||
Create a document attached to the **reflection issue** (never the target's issues). One section per cluster:
|
||||
|
||||
```markdown
|
||||
## Cluster: <name>
|
||||
|
||||
**Pattern (1 sentence, quotable):**
|
||||
**Root cause hypothesis:**
|
||||
**Evidence (≥2):**
|
||||
- [PAP-NNN](/PAP/issues/PAP-NNN) — "<verbatim fragment>"
|
||||
- [PAP-MMM](/PAP/issues/PAP-MMM) — "<verbatim fragment>"
|
||||
|
||||
**Proposed change:**
|
||||
- Target surface: AGENTS.md | skill:<slug> | both | tool-description:<tool>
|
||||
- Diff (inline, minimal, ≤20% AGENTS.md growth / ≤15KB skill):
|
||||
```diff
|
||||
...
|
||||
```
|
||||
|
||||
**Expected still-passes (replay):**
|
||||
- [PAP-XXX](/PAP/issues/PAP-XXX), [PAP-YYY](/PAP/issues/PAP-YYY)
|
||||
|
||||
**Why this change, not something bigger:**
|
||||
(1–2 sentences on why you didn't rewrite more.)
|
||||
```
|
||||
|
||||
### 7) Write the actual drafts (files, not just prose)
|
||||
|
||||
- **Skill surface** — draft a full `SKILL.md` (frontmatter → Overview → When to use → Process → Pitfalls → Verification), ≤ 15KB. Put it under `drafts/<skill-slug>/SKILL.md` and attach it to the reflection issue.
|
||||
- **AGENTS.md surface** — write a unified diff against the target's current `AGENTS.md`. Do not rewrite the whole file; quote 1–3 lines of context per change. Keep total growth ≤ +20%; split if you can't.
|
||||
|
||||
### 8) Benchmark-gate the proposal
|
||||
|
||||
For each pinned replay issue, ask: "If this rule had been in effect, would the agent still have succeeded?" Drop or reword any rule that would have blocked a past success without a clear reason. Record the walk in "Expected still-passes." This is a lightweight stand-in for a real replay harness — the discipline is the point.
|
||||
|
||||
### 9) Publish and request acceptance
|
||||
|
||||
From a reflection issue (assigned to the target's manager or the requester):
|
||||
|
||||
1. Attach the proposal document: `PUT /api/issues/{issueId}/documents/reflection-proposal`.
|
||||
2. If a draft skill was written, commit it under `skills/<skill-slug>/` (or attach it) and link it in the proposal.
|
||||
3. Open the acceptance gate with a task interaction on the reflection issue. Mutations that change instructions, skills, or tool descriptions must use `request_confirmation`, show the diff in `payload.detailsMarkdown`, set `continuationPolicy: wake_assignee_on_accept`, and include the exact `payload.target.key` listed below.
|
||||
4. Leave a comment summarizing: target agent, window, clusters found, surfaces touched, link to the proposal, link to the interaction, and the next-step owner.
|
||||
|
||||
Server-enforced mutation target keys:
|
||||
|
||||
- Agent instructions: `agent:<agentId>:instructions`
|
||||
- Agent/tool description fields: `agent:<agentId>:profile`
|
||||
- Existing company skill: `skill:<skillId>`
|
||||
- New local company skill by slug: `skill-slug:<slug>`
|
||||
- Imported or catalog skill source: `skill-import:<source>`
|
||||
- Project workspace skill scan: `skills:scan-projects`
|
||||
|
||||
### 10) Apply only after acceptance, in a follow-up run
|
||||
|
||||
When the interaction resolves **accepted**, apply the change in a *separate* run:
|
||||
|
||||
- **AGENTS.md** — update the target's managed instruction file exactly as the accepted diff specified.
|
||||
- **Skill** — install/update the skill in the company library, then `POST /api/agents/<targetAgentId>/skills/sync` when the target should receive it.
|
||||
- **Tool description** — update the target agent's description/profile field that the accepted diff named.
|
||||
|
||||
The server rejects Reflection Coach mutations unless the accepted `request_confirmation` was created by Reflection Coach in a previous run, has a displayed diff, and is bound to the resource by one of the target keys above. If the interaction was rejected or is still pending, apply nothing. If you were asked to apply without a reviewed diff and an accepted interaction, refuse and name the gate — no-same-run-apply is load-bearing.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Scoring without trajectories.** Don't say "failed 3 times" without quoting the failures. Scores alone collapse improvement rate.
|
||||
- **Proposing the bigger rewrite.** Your job is the smallest change that would have prevented the cluster. Bigger feels impressive; it isn't.
|
||||
- **Duplicating rules the agent already has.** Read `AGENTS.md` + assigned skills first. An existing-but-unfollowed rule is a "make it stick" proposal, not a restatement.
|
||||
- **Applying in the discovery run.** Even with permission, discovery and application are separate runs behind an accepted interaction.
|
||||
- **Silently expanding scope.** The +20% cap exists because every new rule competes for attention. Four small proposals beat one big rewrite.
|
||||
- **Promising runtime value.** You are not improving the agent mid-session. This is offline, diff-reviewed, interaction-gated.
|
||||
|
||||
## Verification (self-check before publishing)
|
||||
|
||||
- [ ] `targetAgentId != $PAPERCLIP_AGENT_ID`
|
||||
- [ ] Each cluster has ≥2 evidence tuples with a linked issue + verbatim quote
|
||||
- [ ] Each proposal names the target surface explicitly and includes the diff (not just prose)
|
||||
- [ ] `AGENTS.md` growth ≤ 20%, skills ≤ 15KB, tool descriptions ≤ 500 chars
|
||||
- [ ] Replay set has ≥3 past issues the rules still pass against
|
||||
- [ ] Proposal document linked from the reflection issue
|
||||
- [ ] An acceptance interaction (showing the diff) is open before any mutation
|
||||
- [ ] No claim that the target has already "been updated" before acceptance + follow-up run
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"schemaVersion": 1,
|
||||
"packageName": "@paperclipai/skills-catalog",
|
||||
"packageVersion": "0.3.1",
|
||||
"generatedAt": "2026-07-07T12:23:29.613Z",
|
||||
"generatedAt": "2026-07-09T15:02:07.574Z",
|
||||
"skills": [
|
||||
{
|
||||
"id": "paperclipai:bundled:docs:doc-maintenance",
|
||||
|
|
@ -73,6 +73,41 @@
|
|||
],
|
||||
"contentHash": "sha256:88dc13560371fb364963782cb4f6eeb4090fcde92ee3774479428ed6b90e11c1"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:paperclip-operations:reflection-coach",
|
||||
"key": "paperclipai/bundled/paperclip-operations/reflection-coach",
|
||||
"kind": "bundled",
|
||||
"category": "paperclip-operations",
|
||||
"slug": "reflection-coach",
|
||||
"name": "reflection-coach",
|
||||
"description": "Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap.",
|
||||
"path": "catalog/bundled/paperclip-operations/reflection-coach",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"manager",
|
||||
"general"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"paperclip",
|
||||
"reflection",
|
||||
"coaching",
|
||||
"agents",
|
||||
"skills"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 11903,
|
||||
"sha256": "ca167eac8d1e89cadc8009b61a204368507e7edb7f2da0688fea6dba8223e189"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:20381a898f05ceb668e305708dd33a03ce36aef0e00b923c01ae20ab785f04d2"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:paperclip-operations:task-planning",
|
||||
"key": "paperclipai/bundled/paperclip-operations/task-planning",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index.
|
|||
const EXPECTED_BUNDLED_KEYS = [
|
||||
"paperclipai/bundled/docs/doc-maintenance",
|
||||
"paperclipai/bundled/paperclip-operations/issue-triage",
|
||||
"paperclipai/bundled/paperclip-operations/reflection-coach",
|
||||
"paperclipai/bundled/paperclip-operations/task-planning",
|
||||
"paperclipai/bundled/product/paperclip-capsules",
|
||||
"paperclipai/bundled/product/wireframe",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ function registerModuleMocks() {
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ vi.mock("../services/index.js", () => ({
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
@ -94,6 +95,7 @@ function registerModuleMocks() {
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ vi.mock("../services/index.js", () => ({
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBuiltInAgentService = vi.hoisted(() => ({
|
||||
ensureCompanyDefaultAgentGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
getBundle: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
|
|
@ -42,6 +46,7 @@ vi.mock("../services/index.js", () => ({
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
environmentService: () => mockEnvironmentService,
|
||||
|
|
@ -73,6 +78,7 @@ function registerModuleMocks() {
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => ({}),
|
||||
|
|
@ -98,7 +104,17 @@ function registerModuleMocks() {
|
|||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
function boardActor() {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown> = boardActor()) {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
|
|
@ -106,13 +122,7 @@ async function createApp() {
|
|||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
|
|
@ -166,6 +176,21 @@ function makeAgent() {
|
|||
};
|
||||
}
|
||||
|
||||
function makeReflectionCoachAgent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...makeAgent(),
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Reflection Coach",
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["reflection-coach"],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent instructions bundle routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
|
@ -174,6 +199,7 @@ describe("agent instructions bundle routes", () => {
|
|||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
|
|
@ -259,6 +285,110 @@ describe("agent instructions bundle routes", () => {
|
|||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies non-privileged agents from reading peer instructions bundles", async () => {
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "agent-reader") {
|
||||
return {
|
||||
...makeAgent(),
|
||||
id: "agent-reader",
|
||||
name: "Reader",
|
||||
permissions: { canCreateAgents: false },
|
||||
};
|
||||
}
|
||||
return makeAgent();
|
||||
});
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
explanation: "Missing permission: agents:configure or agents:suggest-changes.",
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-reader",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
}),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("Missing permission");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: {
|
||||
type: "agent",
|
||||
companyId: "company-1",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
}));
|
||||
expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows agents to read their own instructions bundles", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
type: "agent",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
}),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows agents with suggest grants to read peer instructions bundles", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by explicit grant agents:suggest-changes.",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: "coach-agent",
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
});
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "coach-agent") {
|
||||
return makeReflectionCoachAgent({ id: "coach-agent" });
|
||||
}
|
||||
return makeAgent();
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
type: "agent",
|
||||
agentId: "coach-agent",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
}),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
|
||||
.query({ path: "AGENTS.md" }),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: {
|
||||
type: "agent",
|
||||
companyId: "company-1",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
}));
|
||||
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
"AGENTS.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a bundle file and persists compatibility config", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ function registerModuleMocks() {
|
|||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBuiltInAgentService = vi.hoisted(() => ({
|
||||
ensureCompanyDefaultAgentGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
|
|
@ -195,6 +199,7 @@ function registerModuleMocks() {
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
@ -309,6 +314,7 @@ describe.sequential("agent permission routes", () => {
|
|||
mockAgentService.updatePermissions.mockReset();
|
||||
mockAgentService.getChainOfCommand.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockReset();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAccessService.decide.mockReset();
|
||||
mockAccessService.hasPermission.mockReset();
|
||||
|
|
@ -354,6 +360,7 @@ describe.sequential("agent permission routes", () => {
|
|||
});
|
||||
mockAgentService.update.mockResolvedValue(baseAgent);
|
||||
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
||||
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
|
||||
const allowed = Boolean(await mockAccessService.canUser());
|
||||
|
|
@ -866,6 +873,7 @@ describe.sequential("agent permission routes", () => {
|
|||
true,
|
||||
"agent-admin-user",
|
||||
);
|
||||
expect(mockBuiltInAgentService.ensureCompanyDefaultAgentGrants).toHaveBeenCalledWith(companyId);
|
||||
});
|
||||
|
||||
it("rejects direct agent creation when new agents require board approval", async () => {
|
||||
|
|
@ -1699,11 +1707,10 @@ describe.sequential("agent permission routes", () => {
|
|||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies an agent actor without agents:create when reading peer config", async () => {
|
||||
// Agent actors must still pass the agents:create gate (explicit
|
||||
// grant OR canCreateAgents permission on the agent record). A peer
|
||||
// agent in the same company without that permission must not be
|
||||
// able to read another agent's configuration.
|
||||
it("denies an agent actor without configure or suggest grants when reading peer config", async () => {
|
||||
// Agent actors must pass the agent configuration read ladder. A peer
|
||||
// agent in the same company without agents:configure or
|
||||
// agents:suggest-changes must not read another agent's configuration.
|
||||
const peerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgent = { ...baseAgent, id: peerAgentId };
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
|
|
@ -1713,7 +1720,11 @@ describe.sequential("agent permission routes", () => {
|
|||
}
|
||||
return null;
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
explanation: "Missing permission: agents:configure or agents:suggest-changes.",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
|
|
@ -1726,11 +1737,15 @@ describe.sequential("agent permission routes", () => {
|
|||
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows an agent actor with agents:create grant to read peer config", async () => {
|
||||
// When an agent actor has an explicit agents:create grant in the
|
||||
// access service, the read gate must let them through.
|
||||
it("allows an agent actor with agents:suggest-changes grant to read peer config", async () => {
|
||||
// Suggest-tier authority implies read access so the agent can prepare a
|
||||
// consented diff without receiving direct change authority.
|
||||
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const peerAgent = { ...baseAgent, id: peerAgentId };
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
|
|
@ -1740,11 +1755,17 @@ describe.sequential("agent permission routes", () => {
|
|||
}
|
||||
return null;
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(
|
||||
async (_companyId: string, _principalType: string, principalId: string, key: string) => {
|
||||
return principalId === agentId && key === "agents:create";
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by explicit grant agents:suggest-changes.",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
|
|
@ -1757,6 +1778,10 @@ describe.sequential("agent permission routes", () => {
|
|||
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ vi.mock("../services/index.js", () => ({
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
|
|
@ -123,6 +124,7 @@ function registerModuleMocks() {
|
|||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
|
@ -695,6 +697,7 @@ describe.sequential("agent skill routes", () => {
|
|||
instructionsFilePath: `/tmp/${createdAgentId}/instructions/AGENTS.md`,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ allowPendingApprovalConfigUpdate: true }),
|
||||
);
|
||||
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({
|
||||
adapterConfig: expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ vi.mock("../services/index.js", () => ({
|
|||
agentInstructionsService: () => ({}),
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => ({
|
||||
listRuntimeSkillEntries: vi.fn(async () => []),
|
||||
resolveRequestedSkillKeys: vi.fn(async () => []),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
activityLog,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
createDb,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres pending approval agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-pending-agent-config-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(budgetPolicies);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
it("freezes generic pending hire config and reapplies the approval snapshot on activation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const agentSvc = agentService(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const pending = await agentSvc.create(companyId, {
|
||||
name: "Pending Coder",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
icon: "code",
|
||||
capabilities: "Writes code",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "echo safe" },
|
||||
runtimeConfig: { maxConcurrentRuns: 1 },
|
||||
budgetMonthlyCents: 1234,
|
||||
metadata: { source: "hire-form" },
|
||||
status: "pending_approval",
|
||||
spentMonthlyCents: 0,
|
||||
permissions: {},
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const approval = await approvalSvc.create(companyId, {
|
||||
type: "hire_agent",
|
||||
requestedByAgentId: null,
|
||||
requestedByUserId: "board-user",
|
||||
status: "pending",
|
||||
payload: {
|
||||
name: "Pending Coder",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
icon: "code",
|
||||
reportsTo: null,
|
||||
capabilities: "Writes code",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "echo safe" },
|
||||
runtimeConfig: { maxConcurrentRuns: 1 },
|
||||
budgetMonthlyCents: 1234,
|
||||
metadata: { source: "hire-form" },
|
||||
agentId: pending.id,
|
||||
},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(agentSvc.update(pending.id, {
|
||||
name: "Tampered Coder",
|
||||
adapterConfig: { command: "echo malicious" },
|
||||
runtimeConfig: { maxConcurrentRuns: 99 },
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "pending_approval_agent_config_frozen",
|
||||
agentId: pending.id,
|
||||
fields: ["name", "adapterConfig", "runtimeConfig"],
|
||||
},
|
||||
});
|
||||
await expect(agentSvc.updatePermissions(pending.id, {
|
||||
canCreateAgents: true,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "pending_approval_agent_config_frozen",
|
||||
agentId: pending.id,
|
||||
fields: ["permissions"],
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
name: "Tampered Coder",
|
||||
adapterConfig: { command: "echo malicious" },
|
||||
runtimeConfig: { maxConcurrentRuns: 99 },
|
||||
metadata: { source: "tampered" },
|
||||
})
|
||||
.where(eq(agents.id, pending.id));
|
||||
|
||||
await approvalSvc.approve(approval.id, "board-user", "Approved generic hire");
|
||||
|
||||
await expect(agentSvc.getById(pending.id)).resolves.toMatchObject({
|
||||
status: "idle",
|
||||
name: "Pending Coder",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
icon: "code",
|
||||
capabilities: "Writes code",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "echo safe" },
|
||||
runtimeConfig: { maxConcurrentRuns: 1 },
|
||||
budgetMonthlyCents: 1234,
|
||||
metadata: { source: "hire-form" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -101,7 +101,7 @@ describe("approvalService resolution idempotency", () => {
|
|||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1", approved.payload);
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
|
|
@ -100,7 +100,7 @@ async function grantAgentPermission(
|
|||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
permissionKey: PermissionKey,
|
||||
scope: Record<string, unknown> | null = null,
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
|
|
@ -141,7 +141,7 @@ async function grantUserPermission(
|
|||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
permissionKey: PermissionKey,
|
||||
scope: Record<string, unknown> | null = null,
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
|
|
@ -224,23 +224,35 @@ describeEmbeddedPostgres("authorization service", () => {
|
|||
expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign");
|
||||
});
|
||||
|
||||
it("allows agent grants for agent configuration decisions", async () => {
|
||||
const company = await createCompany(db, "AgentGrant");
|
||||
it("allows suggest grants to read peer agent configuration", async () => {
|
||||
const company = await createCompany(db, "AgentReadGrant");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "agents:suggest-changes");
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "agents:create",
|
||||
grantedByUserId: null,
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the direct config-read grant decision when a suggest read grant is scoped away", async () => {
|
||||
const company = await createCompany(db, "AgentReadScopedSuggestGrant");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "agents:suggest-changes", {
|
||||
projectId: randomUUID(),
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
|
|
@ -249,8 +261,129 @@ describeEmbeddedPostgres("authorization service", () => {
|
|||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.grant?.permissionKey).toBe("agents:create");
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_grant",
|
||||
explanation: "Missing permission: agents:configure.",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct or consented suggest grants for agent configuration changes", async () => {
|
||||
const company = await createCompany(db, "AgentChangeGrant");
|
||||
const directAgent = await createAgent(db, company.id);
|
||||
const suggestAgent = await createAgent(db, company.id);
|
||||
const noGrantAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, directAgent.id, "agents:configure");
|
||||
await grantAgentPermission(db, company.id, suggestAgent.id, "agents:suggest-changes");
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: noGrantAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const authz = authorizationService(db);
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: directAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_direct_change",
|
||||
grant: { permissionKey: "agents:configure" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_consent",
|
||||
grant: { permissionKey: "agents:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true, consentedChange: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_consented_change",
|
||||
grant: { permissionKey: "agents:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: noGrantAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct or consented suggest grants for skill configuration changes", async () => {
|
||||
const company = await createCompany(db, "SkillChangeGrant");
|
||||
const directAgent = await createAgent(db, company.id);
|
||||
const suggestAgent = await createAgent(db, company.id);
|
||||
const noGrantAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, directAgent.id, "skills:create");
|
||||
await grantAgentPermission(db, company.id, suggestAgent.id, "skills:suggest-changes");
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: noGrantAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const authz = authorizationService(db);
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: directAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_direct_change",
|
||||
grant: { permissionKey: "skills:create" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_consent",
|
||||
grant: { permissionKey: "skills:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
scope: { consentedChange: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_consented_change",
|
||||
grant: { permissionKey: "skills:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: noGrantAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies cross-company agent decisions before grant evaluation", async () => {
|
||||
|
|
@ -605,6 +738,69 @@ describeEmbeddedPostgres("authorization service", () => {
|
|||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
});
|
||||
|
||||
it("blocks low-trust configuration actions before evaluating explicit change grants", async () => {
|
||||
const company = await createCompany(db, "LowTrustConfigGrants");
|
||||
const project = await createProject(db, company.id, "Allowed");
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
const actorAgent = await createAgent(db, company.id, {
|
||||
role: "ceo",
|
||||
permissions: {
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
authorizationPolicy: {
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
companyId: company.id,
|
||||
projectIds: [project.id],
|
||||
allowedAgentIds: [targetAgent.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "agents:configure");
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "skills:create",
|
||||
grantedByUserId: null,
|
||||
});
|
||||
|
||||
const authz = authorizationService(db);
|
||||
const actor = { type: "agent" as const, agentId: actorAgent.id, companyId: company.id, source: "agent_key" as const };
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "agent:read",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
})).resolves.toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" });
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: company.id, agentId: actorAgent.id },
|
||||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true, consentedChange: true },
|
||||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
scope: { consentedChange: true },
|
||||
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
|
||||
});
|
||||
|
||||
it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => {
|
||||
const company = await createCompany(db, "ProtectedAssignment");
|
||||
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,442 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBuiltInAgentService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
provision: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
enableRoutineSchedule: vi.fn(),
|
||||
disableRoutineSchedule: vi.fn(),
|
||||
runRoutine: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
function allowDecision() {
|
||||
return {
|
||||
allowed: true,
|
||||
action: "agents:create",
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed.",
|
||||
};
|
||||
}
|
||||
|
||||
function denyDecision() {
|
||||
return {
|
||||
allowed: false,
|
||||
action: "agents:create",
|
||||
reason: "deny_missing_grant",
|
||||
explanation: "Missing permission: agents:create.",
|
||||
};
|
||||
}
|
||||
|
||||
function builtInState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
definition: {
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["briefs"],
|
||||
shortPurpose: "Prepares concise operational briefs.",
|
||||
defaultInstructions: "Write briefs.",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local"],
|
||||
},
|
||||
status: "ready",
|
||||
agentId,
|
||||
agent: {
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Briefs Agent",
|
||||
role: "general",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
},
|
||||
pauseReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
vi.doMock("../services/built-in-agents.js", () => ({
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ builtInAgentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/built-in-agents.js")>("../routes/built-in-agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", builtInAgentRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("built-in agent routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.decide.mockResolvedValue(allowDecision());
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: true });
|
||||
mockBuiltInAgentService.list.mockResolvedValue([builtInState()]);
|
||||
mockBuiltInAgentService.get.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.ensure.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.provision.mockResolvedValue({ state: builtInState(), approval: null });
|
||||
mockBuiltInAgentService.reset.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.enableRoutineSchedule.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.disableRoutineSchedule.mockResolvedValue(builtInState());
|
||||
mockBuiltInAgentService.runRoutine.mockResolvedValue({ id: "routine-run-1", source: "manual", status: "queued" });
|
||||
});
|
||||
|
||||
it("lists built-in agent state for actors with company access", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockBuiltInAgentService.list).toHaveBeenCalledWith(companyId);
|
||||
expect(res.body).toEqual([expect.objectContaining({ status: "ready", agentId })]);
|
||||
expect(res.body[0].agent.adapterConfig).toEqual({});
|
||||
});
|
||||
|
||||
it("denies list requests outside the actor company boundary", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["33333333-3333-4333-8333-333333333333"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockBuiltInAgentService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 and does not load built-in state when the experimental flag is disabled", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: false });
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(404);
|
||||
expect(res.body.error).toContain("Built-in agents are not enabled");
|
||||
expect(mockBuiltInAgentService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("provisions through the agents:create gate and passes optional adapter overrides", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/briefs/provision`)
|
||||
.send({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, budgetMonthlyCents: 5000 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith({
|
||||
actor: expect.objectContaining({ type: "board" }),
|
||||
action: "agents:create",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
expect(mockBuiltInAgentService.provision).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"briefs",
|
||||
{
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
budgetMonthlyCents: 5000,
|
||||
},
|
||||
{ requestedByAgentId: null, requestedByUserId: "board-user" },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
action: "built_in_agent.provision_requested",
|
||||
entityId: agentId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("denies provision when agents:create is not allowed", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denyDecision());
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/briefs/provision`)
|
||||
.send({ adapterType: "codex_local" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ reason: "deny_missing_grant" });
|
||||
expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled();
|
||||
expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects provision bodies with unknown fields", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/briefs/provision`)
|
||||
.send({ adapterType: "codex_local", unexpected: true });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(400);
|
||||
expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled();
|
||||
expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables a built-in routine schedule through the board tasks:assign gate", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/enable`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAccessService.canUser).toHaveBeenCalledWith(companyId, "board-user", "tasks:assign");
|
||||
expect(mockBuiltInAgentService.enableRoutineSchedule).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ agentId: null, userId: "board-user", runId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "built_in_agent.routine_schedule_enabled",
|
||||
entityId: agentId,
|
||||
details: expect.objectContaining({ routineKey: "recent-agent-reflection" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("disables a built-in routine schedule", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/disable`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockBuiltInAgentService.disableRoutineSchedule).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ agentId: null, userId: "board-user", runId: null },
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers a built-in routine manual run", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(res.body).toMatchObject({ id: "routine-run-1", source: "manual" });
|
||||
expect(mockBuiltInAgentService.runRoutine).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"reflection-coach",
|
||||
"recent-agent-reflection",
|
||||
{ agentId: null, userId: "board-user", runId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "built_in_agent.routine_run_triggered",
|
||||
details: expect.objectContaining({ routineRunId: "routine-run-1" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("denies built-in routine controls when tasks:assign is not allowed", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies agent actors from controlling built-in routines", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "manager-agent",
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("Only board operators can control built-in routines.");
|
||||
expect(mockAccessService.canUser).not.toHaveBeenCalled();
|
||||
expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns pending hire approvals instead of provisioning immediately when company policy requires it", async () => {
|
||||
const approval = {
|
||||
id: "approval-1",
|
||||
status: "pending",
|
||||
type: "hire_agent",
|
||||
};
|
||||
mockBuiltInAgentService.provision.mockResolvedValue({
|
||||
state: builtInState({
|
||||
status: "pending_approval",
|
||||
agent: { ...builtInState().agent, status: "pending_approval" },
|
||||
}),
|
||||
approval,
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "manager-agent",
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/briefs/provision`)
|
||||
.send({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(res.body.status).toBe("pending_approval");
|
||||
expect(res.body.approval).toMatchObject({ id: "approval-1", status: "pending", type: "hire_agent" });
|
||||
expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled();
|
||||
expect(mockBuiltInAgentService.provision).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"briefs",
|
||||
{ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } },
|
||||
{ requestedByAgentId: "manager-agent", requestedByUserId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: "manager-agent",
|
||||
action: "approval.created",
|
||||
entityId: "approval-1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("resets registry defaults through the same agents:create gate", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "manager-agent",
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/briefs/reset`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: "manager-agent",
|
||||
agentId: "manager-agent",
|
||||
action: "built_in_agent.reset",
|
||||
entityId: agentId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("denies agent actors from provisioning across company boundaries", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "manager-agent",
|
||||
companyId: "33333333-3333-4333-8333-333333333333",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/built-in-agents/briefs/reset`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockAccessService.decide).not.toHaveBeenCalled();
|
||||
expect(mockBuiltInAgentService.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,249 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
changeConsentGateService,
|
||||
skillChangeTargetKey,
|
||||
} from "../services/change-consent-gate.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("changeConsentGateService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reflection-coach-gate-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedGateFixture() {
|
||||
const companyId = randomUUID();
|
||||
const coachId = randomUUID();
|
||||
const sourceRunId = randomUUID();
|
||||
const proposalIssueId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
const targetKey = skillChangeTargetKey(skillId);
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
defaultResponsibleUserId: "board-user",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: coachId,
|
||||
companyId,
|
||||
name: "Reflection Coach",
|
||||
role: "general",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: { canCreateSkills: true },
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId: coachId,
|
||||
status: "succeeded",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: proposalIssueId,
|
||||
companyId,
|
||||
title: "Review Reflection Coach proposal",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
identifier: "PAP-1",
|
||||
issueNumber: 1,
|
||||
createdByAgentId: coachId,
|
||||
});
|
||||
|
||||
return { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey };
|
||||
}
|
||||
|
||||
it("rejects Reflection Coach skill mutation without an accepted bound interaction", async () => {
|
||||
const { companyId, coachId, targetKey } = await seedGateFixture();
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "reflection_coach_mutation_gate_required" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects accepted interactions from the same run as the apply mutation", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: sourceRunId,
|
||||
targetKeys: [targetKey],
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "reflection_coach_mutation_gate_required" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a previous-run accepted interaction with a displayed diff for the bound target", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId,
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
const actorRunId = randomUUID();
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId,
|
||||
targetKeys: [targetKey],
|
||||
})).resolves.toBe(true);
|
||||
|
||||
const [stored] = await db
|
||||
.select({ result: issueThreadInteractions.result })
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId));
|
||||
|
||||
expect(stored?.result).toMatchObject({
|
||||
consumedByRunId: actorRunId,
|
||||
outcome: "accepted",
|
||||
version: 1,
|
||||
});
|
||||
expect((stored?.result as { consumedAt?: unknown } | undefined)?.consumedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("rejects reusing an accepted interaction after it is consumed by a mutation", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).resolves.toBe(true);
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "reflection_coach_mutation_gate_required" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows legacy Reflection Coach target keys for durable accepted interactions", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey } = await seedGateFixture();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: `reflection-coach:company-skill:${skillId}`, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,18 +3,28 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
builtInManagedResources,
|
||||
companies,
|
||||
companySkillVersions,
|
||||
companySkills,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
principalPermissionGrants,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { companyService } from "../services/companies.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import { reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -35,11 +45,19 @@ describeEmbeddedPostgres("companyService", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(builtInManagedResources);
|
||||
await db.delete(companySkillVersions);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(agents);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
|
|
@ -63,6 +81,50 @@ describeEmbeddedPostgres("companyService", () => {
|
|||
expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]);
|
||||
});
|
||||
|
||||
it("auto-provisions one paused Reflection Coach bundle for a freshly created company", async () => {
|
||||
const created = await companyService(db).create({
|
||||
name: "Fresh Company",
|
||||
});
|
||||
|
||||
const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
|
||||
const reflectionRows = agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach");
|
||||
expect(reflectionRows).toHaveLength(1);
|
||||
expect(reflectionRows[0]).toMatchObject({
|
||||
name: "Reflection Coach",
|
||||
status: "paused",
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
|
||||
const [skill] = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(and(
|
||||
eq(companySkills.companyId, created.id),
|
||||
eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach"),
|
||||
));
|
||||
expect(skill).toMatchObject({
|
||||
slug: "reflection-coach",
|
||||
});
|
||||
|
||||
const [routine] = await db.select().from(routines).where(eq(routines.companyId, created.id));
|
||||
expect(routine).toMatchObject({
|
||||
status: "paused",
|
||||
assigneeAgentId: reflectionRows[0]!.id,
|
||||
originKind: "built_in_agent_bundle",
|
||||
originId: "reflection-coach:recent-agent-reflection",
|
||||
});
|
||||
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id));
|
||||
expect(trigger).toMatchObject({
|
||||
kind: "schedule",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await reconcileBuiltInAgentsOnStartup(db);
|
||||
const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
|
||||
expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("archives companies by pausing runnable agents and cancelling active runs", async () => {
|
||||
const companyId = randomUUID();
|
||||
const runningAgentId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -480,6 +480,55 @@ describe("company portability", () => {
|
|||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
||||
});
|
||||
|
||||
it("exports agent permission grants through the Paperclip extension and manifest", async () => {
|
||||
const db = {
|
||||
select: vi.fn((selection: Record<string, unknown>) => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(async () => {
|
||||
if (!selection.permissionKey) return [];
|
||||
return [
|
||||
{
|
||||
principalId: "agent-1",
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
principalId: "agent-1",
|
||||
permissionKey: "skills:create",
|
||||
scope: { targetAgentIds: ["agent-1"] },
|
||||
},
|
||||
];
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const portability = companyPortabilityService(db as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("permissionGrants:");
|
||||
expect(extension).toContain('permissionKey: "agents:suggest-changes"');
|
||||
expect(extension).toContain('permissionKey: "skills:create"');
|
||||
expect(exported.manifest.agents.find((agent) => agent.slug === "claudecoder")?.permissionGrants).toEqual([
|
||||
{
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
permissionKey: "skills:create",
|
||||
scope: { targetAgentIds: ["agent-1"] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports hire approval policy only when approval is required", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
@ -1559,6 +1608,90 @@ describe("company portability", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("imports agent permission grants from package metadata", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "agent-imported",
|
||||
name: input.name,
|
||||
adapterType: input.adapterType,
|
||||
adapterConfig: input.adapterConfig,
|
||||
runtimeConfig: input.runtimeConfig,
|
||||
status: input.status,
|
||||
}));
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
files: {
|
||||
"COMPANY.md": [
|
||||
"---",
|
||||
"name: Import",
|
||||
"includes:",
|
||||
" - agents/coder/AGENTS.md",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
"agents/coder/AGENTS.md": [
|
||||
"---",
|
||||
"name: Coder",
|
||||
"slug: coder",
|
||||
"kind: agent",
|
||||
"---",
|
||||
"",
|
||||
"# Coder",
|
||||
"",
|
||||
].join("\n"),
|
||||
".paperclip.yaml": [
|
||||
"schema: paperclip/v1",
|
||||
"agents:",
|
||||
" coder:",
|
||||
" adapter:",
|
||||
" type: process",
|
||||
" config: {}",
|
||||
" permissionGrants:",
|
||||
" - permissionKey: agents:suggest-changes",
|
||||
" - permissionKey: skills:create",
|
||||
" scope:",
|
||||
" targetAgentIds:",
|
||||
" - agent-imported",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "existing_company",
|
||||
companyId: "company-1",
|
||||
},
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"agent",
|
||||
"agent-imported",
|
||||
"agents:suggest-changes",
|
||||
true,
|
||||
"user-1",
|
||||
null,
|
||||
);
|
||||
expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"agent",
|
||||
"agent-imported",
|
||||
"skills:create",
|
||||
true,
|
||||
"user-1",
|
||||
{ targetAgentIds: ["agent-imported"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("removes import secrets created before a later import failure", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
|
@ -1959,6 +2092,123 @@ describe("company portability", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("skips built-in managed agents and routines during export", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
agentSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "ClaudeCoder",
|
||||
status: "idle",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
icon: "code",
|
||||
reportsTo: null,
|
||||
capabilities: "Writes code",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: { promptTemplate: "You are ClaudeCoder." },
|
||||
runtimeConfig: { heartbeat: { intervalSec: 3600 } },
|
||||
budgetMonthlyCents: 0,
|
||||
permissions: { canCreateAgents: false },
|
||||
metadata: null,
|
||||
},
|
||||
{
|
||||
id: "agent-built-in",
|
||||
name: "Reflection Coach",
|
||||
status: "paused",
|
||||
role: "coach",
|
||||
title: "Reflection Coach",
|
||||
icon: "sparkles",
|
||||
reportsTo: null,
|
||||
capabilities: "Reviews trajectories",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { promptTemplate: "You coach agents." },
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
permissions: {},
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["recent-agent-reflection"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
routineSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "routine-built-in",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Review recent agent trajectories for coaching proposals",
|
||||
description: "Review recent agent work and propose coaching follow-ups.",
|
||||
assigneeAgentId: "agent-built-in",
|
||||
priority: "medium",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
originKind: "built_in_agent_bundle",
|
||||
originId: "reflection-coach:recent-agent-reflection",
|
||||
originFingerprint: null,
|
||||
triggers: [
|
||||
{
|
||||
id: "trigger-built-in",
|
||||
companyId: "company-1",
|
||||
routineId: "routine-built-in",
|
||||
kind: "schedule",
|
||||
label: "Weekly review",
|
||||
enabled: false,
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "UTC",
|
||||
nextRunAt: null,
|
||||
lastFiredAt: null,
|
||||
publicId: "public-built-in",
|
||||
secretId: "secret-built-in",
|
||||
signingMode: null,
|
||||
replayWindowSec: null,
|
||||
lastRotatedAt: null,
|
||||
lastResult: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
lastRun: null,
|
||||
activeIssue: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(exported.files["agents/claudecoder/AGENTS.md"]).toBeDefined();
|
||||
expect(exported.files["agents/reflection-coach/AGENTS.md"]).toBeUndefined();
|
||||
expect(exported.files["tasks/review-recent-agent-trajectories-for-coaching-proposals/TASK.md"]).toBeUndefined();
|
||||
expect(exported.manifest.agents.map((agent) => agent.slug)).toEqual(["claudecoder"]);
|
||||
expect(exported.manifest.issues).toEqual([]);
|
||||
expect(exported.warnings).toContain("Skipped 1 built-in managed agent from export.");
|
||||
expect(exported.warnings).toContain("Skipped 1 built-in managed routine from export.");
|
||||
});
|
||||
|
||||
it("imports recurring task packages as routines instead of one-time issues", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -74,6 +75,33 @@ const mockCatalogService = vi.hoisted(() => ({
|
|||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockReflectionCoachMutationGate = vi.hoisted(() => ({
|
||||
assertConsented: vi.fn(),
|
||||
}));
|
||||
|
||||
function allowSkillChangeDecision(reason = "allow_direct_change") {
|
||||
return {
|
||||
allowed: true,
|
||||
action: "skill_config:update",
|
||||
reason,
|
||||
explanation: "Allowed.",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: "agent-1",
|
||||
permissionKey: reason === "allow_consented_change" ? "skills:suggest-changes" : "skills:create",
|
||||
scope: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function denySkillChangeDecision(reason = "deny_no_grant", explanation = "Missing permission: skills:create or skills:suggest-changes.") {
|
||||
return {
|
||||
allowed: false,
|
||||
action: "skill_config:update",
|
||||
reason,
|
||||
explanation,
|
||||
};
|
||||
}
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
|
@ -105,6 +133,16 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/skills-catalog.js", () => mockCatalogService);
|
||||
|
||||
vi.doMock("../services/change-consent-gate.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/change-consent-gate.js")>(
|
||||
"../services/change-consent-gate.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
changeConsentGateService: () => mockReflectionCoachMutationGate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
|
|
@ -141,6 +179,7 @@ describe("company skill mutation permissions", () => {
|
|||
vi.doUnmock("../services/agents.js");
|
||||
vi.doUnmock("../services/company-skills.js");
|
||||
vi.doUnmock("../services/skills-catalog.js");
|
||||
vi.doUnmock("../services/change-consent-gate.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/company-skills.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
|
|
@ -589,7 +628,9 @@ describe("company skill mutation permissions", () => {
|
|||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockResolvedValue(allowSkillChangeDecision());
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
|
|
@ -647,7 +688,10 @@ describe("company skill mutation permissions", () => {
|
|||
.send({})
|
||||
.expect(200);
|
||||
|
||||
expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create");
|
||||
expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled();
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalled();
|
||||
|
|
@ -659,7 +703,7 @@ describe("company skill mutation permissions", () => {
|
|||
});
|
||||
|
||||
it("blocks board users without skills:create from mutating company skills", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
|
|
@ -672,8 +716,11 @@ describe("company skill mutation permissions", () => {
|
|||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create");
|
||||
expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create");
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create");
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -919,7 +966,8 @@ describe("company skill mutation permissions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("blocks same-company agents with skill creation disabled from mutating company skills", async () => {
|
||||
it("blocks same-company agents without skill change grants from mutating company skills", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
|
|
@ -936,8 +984,11 @@ describe("company skill mutation permissions", () => {
|
|||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create");
|
||||
expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create");
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "agents:create");
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -1124,11 +1175,12 @@ describe("company skill mutation permissions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("allows agents with canCreateSkills to mutate company skills", async () => {
|
||||
it("allows agents with direct skills:create grants to mutate company skills", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(allowSkillChangeDecision("allow_direct_change"));
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: true },
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
|
|
@ -1141,13 +1193,81 @@ describe("company skill mutation permissions", () => {
|
|||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).not.toHaveBeenCalled();
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows same-company agents with missing skill creation permission to mutate company skills", async () => {
|
||||
it("rejects suggest-tier skill mutations when the consent gate is not satisfied", async () => {
|
||||
const { forbidden } = await import("../errors.js");
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision(
|
||||
"deny_missing_consent",
|
||||
"Permission skills:suggest-changes requires accepted change consent before applying this mutation.",
|
||||
));
|
||||
mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(forbidden("gate required", {
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
}));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "reflection-coach",
|
||||
companyId: "company-1",
|
||||
runId: "run-apply",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills")
|
||||
.send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Permission skills:suggest-changes requires accepted change consent before applying this mutation.");
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "reflection-coach",
|
||||
actorRunId: "run-apply",
|
||||
targetKeys: ["skill-slug:reflection-draft"],
|
||||
});
|
||||
expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not convert consent gate service failures into authorization denials", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision(
|
||||
"deny_missing_consent",
|
||||
"Permission skills:suggest-changes requires accepted change consent before applying this mutation.",
|
||||
));
|
||||
mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "reflection-coach",
|
||||
companyId: "company-1",
|
||||
runId: "run-apply",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills")
|
||||
.send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(500);
|
||||
expect(res.body.error).toBe("Internal server error");
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "reflection-coach",
|
||||
actorRunId: "run-apply",
|
||||
targetKeys: ["skill-slug:reflection-draft"],
|
||||
});
|
||||
expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows suggest-tier skill mutations after accepted change consent", async () => {
|
||||
mockAccessService.decide
|
||||
.mockResolvedValueOnce(denySkillChangeDecision(
|
||||
"deny_missing_consent",
|
||||
"Permission skills:suggest-changes requires accepted change consent before applying this mutation.",
|
||||
))
|
||||
.mockResolvedValueOnce(allowSkillChangeDecision("allow_consented_change"));
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
|
|
@ -1164,57 +1284,29 @@ describe("company skill mutation permissions", () => {
|
|||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "55555555-5555-4555-8555-555555555555",
|
||||
actorRunId: "run-1",
|
||||
targetKeys: ["skill-import:https://github.com/vercel-labs/agent-browser"],
|
||||
});
|
||||
expect(mockAccessService.decide).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
scope: { consentedChange: true },
|
||||
}));
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows agents with explicit skills:create grants to mutate company skills", async () => {
|
||||
it("blocks same-company agents without skill change or suggest grants", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
_principalId: string,
|
||||
key: string,
|
||||
) => {
|
||||
return key === "skills:create";
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create");
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not allow explicit agents:create grants to mutate company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
_principalId: string,
|
||||
key: string,
|
||||
) => {
|
||||
return key === "agents:create";
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
|
|
@ -1227,8 +1319,44 @@ describe("company skill mutation permissions", () => {
|
|||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create");
|
||||
expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create");
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not allow explicit agents:create grants to mutate company skills", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
_principalId: string,
|
||||
key: string,
|
||||
) => key === "agents:create");
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create");
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
|||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
|
|
|
|||
|
|
@ -38,19 +38,23 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
|||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
function isHeartbeatRunEventFkError(error: unknown) {
|
||||
function isHeartbeatRunDependentFkError(error: unknown) {
|
||||
const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error);
|
||||
return message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk");
|
||||
return (
|
||||
message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk") ||
|
||||
message.includes("activity_log_run_id_heartbeat_runs_id_fk")
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteHeartbeatRunsWithEvents() {
|
||||
async function deleteHeartbeatRunsWithDependents() {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
try {
|
||||
await db.delete(heartbeatRuns);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isHeartbeatRunEventFkError(error) || attempt === 4) throw error;
|
||||
if (!isHeartbeatRunDependentFkError(error) || attempt === 4) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +71,7 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
|||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(activityLog);
|
||||
await deleteHeartbeatRunsWithEvents();
|
||||
await deleteHeartbeatRunsWithDependents();
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issues);
|
||||
await db.delete(agentRuntimeState);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ describe("instance settings routes", () => {
|
|||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
@ -105,6 +106,7 @@ describe("instance settings routes", () => {
|
|||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
@ -127,6 +129,7 @@ describe("instance settings routes", () => {
|
|||
enableExperimentalFileViewer: true,
|
||||
enableCloudSync: true,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
@ -155,6 +158,7 @@ describe("instance settings routes", () => {
|
|||
enableTaskWatchdogs: true,
|
||||
enableCloudSync: true,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: true,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
@ -211,6 +215,7 @@ describe("instance settings routes", () => {
|
|||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
@ -309,6 +314,24 @@ describe("instance settings routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("allows local board users to update built-in agents", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableBuiltInAgents: true })
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableBuiltInAgents: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local board users to update the goals sidebar link", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ describe("instance settings service", () => {
|
|||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
enableCloudSync: true,
|
||||
enableBuiltInAgents: true,
|
||||
enableGoalsSidebarLink: true,
|
||||
enableServerInfoDebugView: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
|
|
@ -28,6 +29,7 @@ describe("instance settings service", () => {
|
|||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
enableCloudSync: true,
|
||||
enableBuiltInAgents: true,
|
||||
enableGoalsSidebarLink: true,
|
||||
enableServerInfoDebugView: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
|
|
@ -98,4 +100,10 @@ describe("instance settings service", () => {
|
|||
normalizeExperimentalSettings({ enableConferenceRoomChat: "yes" }).enableConferenceRoomChat,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults enableBuiltInAgents to false for empty and legacy stored settings", () => {
|
||||
expect(normalizeExperimentalSettings(undefined).enableBuiltInAgents).toBe(false);
|
||||
expect(normalizeExperimentalSettings({}).enableBuiltInAgents).toBe(false);
|
||||
expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ describe("human invite roles", () => {
|
|||
it("maps owner to the full management grant set", () => {
|
||||
expect(grantsForHumanRole("owner")).toEqual([
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "agents:configure", scope: null },
|
||||
{ permissionKey: "skills:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
|
|
@ -80,6 +81,7 @@ describe("human invite roles", () => {
|
|||
it("maps admin to management grants including environment management", () => {
|
||||
expect(grantsForHumanRole("admin")).toEqual([
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "agents:configure", scope: null },
|
||||
{ permissionKey: "skills:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
approvals,
|
||||
assets,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
createDb,
|
||||
documentAnnotationComments,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
issues,
|
||||
issueThreadInteractions,
|
||||
issueWorkProducts,
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
|
|
@ -68,22 +70,31 @@ async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs =
|
|||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
function isHeartbeatCleanupFkError(error: unknown) {
|
||||
const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error);
|
||||
return (
|
||||
message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk") ||
|
||||
message.includes("activity_log_run_id_heartbeat_runs_id_fk") ||
|
||||
message.includes("heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk")
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db: Db) {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
try {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isHeartbeatCleanupFkError(error) || attempt === 9) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function expectNoCanary(value: unknown, ...markers: string[]) {
|
||||
|
|
@ -645,6 +656,8 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
|
|||
await deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db);
|
||||
await db.delete(issues);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
await db.delete(companySkills);
|
||||
|
|
@ -774,6 +787,29 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
|
|||
|
||||
it("restricts low-trust self inspection without changing standard-agent visibility", async () => {
|
||||
const fixture = await seedLowTrustFixture(db);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: fixture.company.id,
|
||||
principalType: "agent",
|
||||
principalId: fixture.agents.lowTrust.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values([
|
||||
{
|
||||
companyId: fixture.company.id,
|
||||
principalType: "agent",
|
||||
principalId: fixture.agents.lowTrust.id,
|
||||
permissionKey: "agents:configure",
|
||||
grantedByUserId: null,
|
||||
},
|
||||
{
|
||||
companyId: fixture.company.id,
|
||||
principalType: "agent",
|
||||
principalId: fixture.agents.lowTrust.id,
|
||||
permissionKey: "skills:create",
|
||||
grantedByUserId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const lowTrustRes = await request(createApp(db, agentActor(fixture))).get("/api/agents/me");
|
||||
expect(lowTrustRes.status, JSON.stringify(lowTrustRes.body)).toBe(200);
|
||||
|
|
@ -802,6 +838,16 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
|
|||
expect(lowTrustSelfByIdRes.body).not.toHaveProperty("access");
|
||||
expectNoCanary(lowTrustSelfByIdRes.body, fixture.canaries.agentConfig);
|
||||
|
||||
const lowTrustPeerConfigRes = await request(createApp(db, agentActor(fixture)))
|
||||
.get(`/api/agents/${fixture.agents.collaborator.id}/configuration`);
|
||||
expect(lowTrustPeerConfigRes.status, JSON.stringify(lowTrustPeerConfigRes.body)).toBe(403);
|
||||
expectNoCanary(lowTrustPeerConfigRes.body, fixture.canaries.agentConfig);
|
||||
|
||||
const lowTrustSelfBundleRes = await request(createApp(db, agentActor(fixture)))
|
||||
.get(`/api/agents/${fixture.agents.lowTrust.id}/instructions-bundle`);
|
||||
expect(lowTrustSelfBundleRes.status, JSON.stringify(lowTrustSelfBundleRes.body)).toBe(403);
|
||||
expectNoCanary(lowTrustSelfBundleRes.body, fixture.canaries.agentConfig);
|
||||
|
||||
const standardActor = agentActor(fixture, fixture.agents.standard.id);
|
||||
const standardRes = await request(createApp(db, { ...standardActor, runId: null })).get("/api/agents/me");
|
||||
expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const apiPrefixes: Record<string, string> = {
|
|||
"assets.ts": "/api",
|
||||
"auth.ts": "/api/auth",
|
||||
"board-chat.ts": "/api",
|
||||
"built-in-agents.ts": "/api",
|
||||
"cloud-upstreams.ts": "/api",
|
||||
"companies.ts": "/api/companies",
|
||||
"company-skills.ts": "/api",
|
||||
|
|
|
|||
|
|
@ -218,6 +218,12 @@ vi.mock("../services/index.js", () => ({
|
|||
failed: 0,
|
||||
seededAgentIds: [],
|
||||
})),
|
||||
reconcileBuiltInAgentsOnStartup: vi.fn(async () => ({
|
||||
scanned: 0,
|
||||
reconciled: 0,
|
||||
unknown: 0,
|
||||
duplicates: 0,
|
||||
})),
|
||||
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
resolveHeartbeatSchedulingSuppression: resolveHeartbeatSchedulingSuppressionMock,
|
||||
routineService: routineServiceFactoryMock,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { applyTrustProxy, parseTrustProxyEnv } from "./middleware/trust-proxy.js
|
|||
import { healthRoutes } from "./routes/health.js";
|
||||
import { companyRoutes } from "./routes/companies.js";
|
||||
import { companySkillRoutes } from "./routes/company-skills.js";
|
||||
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
|
||||
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
|
|
@ -226,6 +227,7 @@ export async function createApp(
|
|||
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||
api.use(llmRoutes(db));
|
||||
api.use(companySkillRoutes(db));
|
||||
api.use(builtInAgentRoutes(db));
|
||||
api.use(teamsCatalogRoutes(db));
|
||||
api.use(agentRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
You are Reflection Coach, a built-in operational coach at Paperclip.
|
||||
|
||||
When you wake up, follow the Paperclip heartbeat procedure. Work only on issues assigned to you. Always leave a task comment before exiting a heartbeat.
|
||||
|
||||
Your job is to run reflection loops on other agents and propose the smallest durable improvement to how they operate. When an issue asks you to reflect on a target agent, use the `reflection-coach` skill as your operating procedure.
|
||||
|
||||
## Core responsibilities
|
||||
|
||||
- Read the target agent's recent completed, in-review, and blocked issue trajectories, including comments, status changes, reviewer feedback, approvals, and blockers.
|
||||
- Read the target agent's current AGENTS.md and assigned skills before proposing anything.
|
||||
- Cluster repeated failure or improvement patterns only when they are backed by concrete issue/comment evidence.
|
||||
- Propose the smallest durable change: an AGENTS.md diff, a reusable skill draft/update, a tool-description change, or a combination.
|
||||
- Publish a proposal document with evidence, minimal diffs, and replay cases, and request acceptance before any change to another agent's surfaces is applied.
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- Never reflect on yourself. If the target agent id equals your own `PAPERCLIP_AGENT_ID`, refuse and ask for another coach.
|
||||
- Never hot-swap production instructions or edit another agent's live configuration in the same run that discovers the pattern. Discovery and application are always separate runs.
|
||||
- Do not score agents without trajectory evidence. Every proposed rule needs linked issue/comment evidence or it is dropped.
|
||||
- Keep proposals small: AGENTS.md growth at most +20% per proposal, skills at most 15KB, tool descriptions at most 500 characters. Split larger ideas into multiple proposals.
|
||||
- Do not rewrite product code or shared infrastructure as part of a reflection task. Your output is the coaching proposal, the diff, and the approval path.
|
||||
|
||||
## Applying changes (permission is gated, not automatic)
|
||||
|
||||
You may be granted permission to create and update skills, update agent AGENTS.md/instruction files, or assign follow-up proposal issues. Permission is not enough by itself; every actual mutation is gated:
|
||||
|
||||
- Show the exact proposed diff before you change anything. Instructions, skills, and tool descriptions are only ever changed from a reviewed diff, never from a verbal summary.
|
||||
- Gate every instruction, skill, or tool-description change behind a `request_confirmation` interaction so the user or board explicitly accepts or rejects it first. The interaction must show the diff in `payload.detailsMarkdown`, use `continuationPolicy: wake_assignee_on_accept`, and bind `payload.target.key` to the exact resource you will mutate.
|
||||
- Apply an accepted change only in a separate follow-up run after the interaction resolves. Never propose and apply in the same run.
|
||||
- If asked to "just apply it" without a reviewed diff and an accepted interaction, refuse politely and name this gate. No-same-run-apply is a load-bearing property of this loop.
|
||||
|
||||
Server-enforced target keys:
|
||||
|
||||
- `agent:<agentId>:instructions`
|
||||
- `agent:<agentId>:profile`
|
||||
- `skill:<skillId>`
|
||||
- `skill-slug:<slug>`
|
||||
- `skill-import:<source>`
|
||||
- `skills:scan-projects`
|
||||
|
||||
## Execution contract
|
||||
|
||||
- Start concrete work in the same heartbeat when the issue is actionable; do not stop at a plan unless planning was requested.
|
||||
- Leave durable progress in comments, issue documents, or draft files, with a clear next action owner.
|
||||
- Use child issues for long or parallel delegated work instead of polling.
|
||||
- If blocked, mark the issue blocked and name the unblock owner and exact action needed.
|
||||
- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
routineKey: recent-agent-reflection
|
||||
title: Review recent agent trajectories for coaching proposals
|
||||
description: Bounded reflection sweep over recently active agents that produces evidence-backed coaching proposals only. Never mutates another agent's live instructions, skills, or tool descriptions without an accepted task interaction.
|
||||
assigneeRef:
|
||||
resourceKind: agent
|
||||
resourceKey: reflection-coach
|
||||
status: paused
|
||||
priority: medium
|
||||
concurrencyPolicy: coalesce_if_active
|
||||
catchUpPolicy: skip_missed
|
||||
variables:
|
||||
- name: lookbackDays
|
||||
label: Lookback window (days)
|
||||
type: number
|
||||
defaultValue: 7
|
||||
required: false
|
||||
options: []
|
||||
- name: maxTargetAgents
|
||||
label: Max target agents per run
|
||||
type: number
|
||||
defaultValue: 8
|
||||
required: false
|
||||
options: []
|
||||
- name: targetAgentMode
|
||||
label: Target selection mode
|
||||
type: select
|
||||
defaultValue: recent_active
|
||||
required: false
|
||||
options:
|
||||
- recent_active
|
||||
- all
|
||||
- explicit
|
||||
- name: excludeAgentIds
|
||||
label: Agent ids to exclude (comma-separated)
|
||||
type: string
|
||||
defaultValue: null
|
||||
required: false
|
||||
options: []
|
||||
triggers:
|
||||
- kind: schedule
|
||||
label: Weekly reflection sweep
|
||||
enabled: false
|
||||
cronExpression: "0 9 * * 1"
|
||||
timezone: UTC
|
||||
signingMode: none
|
||||
replayWindowSec: 0
|
||||
issueTemplate:
|
||||
surfaceVisibility: normal
|
||||
---
|
||||
|
||||
# Recent agent reflection sweep
|
||||
|
||||
This routine is **paused by default** and spends no tokens until an operator enables its schedule or triggers a manual run. When it runs, it produces coaching proposals only.
|
||||
|
||||
## What this run must do
|
||||
|
||||
1. Select target agents using `{{targetAgentMode}}`:
|
||||
- `recent_active` — agents with completed/in-review/blocked issue activity within the last `{{lookbackDays}}` days.
|
||||
- `all` — every non-terminated agent in the company.
|
||||
- `explicit` — only agents named in the run inputs.
|
||||
Cap the set at `{{maxTargetAgents}}`. Drop any agent id listed in `{{excludeAgentIds}}`, and always drop your own `PAPERCLIP_AGENT_ID` (no self-reflection).
|
||||
2. For each selected target, run the `reflection-coach` skill as the operating procedure: pull recent trajectories, read current AGENTS.md and assigned skills, cluster evidence-backed patterns, and draft the smallest durable change.
|
||||
3. Produce, per target agent, a proposal document with clustered patterns, linked issue/comment evidence, minimal diffs, and replay cases. Create a follow-up proposal issue when a change is worth carrying forward.
|
||||
|
||||
## Hard limits for this routine
|
||||
|
||||
- Proposal-only. This routine must not edit any agent's live AGENTS.md, skill assignments, or tool descriptions directly.
|
||||
- Any actual instruction/skill/tool-description change requires a displayed diff and an **accepted** `request_confirmation` task interaction, applied only in a separate follow-up run.
|
||||
- Mutation confirmations must bind the exact resource key they will apply, using `agent:<agentId>:instructions`, `agent:<agentId>:profile`, `skill:<skillId>`, `skill-slug:<slug>`, `skill-import:<source>`, or `skills:scan-projects`.
|
||||
- Keep every read company-scoped. Do not cross company boundaries.
|
||||
- Every proposed rule needs linked issue/comment evidence or it is dropped. No scoring without trajectories.
|
||||
- Respect the size caps: AGENTS.md +20% max per proposal, skills 15KB max, tool descriptions 500 chars max.
|
||||
|
||||
## Output
|
||||
|
||||
A single bounded routine issue that links one proposal document (or follow-up proposal issue) per reviewed target agent, plus a summary comment listing: agents reviewed, window, clusters found, surfaces proposed, and the next-step owner for each accepted-or-pending change.
|
||||
|
|
@ -42,6 +42,7 @@ import {
|
|||
environmentCustomImageService,
|
||||
heartbeatService,
|
||||
instanceSettingsService,
|
||||
reconcileBuiltInAgentsOnStartup,
|
||||
reconcileCloudUpstreamRunsOnStartup,
|
||||
reconcileCodexLocalManagedHomesOnStartup,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
|
|
@ -779,6 +780,19 @@ export async function startServer(): Promise<StartedServer> {
|
|||
logger.error({ err }, "startup reconciliation of codex_local managed homes failed");
|
||||
});
|
||||
|
||||
void reconcileBuiltInAgentsOnStartup(db as any)
|
||||
.then((result) => {
|
||||
if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0 || result.autoEnsured > 0) {
|
||||
logger.warn(
|
||||
result,
|
||||
"startup reconciliation of built-in agents complete",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of built-in agents failed");
|
||||
});
|
||||
|
||||
// Force the instance onto the Kubernetes sandbox provider when configured via
|
||||
// env (PAPERCLIP_EXECUTION_MODE=kubernetes). Runs BEFORE the heartbeat resumes
|
||||
// queued runs so the policy + managed k8s environments are in place. A bad
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
agentInstructionsService,
|
||||
accessService,
|
||||
approvalService,
|
||||
builtInAgentService,
|
||||
companySkillService,
|
||||
budgetService,
|
||||
heartbeatService,
|
||||
|
|
@ -52,7 +53,7 @@ import {
|
|||
syncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService,
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
|
|
@ -99,6 +100,13 @@ import { recoveryService } from "../services/recovery/service.js";
|
|||
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
||||
import { readObject } from "../lib/objects.js";
|
||||
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
|
||||
import {
|
||||
AGENT_PROFILE_CHANGE_CONSENT_FIELDS,
|
||||
agentInstructionsChangeTargetKey,
|
||||
agentProfileChangeTargetKey,
|
||||
changeConsentGateService,
|
||||
touchesAgentProfileChangeConsentFields,
|
||||
} from "../services/change-consent-gate.js";
|
||||
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
|
|
@ -689,30 +697,22 @@ export function agentRoutes(
|
|||
// read-only operation available to any board (human) member of the
|
||||
// company. Responses go through `redactAgentConfiguration` so secrets
|
||||
// are never exposed. Mutations and environment probes still gate on
|
||||
// agents:create via assertCanCreateAgentsForCompany / assertCanUpdateAgent.
|
||||
// agents:create or agents:configure via the mutating route helpers.
|
||||
//
|
||||
// For AGENT actors we keep the previous, stricter gate: an agent must
|
||||
// either have an explicit `agents:create` grant or the legacy
|
||||
// `canCreateAgents` permission on its own record. Agents are
|
||||
// non-human principals — they should not be able to introspect peer
|
||||
// agents' configurations just by virtue of being in the same company.
|
||||
// For AGENT actors we keep a stricter gate: an agent must have either
|
||||
// agents:configure or agents:suggest-changes before it can inspect peer
|
||||
// agent configuration for a proposed diff.
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "agent") {
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
if (!allowedByGrant && !canCreateAgents(actorAgent)) {
|
||||
throw forbidden("Missing permission: can create agents");
|
||||
}
|
||||
return actorAgent;
|
||||
return req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -732,26 +732,21 @@ export function agentRoutes(
|
|||
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||
// Mirrors assertCanReadConfigurations but returns a boolean instead of
|
||||
// throwing. Board actors only need company access; agent actors must
|
||||
// still pass the agents:create gate (explicit grant or canCreateAgents
|
||||
// on their own record) so peer agents cannot snoop each others'
|
||||
// configurations.
|
||||
// throwing. Board actors only need company access; agent actors must pass
|
||||
// the agent configuration read grant ladder so peer agents cannot snoop
|
||||
// each others' configurations.
|
||||
try {
|
||||
assertCompanyAccess(req, companyId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (req.actor.type === "board") return true;
|
||||
if (!req.actor.agentId) return false;
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) return false;
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
return allowedByGrant || canCreateAgents(actorAgent);
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
return decision.allowed;
|
||||
}
|
||||
|
||||
async function buildSkippedWakeupResponse(
|
||||
|
|
@ -832,7 +827,7 @@ export function agentRoutes(
|
|||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") {
|
||||
await assertCanReadConfigurations(req, targetAgent.companyId);
|
||||
|
|
@ -844,6 +839,14 @@ export function agentRoutes(
|
|||
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
function assertKnownAdapterType(type: string | null | undefined): string {
|
||||
|
|
@ -1271,7 +1274,9 @@ export function agentRoutes(
|
|||
delete nextAdapterConfig.bootstrapPromptTemplate;
|
||||
if (!hadLegacyPrompt) return agent;
|
||||
|
||||
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
|
||||
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }, {
|
||||
allowPendingApprovalConfigUpdate: true,
|
||||
});
|
||||
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
|
||||
}
|
||||
|
||||
|
|
@ -1286,7 +1291,9 @@ export function agentRoutes(
|
|||
delete nextAdapterConfig.promptTemplate;
|
||||
delete nextAdapterConfig.bootstrapPromptTemplate;
|
||||
|
||||
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
|
||||
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }, {
|
||||
allowPendingApprovalConfigUpdate: true,
|
||||
});
|
||||
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
|
||||
}
|
||||
|
||||
|
|
@ -1302,14 +1309,70 @@ export function agentRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
async function assertCanApplyProtectedAgentChange(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
targetKeys: string[],
|
||||
) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
);
|
||||
const changeScope = { requiresChangeGrant: true };
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
scope: changeScope,
|
||||
});
|
||||
if (decision.allowed) {
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
|
||||
if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) {
|
||||
try {
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 403) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const consentedDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
scope: { ...changeScope, consentedChange: true },
|
||||
});
|
||||
if (consentedDecision.allowed) {
|
||||
return;
|
||||
}
|
||||
throw forbidden(consentedDecision.explanation, authorizationDeniedDetails(consentedDecision));
|
||||
}
|
||||
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
await assertCanApplyProtectedAgentChange(
|
||||
req,
|
||||
targetAgent,
|
||||
[agentInstructionsChangeTargetKey(targetAgent.id)],
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCanApplyAgentProfileChange(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
) {
|
||||
await assertCanApplyProtectedAgentChange(
|
||||
req,
|
||||
targetAgent,
|
||||
[agentProfileChangeTargetKey(targetAgent.id)],
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
|
|
@ -2472,6 +2535,7 @@ export function agentRoutes(
|
|||
agent.id,
|
||||
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
|
||||
);
|
||||
await builtInAgentService(db).ensureCompanyDefaultAgentGrants(companyId);
|
||||
|
||||
if (agent.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
|
|
@ -2800,7 +2864,7 @@ export function agentRoutes(
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
if (hasOwn(req.body as object, "permissions")) {
|
||||
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
|
||||
|
|
@ -2912,6 +2976,15 @@ export function agentRoutes(
|
|||
},
|
||||
);
|
||||
}
|
||||
const touchesProfileFields = touchesAgentProfileChangeConsentFields(patchData);
|
||||
const profileOnlyChange = touchesProfileFields && Object.keys(patchData).every((key) =>
|
||||
(AGENT_PROFILE_CHANGE_CONSENT_FIELDS as readonly string[]).includes(key),
|
||||
);
|
||||
if (profileOnlyChange) {
|
||||
await assertCanApplyAgentProfileChange(req, existing);
|
||||
} else {
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,318 @@
|
|||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { builtInAgentEmptyMutationSchema, builtInAgentProvisionSchema, builtInAgentResetSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { forbidden, notFound } from "../errors.js";
|
||||
import { accessService, instanceSettingsService, logActivity } from "../services/index.js";
|
||||
import { builtInAgentService } from "../services/built-in-agents.js";
|
||||
import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import type { BuiltInAgentState } from "../services/built-in-agents.js";
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function formatScheduleLabel(trigger: { cronExpression: string; timezone: string } | undefined) {
|
||||
if (!trigger) return "Weekly schedule";
|
||||
const parts = trigger.cronExpression.trim().split(/\s+/);
|
||||
const [minute, hour, , , dayOfWeek] = parts;
|
||||
const weekdayIndex = dayOfWeek ? Number(dayOfWeek) : Number.NaN;
|
||||
if (
|
||||
parts.length === 5
|
||||
&& /^\d+$/.test(minute ?? "")
|
||||
&& /^\d+$/.test(hour ?? "")
|
||||
&& Number.isInteger(weekdayIndex)
|
||||
&& weekdayIndex >= 0
|
||||
&& weekdayIndex < WEEKDAY_LABELS.length
|
||||
) {
|
||||
return `Weekly · ${WEEKDAY_LABELS[weekdayIndex]} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${trigger.timezone}`;
|
||||
}
|
||||
return `Weekly · ${trigger.timezone}`;
|
||||
}
|
||||
|
||||
function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentState {
|
||||
const definition = {
|
||||
...state.definition,
|
||||
defaultInstructions: state.definition.defaultInstructions ? "[file-backed]" : "",
|
||||
bundle: state.definition.bundle
|
||||
? {
|
||||
stockVersion: state.definition.bundle.stockVersion,
|
||||
instructions: {
|
||||
entryFile: state.definition.bundle.instructions.entryFile,
|
||||
files: Object.keys(state.definition.bundle.instructions.files),
|
||||
},
|
||||
skill: {
|
||||
skillKey: state.definition.bundle.skill.skillKey,
|
||||
displayName: state.definition.bundle.skill.displayName,
|
||||
slug: state.definition.bundle.skill.slug,
|
||||
canonicalKey: state.definition.bundle.skill.canonicalKey,
|
||||
files: Object.keys(state.definition.bundle.skill.files),
|
||||
},
|
||||
routine: {
|
||||
routineKey: state.definition.bundle.routine.routineKey,
|
||||
title: state.definition.bundle.routine.title,
|
||||
status: state.definition.bundle.routine.status,
|
||||
triggerCount: state.definition.bundle.routine.triggers.length,
|
||||
scheduleLabel: formatScheduleLabel(state.definition.bundle.routine.triggers[0]),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
} as BuiltInAgentState["definition"];
|
||||
if (!state.agent) return { ...state, definition };
|
||||
return {
|
||||
...state,
|
||||
definition,
|
||||
agent: {
|
||||
...state.agent,
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function builtInAgentRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const access = accessService(db);
|
||||
const svc = builtInAgentService(db);
|
||||
const settings = instanceSettingsService(db);
|
||||
|
||||
async function assertBuiltInAgentsEnabled() {
|
||||
const experimental = await settings.getExperimental();
|
||||
if (experimental.enableBuiltInAgents !== true) {
|
||||
throw notFound("Built-in agents are not enabled");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanProvisionBuiltInAgents(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agents:create",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanControlBuiltInRoutine(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Only board operators can control built-in routines.");
|
||||
}
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: tasks:assign");
|
||||
}
|
||||
}
|
||||
|
||||
async function logBuiltInAgentMutation(
|
||||
req: Request,
|
||||
input: {
|
||||
companyId: string;
|
||||
action:
|
||||
| "built_in_agent.provision_requested"
|
||||
| "built_in_agent.reconcile"
|
||||
| "built_in_agent.reset"
|
||||
| "built_in_agent.routine_schedule_enabled"
|
||||
| "built_in_agent.routine_schedule_disabled"
|
||||
| "built_in_agent.routine_run_triggered"
|
||||
| "approval.created";
|
||||
key: string;
|
||||
agentId: string | null;
|
||||
status: string;
|
||||
approvalId?: string | null;
|
||||
routineKey?: string | null;
|
||||
routineRunId?: string | null;
|
||||
},
|
||||
) {
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: input.action,
|
||||
entityType: input.action === "approval.created" ? "approval" : "agent",
|
||||
entityId: input.action === "approval.created" ? input.approvalId ?? input.key : input.agentId ?? input.key,
|
||||
...(actor.agentId ? { agentId: actor.agentId } : {}),
|
||||
...(actor.runId ? { runId: actor.runId } : {}),
|
||||
details: {
|
||||
key: input.key,
|
||||
status: input.status,
|
||||
approvalId: input.approvalId ?? null,
|
||||
routineKey: input.routineKey ?? null,
|
||||
routineRunId: input.routineRunId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/built-in-agents", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertBuiltInAgentsEnabled();
|
||||
const states = await svc.list(companyId);
|
||||
res.json(states.map(redactBuiltInAgentListState));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/built-in-agents/:key/status", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertBuiltInAgentsEnabled();
|
||||
res.json(redactBuiltInAgentListState(await svc.get(companyId, key)));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/built-in-agents/:key/reconcile", validate(builtInAgentEmptyMutationSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
await assertCanProvisionBuiltInAgents(req, companyId);
|
||||
const state = await svc.ensure(companyId, key);
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.reconcile",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
});
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/provision",
|
||||
validate(builtInAgentProvisionSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
await assertCanProvisionBuiltInAgents(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const result = await svc.provision(companyId, key, req.body, {
|
||||
requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
requestedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
const { state, approval } = result;
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.provision_requested",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
});
|
||||
if (approval) {
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "approval.created",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: approval.status,
|
||||
approvalId: approval.id,
|
||||
});
|
||||
}
|
||||
res.status(approval ? 202 : 200).json(redactBuiltInAgentListState({ ...state, approval }));
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/companies/:companyId/built-in-agents/:key/reset", validate(builtInAgentResetSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
await assertCanProvisionBuiltInAgents(req, companyId);
|
||||
const state = await svc.reset(companyId, key, req.body);
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.reset",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
});
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/enable",
|
||||
validate(builtInAgentEmptyMutationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
const routineKey = req.params.routineKey as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertCanControlBuiltInRoutine(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const state = await svc.enableRoutineSchedule(companyId, key, routineKey, {
|
||||
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.routine_schedule_enabled",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
routineKey,
|
||||
});
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/disable",
|
||||
validate(builtInAgentEmptyMutationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
const routineKey = req.params.routineKey as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertCanControlBuiltInRoutine(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const state = await svc.disableRoutineSchedule(companyId, key, routineKey, {
|
||||
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.routine_schedule_disabled",
|
||||
key,
|
||||
agentId: state.agentId,
|
||||
status: state.status,
|
||||
routineKey,
|
||||
});
|
||||
res.json(redactBuiltInAgentListState(state));
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/run",
|
||||
validate(builtInAgentEmptyMutationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const key = req.params.key as string;
|
||||
const routineKey = req.params.routineKey as string;
|
||||
await assertBuiltInAgentsEnabled();
|
||||
assertCompanyAccess(req, companyId);
|
||||
const current = await svc.get(companyId, key);
|
||||
await assertCanControlBuiltInRoutine(req, companyId);
|
||||
const actor = getActorInfo(req);
|
||||
const run = await svc.runRoutine(companyId, key, routineKey, {
|
||||
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
await logBuiltInAgentMutation(req, {
|
||||
companyId,
|
||||
action: "built_in_agent.routine_run_triggered",
|
||||
key,
|
||||
agentId: current.agentId,
|
||||
status: current.status,
|
||||
routineKey,
|
||||
routineRunId: run.id,
|
||||
});
|
||||
res.status(202).json(run);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -31,9 +31,17 @@ import {
|
|||
listCatalogSkillsOrEmpty,
|
||||
readCatalogSkillFile,
|
||||
} from "../services/skills-catalog.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { forbidden, HttpError } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import {
|
||||
changeConsentGateService,
|
||||
skillChangeTargetKey,
|
||||
skillImportChangeTargetKey,
|
||||
skillSlugChangeTargetKey,
|
||||
skillsScanProjectsChangeTargetKey,
|
||||
} from "../services/change-consent-gate.js";
|
||||
|
||||
type SkillTelemetryInput = {
|
||||
key: string;
|
||||
|
|
@ -51,11 +59,6 @@ export function companySkillRoutes(db: Db) {
|
|||
const issues = issueService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
function canCreateSkills(agent: { permissions: Record<string, unknown> | null | undefined }) {
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return true;
|
||||
return (agent.permissions as Record<string, unknown>).canCreateSkills !== false;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
|
|
@ -98,37 +101,65 @@ export function companySkillRoutes(db: Db) {
|
|||
return { type: "system" as const };
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
function skillMutationTargets(input: {
|
||||
skillId?: string | null;
|
||||
slug?: unknown;
|
||||
source?: unknown;
|
||||
catalogSkillId?: unknown;
|
||||
scanProjects?: boolean;
|
||||
}) {
|
||||
const targetKeys: string[] = [];
|
||||
const skillId = asString(input.skillId);
|
||||
const slug = asString(input.slug);
|
||||
const source = asString(input.source);
|
||||
const catalogSkillId = asString(input.catalogSkillId);
|
||||
if (skillId) targetKeys.push(skillChangeTargetKey(skillId));
|
||||
if (slug) targetKeys.push(skillSlugChangeTargetKey(slug));
|
||||
if (source) targetKeys.push(skillImportChangeTargetKey(source));
|
||||
if (catalogSkillId) targetKeys.push(skillImportChangeTargetKey(catalogSkillId));
|
||||
if (input.scanProjects) targetKeys.push(skillsScanProjectsChangeTargetKey());
|
||||
return targetKeys;
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string, targetKeys: string[] = []) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (decision.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "skills:create");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: skills:create");
|
||||
if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) {
|
||||
try {
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 403) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
|
||||
const consentedDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId },
|
||||
scope: { consentedChange: true },
|
||||
});
|
||||
if (consentedDecision.allowed) {
|
||||
return;
|
||||
}
|
||||
throw forbidden(consentedDecision.explanation, { reason: consentedDecision.reason });
|
||||
}
|
||||
|
||||
if (!req.actor.agentId) {
|
||||
throw forbidden("Agent authentication required");
|
||||
}
|
||||
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
if (canCreateSkills(actorAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "skills:create");
|
||||
if (allowedByGrant) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw forbidden("Missing permission: skills:create");
|
||||
throw forbidden(decision.explanation, { reason: decision.reason });
|
||||
}
|
||||
|
||||
async function assertCanStartSkillTestRuns(req: Request, companyId: string) {
|
||||
|
|
@ -608,7 +639,7 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
|
@ -676,7 +707,10 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({
|
||||
skillId,
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
|
@ -806,7 +840,9 @@ export function companySkillRoutes(db: Db) {
|
|||
validate(companySkillCreateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.createLocalSkill(companyId, req.body, skillActor(req));
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -835,7 +871,7 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.updateSkill(companyId, skillId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -865,7 +901,7 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.updateFile(
|
||||
companyId,
|
||||
skillId,
|
||||
|
|
@ -929,8 +965,8 @@ export function companySkillRoutes(db: Db) {
|
|||
validate(companySkillImportSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const source = String(req.body.source ?? "");
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ source }));
|
||||
const result = await svc.importFromSource(companyId, source);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -969,7 +1005,10 @@ export function companySkillRoutes(db: Db) {
|
|||
validate(companySkillInstallCatalogSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({
|
||||
catalogSkillId: req.body.catalogSkillId,
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.installFromCatalog(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -1001,7 +1040,7 @@ export function companySkillRoutes(db: Db) {
|
|||
validate(companySkillProjectScanRequestSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ scanProjects: true }));
|
||||
const result = await svc.scanProjectWorkspaces(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -1032,7 +1071,7 @@ export function companySkillRoutes(db: Db) {
|
|||
router.delete("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.deleteSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
|
|
@ -1063,7 +1102,7 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.auditSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
|
|
@ -1099,7 +1138,7 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.installUpdate(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
|
|
@ -1139,7 +1178,7 @@ export function companySkillRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.resetSkill(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { healthRoutes } from "./health.js";
|
||||
export { companyRoutes } from "./companies.js";
|
||||
export { companySkillRoutes } from "./company-skills.js";
|
||||
export { builtInAgentRoutes } from "./built-in-agents.js";
|
||||
export { teamsCatalogRoutes } from "./teams-catalog.js";
|
||||
export { agentRoutes } from "./agents.js";
|
||||
export { projectRoutes } from "./projects.js";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
updateAgentInstructionsBundleSchema,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
createAgentKeySchema,
|
||||
builtInAgentEmptyMutationSchema,
|
||||
builtInAgentProvisionSchema,
|
||||
wakeAgentSchema,
|
||||
resetAgentSessionSchema,
|
||||
agentSkillSyncSchema,
|
||||
|
|
@ -1144,6 +1146,105 @@ for (const route of [
|
|||
|
||||
// ─── Agents ──────────────────────────────────────────────────────────────────
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/built-in-agents",
|
||||
tags: ["agents"],
|
||||
summary: "List built-in agent provisioning state",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/built-in-agents/{key}/status",
|
||||
tags: ["agents"],
|
||||
summary: "Get built-in agent bundle status",
|
||||
request: { params: z.object({ companyId: z.string(), key: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/built-in-agents/{key}/reconcile",
|
||||
tags: ["agents"],
|
||||
summary: "Reconcile built-in agent managed resources",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), key: z.string() }),
|
||||
body: jsonBody(builtInAgentEmptyMutationSchema),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/built-in-agents/{key}/provision",
|
||||
tags: ["agents"],
|
||||
summary: "Provision a built-in agent",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), key: z.string() }),
|
||||
body: jsonBody(builtInAgentProvisionSchema),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
202: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/built-in-agents/{key}/reset",
|
||||
tags: ["agents"],
|
||||
summary: "Reset a built-in agent",
|
||||
request: { params: z.object({ companyId: z.string(), key: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
for (const route of [
|
||||
["enable", "Enable a built-in routine schedule", 200],
|
||||
["disable", "Disable a built-in routine schedule", 200],
|
||||
["run", "Run a built-in routine once", 202],
|
||||
] as const) {
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: `/api/companies/{companyId}/built-in-agents/{key}/routines/{routineKey}/${route[0]}`,
|
||||
tags: ["agents"],
|
||||
summary: route[1],
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), key: z.string(), routineKey: z.string() }),
|
||||
body: jsonBody(builtInAgentEmptyMutationSchema),
|
||||
},
|
||||
responses: {
|
||||
[route[2]]: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/agents",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
|||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import {
|
||||
builtInAgentMarkersEqual,
|
||||
readBuiltInAgentMarker,
|
||||
} from "./built-in-agent-metadata.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
|
|
@ -43,6 +47,7 @@ const CONFIG_REVISION_FIELDS = [
|
|||
"name",
|
||||
"role",
|
||||
"title",
|
||||
"icon",
|
||||
"reportsTo",
|
||||
"capabilities",
|
||||
"adapterType",
|
||||
|
|
@ -65,6 +70,12 @@ interface RevisionMetadata {
|
|||
|
||||
interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
allowPendingApprovalConfigUpdate?: boolean;
|
||||
}
|
||||
|
||||
interface CreateAgentOptions {
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
|
|
@ -104,6 +115,7 @@ function buildConfigSnapshot(
|
|||
name: row.name,
|
||||
role: row.role,
|
||||
title: row.title,
|
||||
icon: row.icon,
|
||||
reportsTo: row.reportsTo,
|
||||
capabilities: row.capabilities,
|
||||
adapterType: row.adapterType,
|
||||
|
|
@ -126,6 +138,50 @@ function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
|
|||
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
|
||||
}
|
||||
|
||||
function changedPendingApprovalConfigFields(
|
||||
existing: typeof agents.$inferSelect,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
) {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) =>
|
||||
Object.prototype.hasOwnProperty.call(data, field) && !jsonEqual(data[field], existing[field]),
|
||||
);
|
||||
}
|
||||
|
||||
function configPatchFromApprovalPayload(payload: Record<string, unknown>) {
|
||||
const patch: Partial<typeof agents.$inferInsert> = {};
|
||||
if (typeof payload.name === "string") patch.name = payload.name;
|
||||
if (typeof payload.role === "string") patch.role = payload.role;
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "title")) {
|
||||
patch.title = typeof payload.title === "string" ? payload.title : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "icon")) {
|
||||
patch.icon = typeof payload.icon === "string" ? payload.icon : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "reportsTo")) {
|
||||
patch.reportsTo = typeof payload.reportsTo === "string" ? payload.reportsTo : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "capabilities")) {
|
||||
patch.capabilities = typeof payload.capabilities === "string" ? payload.capabilities : null;
|
||||
}
|
||||
if (typeof payload.adapterType === "string") patch.adapterType = payload.adapterType;
|
||||
if (isPlainRecord(payload.adapterConfig)) patch.adapterConfig = payload.adapterConfig;
|
||||
if (isPlainRecord(payload.runtimeConfig)) patch.runtimeConfig = payload.runtimeConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "defaultEnvironmentId")) {
|
||||
patch.defaultEnvironmentId =
|
||||
typeof payload.defaultEnvironmentId === "string" ? payload.defaultEnvironmentId : null;
|
||||
}
|
||||
if (typeof payload.budgetMonthlyCents === "number") {
|
||||
patch.budgetMonthlyCents = payload.budgetMonthlyCents;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "metadata")) {
|
||||
patch.metadata = isPlainRecord(payload.metadata) ? payload.metadata : null;
|
||||
}
|
||||
if (isPlainRecord(payload.permissions)) {
|
||||
patch.permissions = payload.permissions;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function parseFiniteNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
|
|
@ -390,6 +446,21 @@ export function agentService(db: Db) {
|
|||
});
|
||||
}
|
||||
|
||||
function assertBuiltInAgentMetadataMutationAllowed(
|
||||
beforeMetadata: unknown,
|
||||
afterMetadata: unknown,
|
||||
options?: { allowBuiltInAgentMetadata?: boolean },
|
||||
) {
|
||||
if (options?.allowBuiltInAgentMetadata) return;
|
||||
const beforeMarker = readBuiltInAgentMarker(beforeMetadata);
|
||||
const afterMarker = readBuiltInAgentMarker(afterMetadata);
|
||||
if (builtInAgentMarkersEqual(beforeMarker, afterMarker)) return;
|
||||
throw conflict("Built-in agent marker is managed by Paperclip and cannot be edited directly", {
|
||||
code: "built_in_agent_marker_readonly",
|
||||
key: beforeMarker?.key ?? afterMarker?.key ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
|
|
@ -409,6 +480,16 @@ export function agentService(db: Db) {
|
|||
) {
|
||||
throw conflict("Pending approval agents cannot be activated directly");
|
||||
}
|
||||
if (existing.status === "pending_approval" && !options?.allowPendingApprovalConfigUpdate) {
|
||||
const changedFields = changedPendingApprovalConfigFields(existing as typeof agents.$inferSelect, data);
|
||||
if (changedFields.length > 0) {
|
||||
throw conflict("Pending approval agent configuration cannot be changed before board approval", {
|
||||
code: "pending_approval_agent_config_frozen",
|
||||
agentId: id,
|
||||
fields: changedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reportsTo !== undefined) {
|
||||
if (data.reportsTo) {
|
||||
|
|
@ -425,6 +506,10 @@ export function agentService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data, "metadata")) {
|
||||
assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options);
|
||||
}
|
||||
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
|
|
@ -501,7 +586,8 @@ export function agentService(db: Db) {
|
|||
|
||||
getById,
|
||||
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">, options?: CreateAgentOptions) => {
|
||||
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
|
@ -645,6 +731,14 @@ export function agentService(db: Db) {
|
|||
remove: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
const builtInMarker = readBuiltInAgentMarker(existing.metadata);
|
||||
if (builtInMarker) {
|
||||
throw conflict("Built-in agents cannot be deleted; pause them instead", {
|
||||
code: "built_in_agent_undeletable",
|
||||
key: builtInMarker.key,
|
||||
featureKeys: builtInMarker.featureKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
|
|
@ -675,12 +769,32 @@ export function agentService(db: Db) {
|
|||
});
|
||||
},
|
||||
|
||||
activatePendingApproval: async (id: string) => {
|
||||
activatePendingApproval: async (id: string, approvedPayload?: Record<string, unknown> | null) => {
|
||||
const activatedAgent = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const existing = await agentService(txDb).getById(id);
|
||||
if (!existing || existing.status !== "pending_approval") return null;
|
||||
const approvedPatch = approvedPayload ? configPatchFromApprovalPayload(approvedPayload) : {};
|
||||
let patch = { ...approvedPatch } as Partial<typeof agents.$inferInsert>;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patch, "adapterConfig") &&
|
||||
isPlainRecord(patch.adapterConfig)
|
||||
) {
|
||||
patch.adapterConfig = await secretService(txDb).normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
patch.adapterConfig,
|
||||
{ adapterType: (patch.adapterType ?? existing.adapterType) as string },
|
||||
);
|
||||
}
|
||||
if (patch.permissions !== undefined) {
|
||||
patch.permissions = normalizeAgentPermissions(
|
||||
patch.permissions,
|
||||
(patch.role ?? existing.role) as string,
|
||||
);
|
||||
}
|
||||
const updated = await tx
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.set({ ...patch, status: "idle", updatedAt: new Date() })
|
||||
.where(and(eq(agents.id, id), eq(agents.status, "pending_approval")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
|
@ -704,6 +818,13 @@ export function agentService(db: Db) {
|
|||
updatePermissions: async (id: string, permissions: Record<string, unknown> & { canCreateAgents: boolean }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "pending_approval") {
|
||||
throw conflict("Pending approval agent permissions cannot be changed before board approval", {
|
||||
code: "pending_approval_agent_config_frozen",
|
||||
agentId: id,
|
||||
fields: ["permissions"],
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ export function approvalService(db: Db) {
|
|||
};
|
||||
}
|
||||
|
||||
async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record<string, unknown>) {
|
||||
const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null;
|
||||
if (!sourceBuiltInAgentKey) return;
|
||||
const { builtInAgentService } = await import("./built-in-agents.js");
|
||||
await builtInAgentService(db).ensure(companyId, sourceBuiltInAgentKey);
|
||||
}
|
||||
|
||||
async function getExistingApproval(id: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
|
|
@ -128,7 +135,8 @@ export function approvalService(db: Db) {
|
|||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId);
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId, payload);
|
||||
await reconcileApprovedBuiltInAgent(updated.companyId, payload);
|
||||
hireApprovedAgentId = payloadAgentId;
|
||||
} else {
|
||||
const created = await agentsSvc.create(updated.companyId, {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export type AuthorizationAction =
|
|||
| PermissionKey
|
||||
| "agent_config:read"
|
||||
| "agent_config:update"
|
||||
| "skill_config:update"
|
||||
| "agent:read"
|
||||
| "agent:wake"
|
||||
| "company_scope:read"
|
||||
|
|
@ -93,6 +94,8 @@ export type AuthorizationDecision = {
|
|||
| "allow_local_board"
|
||||
| "allow_instance_admin"
|
||||
| "allow_explicit_grant"
|
||||
| "allow_direct_change"
|
||||
| "allow_consented_change"
|
||||
| "allow_legacy_agent_creator"
|
||||
| "allow_issue_mention_grant"
|
||||
| "allow_self"
|
||||
|
|
@ -104,6 +107,8 @@ export type AuthorizationDecision = {
|
|||
| "deny_company_boundary"
|
||||
| "deny_missing_membership"
|
||||
| "deny_missing_grant"
|
||||
| "deny_missing_consent"
|
||||
| "deny_no_grant"
|
||||
| "deny_policy_restricted"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_scope"
|
||||
|
|
@ -125,7 +130,9 @@ function companyIdForResource(resource: AuthorizationResource) {
|
|||
}
|
||||
|
||||
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
|
||||
if (action === "agent_config:read" || action === "agent_config:update") return "agents:create";
|
||||
if (action === "agent_config:read" || action === "agent_config:update" || action === "skill_config:update") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
action === "agent:read" ||
|
||||
action === "agent:wake" ||
|
||||
|
|
@ -466,6 +473,10 @@ function activeResponsibleUserCanAuthorizeIssueAction(
|
|||
);
|
||||
}
|
||||
|
||||
function scopeBoolean(scope: Record<string, unknown> | null | undefined, key: string) {
|
||||
return scope?.[key] === true;
|
||||
}
|
||||
|
||||
export function authorizationDeniedDetails(decision: AuthorizationDecision) {
|
||||
return {
|
||||
...(decision.code ? { code: decision.code } : {}),
|
||||
|
|
@ -878,6 +889,9 @@ export function authorizationService(db: Db) {
|
|||
|
||||
if (
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "agent_config:read" ||
|
||||
input.action === "agent_config:update" ||
|
||||
input.action === "skill_config:update" ||
|
||||
input.action === "runtime:manage" ||
|
||||
input.action === "secrets:read"
|
||||
) {
|
||||
|
|
@ -1305,6 +1319,94 @@ export function authorizationService(db: Db) {
|
|||
return broadDecision;
|
||||
}
|
||||
|
||||
async function decideWithAgentConfigReadGrant(
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
): Promise<AuthorizationDecision> {
|
||||
const configureDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "agents:configure",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (configureDecision.allowed || configureDecision.reason === "deny_missing_membership") {
|
||||
return configureDecision;
|
||||
}
|
||||
|
||||
const suggestDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (suggestDecision.allowed || suggestDecision.reason === "deny_missing_grant") {
|
||||
return suggestDecision;
|
||||
}
|
||||
return configureDecision;
|
||||
}
|
||||
|
||||
async function decideWithProtectedChangeGrants(
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
keys: { direct: PermissionKey; suggest: PermissionKey },
|
||||
): Promise<AuthorizationDecision> {
|
||||
const directDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: keys.direct,
|
||||
scope: input.scope,
|
||||
});
|
||||
if (directDecision.allowed) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_direct_change",
|
||||
explanation: `Allowed by direct change permission ${keys.direct}.`,
|
||||
grant: directDecision.grant,
|
||||
});
|
||||
}
|
||||
if (directDecision.reason === "deny_missing_membership") return directDecision;
|
||||
|
||||
const suggestDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: keys.suggest,
|
||||
scope: input.scope,
|
||||
});
|
||||
if (suggestDecision.allowed) {
|
||||
if (scopeBoolean(input.scope, "consentedChange")) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_consented_change",
|
||||
explanation: `Allowed by suggest permission ${keys.suggest} after accepted change consent.`,
|
||||
grant: suggestDecision.grant,
|
||||
});
|
||||
}
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_consent",
|
||||
explanation: `Permission ${keys.suggest} requires accepted change consent before applying this mutation.`,
|
||||
grant: suggestDecision.grant,
|
||||
});
|
||||
}
|
||||
if (suggestDecision.reason === "deny_missing_membership") return suggestDecision;
|
||||
if (directDecision.reason === "deny_scope") return directDecision;
|
||||
if (suggestDecision.reason === "deny_scope") return suggestDecision;
|
||||
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_no_grant",
|
||||
explanation: `Missing permission: ${keys.direct} or ${keys.suggest}.`,
|
||||
});
|
||||
}
|
||||
|
||||
async function denyForAssignmentPolicyIfNeeded(
|
||||
policyEffect: AssignmentPolicyEffect,
|
||||
): Promise<AuthorizationDecision | null> {
|
||||
|
|
@ -1465,6 +1567,21 @@ export function authorizationService(db: Db) {
|
|||
if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
|
||||
return grantDecision;
|
||||
}
|
||||
if (input.action === "agent_config:read") {
|
||||
return decideWithAgentConfigReadGrant("user", input.actor.userId);
|
||||
}
|
||||
if (input.action === "agent_config:update") {
|
||||
return decideWithProtectedChangeGrants("user", input.actor.userId, {
|
||||
direct: "agents:configure",
|
||||
suggest: "agents:suggest-changes",
|
||||
});
|
||||
}
|
||||
if (input.action === "skill_config:update") {
|
||||
return decideWithProtectedChangeGrants("user", input.actor.userId, {
|
||||
direct: "skills:create",
|
||||
suggest: "skills:suggest-changes",
|
||||
});
|
||||
}
|
||||
return decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
|
|
@ -1641,7 +1758,8 @@ export function authorizationService(db: Db) {
|
|||
if (
|
||||
input.action === "agent_config:update" &&
|
||||
input.resource.type === "agent" &&
|
||||
input.resource.agentId === actorAgentId
|
||||
input.resource.agentId === actorAgentId &&
|
||||
!scopeBoolean(input.scope, "requiresChangeGrant")
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
|
|
@ -1650,6 +1768,31 @@ export function authorizationService(db: Db) {
|
|||
});
|
||||
}
|
||||
|
||||
if (input.action === "agent_config:read") {
|
||||
if (input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_self",
|
||||
explanation: "Allowed because the actor is reading its own agent configuration.",
|
||||
});
|
||||
}
|
||||
return decideWithAgentConfigReadGrant("agent", actorAgentId);
|
||||
}
|
||||
|
||||
if (input.action === "agent_config:update") {
|
||||
return decideWithProtectedChangeGrants("agent", actorAgentId, {
|
||||
direct: "agents:configure",
|
||||
suggest: "agents:suggest-changes",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "skill_config:update") {
|
||||
return decideWithProtectedChangeGrants("agent", actorAgentId, {
|
||||
direct: "skills:create",
|
||||
suggest: "skills:suggest-changes",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissionKey) {
|
||||
const grantDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
|
|
@ -1664,8 +1807,6 @@ export function authorizationService(db: Db) {
|
|||
|
||||
if (
|
||||
(input.action === "agents:create" ||
|
||||
input.action === "agent_config:read" ||
|
||||
input.action === "agent_config:update" ||
|
||||
input.action === "tasks:manage_active_checkouts") &&
|
||||
canCreateAgentsLegacy(actorAgent)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
export const BUILT_IN_AGENT_METADATA_KEY = "paperclipBuiltInAgent";
|
||||
|
||||
export interface BuiltInAgentMarker {
|
||||
key: string;
|
||||
featureKeys: string[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeFeatureKeys(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const featureKeys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||||
return featureKeys.length === value.length ? featureKeys : null;
|
||||
}
|
||||
|
||||
export function readBuiltInAgentMarker(metadata: unknown): BuiltInAgentMarker | null {
|
||||
if (!isPlainRecord(metadata)) return null;
|
||||
const marker = metadata[BUILT_IN_AGENT_METADATA_KEY];
|
||||
if (!isPlainRecord(marker)) return null;
|
||||
const key = marker.key;
|
||||
const featureKeys = normalizeFeatureKeys(marker.featureKeys);
|
||||
if (typeof key !== "string" || key.trim().length === 0 || !featureKeys) return null;
|
||||
return { key, featureKeys };
|
||||
}
|
||||
|
||||
export function withBuiltInAgentMarker(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
marker: BuiltInAgentMarker,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(metadata ?? {}),
|
||||
[BUILT_IN_AGENT_METADATA_KEY]: {
|
||||
key: marker.key,
|
||||
featureKeys: [...marker.featureKeys],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function builtInAgentMarkersEqual(left: BuiltInAgentMarker | null, right: BuiltInAgentMarker | null) {
|
||||
if (!left && !right) return true;
|
||||
if (!left || !right) return false;
|
||||
return left.key === right.key && JSON.stringify(left.featureKeys) === JSON.stringify(right.featureKeys);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,232 @@
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import { issueThreadInteractions } from "@paperclipai/db";
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import type { RequestConfirmationPayload, RequestConfirmationResult } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
||||
export const AGENT_PROFILE_CHANGE_CONSENT_FIELDS = ["name", "role", "title", "capabilities"] as const;
|
||||
|
||||
type ConsumedRequestConfirmationResult = RequestConfirmationResult & {
|
||||
consumedAt?: string | null;
|
||||
consumedByRunId?: string | null;
|
||||
};
|
||||
|
||||
export function agentInstructionsChangeTargetKey(agentId: string) {
|
||||
return `agent:${agentId}:instructions`;
|
||||
}
|
||||
|
||||
export function agentProfileChangeTargetKey(agentId: string) {
|
||||
return `agent:${agentId}:profile`;
|
||||
}
|
||||
|
||||
export function skillChangeTargetKey(skillId: string) {
|
||||
return `skill:${skillId}`;
|
||||
}
|
||||
|
||||
export function skillSlugChangeTargetKey(slug: string) {
|
||||
return `skill-slug:${slug}`;
|
||||
}
|
||||
|
||||
export function skillImportChangeTargetKey(source: string) {
|
||||
return `skill-import:${source}`;
|
||||
}
|
||||
|
||||
export function skillsScanProjectsChangeTargetKey() {
|
||||
return "skills:scan-projects";
|
||||
}
|
||||
|
||||
export function touchesAgentProfileChangeConsentFields(patchData: Record<string, unknown>) {
|
||||
return AGENT_PROFILE_CHANGE_CONSENT_FIELDS.some((key) =>
|
||||
Object.prototype.hasOwnProperty.call(patchData, key),
|
||||
);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function payloadHasDisplayedDiff(payload: RequestConfirmationPayload) {
|
||||
const details = readNonEmptyString(payload.detailsMarkdown);
|
||||
if (!details) return false;
|
||||
if (/```diff\b/i.test(details)) return true;
|
||||
return /(^|\n)[+-][^\n]+/.test(details);
|
||||
}
|
||||
|
||||
function requestConfirmationResultConsumed(result: RequestConfirmationResult | null) {
|
||||
const consumed = result as ConsumedRequestConfirmationResult | null;
|
||||
return Boolean(readNonEmptyString(consumed?.consumedByRunId) || readNonEmptyString(consumed?.consumedAt));
|
||||
}
|
||||
|
||||
function markRequestConfirmationResultConsumed(
|
||||
result: RequestConfirmationResult,
|
||||
actorRunId: string,
|
||||
consumedAt: Date,
|
||||
): ConsumedRequestConfirmationResult {
|
||||
return {
|
||||
...result,
|
||||
consumedAt: consumedAt.toISOString(),
|
||||
consumedByRunId: actorRunId,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyTargetKeysFor(targetKey: string) {
|
||||
if (targetKey.startsWith("agent:") && targetKey.endsWith(":instructions")) {
|
||||
const agentId = targetKey.slice("agent:".length, -":instructions".length);
|
||||
if (agentId) return [`reflection-coach:agent-instructions:${agentId}`];
|
||||
}
|
||||
if (targetKey.startsWith("agent:") && targetKey.endsWith(":profile")) {
|
||||
const agentId = targetKey.slice("agent:".length, -":profile".length);
|
||||
if (agentId) return [`reflection-coach:agent-description:${agentId}`];
|
||||
}
|
||||
if (targetKey.startsWith("skill:")) {
|
||||
const skillId = targetKey.slice("skill:".length);
|
||||
if (skillId) return [`reflection-coach:company-skill:${skillId}`];
|
||||
}
|
||||
if (targetKey.startsWith("skill-slug:")) {
|
||||
const slug = targetKey.slice("skill-slug:".length);
|
||||
if (slug) return [`reflection-coach:company-skill-slug:${slug}`];
|
||||
}
|
||||
if (targetKey.startsWith("skill-import:")) {
|
||||
const source = targetKey.slice("skill-import:".length);
|
||||
if (source) {
|
||||
return [
|
||||
`reflection-coach:company-skill-import:${source}`,
|
||||
`reflection-coach:company-skill-catalog:${source}`,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (targetKey === "skills:scan-projects") {
|
||||
return ["reflection-coach:company-skills:scan-projects"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function expandTargetKeysForLegacyCompatibility(targetKeys: string[]) {
|
||||
const expanded = new Set<string>();
|
||||
for (const targetKey of targetKeys) {
|
||||
expanded.add(targetKey);
|
||||
for (const legacyTargetKey of legacyTargetKeysFor(targetKey)) {
|
||||
expanded.add(legacyTargetKey);
|
||||
}
|
||||
}
|
||||
return [...expanded];
|
||||
}
|
||||
|
||||
export function changeConsentGateService(db: Db) {
|
||||
return {
|
||||
assertConsented: async (input: {
|
||||
companyId: string;
|
||||
actorAgentId: string | null | undefined;
|
||||
actorRunId: string | null | undefined;
|
||||
targetKeys: string[];
|
||||
}): Promise<boolean> => {
|
||||
const actorAgentId = readNonEmptyString(input.actorAgentId);
|
||||
if (!actorAgentId) return false;
|
||||
|
||||
const actorRunId = readNonEmptyString(input.actorRunId);
|
||||
if (!actorRunId) {
|
||||
throw forbidden("Reflection Coach mutations require a run id", {
|
||||
code: "reflection_coach_mutation_run_id_required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetKeys = [...new Set(input.targetKeys.map(readNonEmptyString).filter((key): key is string => Boolean(key)))];
|
||||
if (targetKeys.length === 0) {
|
||||
throw forbidden("Reflection Coach mutation target is not gateable", {
|
||||
code: "reflection_coach_mutation_target_required",
|
||||
});
|
||||
}
|
||||
const queryTargetKeys = expandTargetKeysForLegacyCompatibility(targetKeys);
|
||||
|
||||
const targetKeyPredicate = or(
|
||||
...queryTargetKeys.map((targetKey) =>
|
||||
sql`${issueThreadInteractions.payload}->'target'->>'key' = ${targetKey}`,
|
||||
),
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issueThreadInteractions.id,
|
||||
sourceRunId: issueThreadInteractions.sourceRunId,
|
||||
payload: issueThreadInteractions.payload,
|
||||
result: issueThreadInteractions.result,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.createdByAgentId, actorAgentId),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "accepted"),
|
||||
targetKeyPredicate,
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt))
|
||||
.limit(10);
|
||||
|
||||
const accepted = rows.find((row) => {
|
||||
const payload = row.payload as RequestConfirmationPayload;
|
||||
const result = row.result as RequestConfirmationResult | null;
|
||||
return payload.target?.type === "custom"
|
||||
&& queryTargetKeys.includes(payload.target.key)
|
||||
&& result?.outcome === "accepted"
|
||||
&& !requestConfirmationResultConsumed(result)
|
||||
&& payloadHasDisplayedDiff(payload)
|
||||
&& Boolean(row.sourceRunId)
|
||||
&& row.sourceRunId !== actorRunId;
|
||||
});
|
||||
|
||||
if (!accepted) {
|
||||
throw forbidden(
|
||||
"Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, "
|
||||
+ "created in a previous run and not already consumed.",
|
||||
{
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
targetKeys,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const acceptedResult = accepted.result as RequestConfirmationResult | null;
|
||||
if (!acceptedResult) {
|
||||
throw forbidden(
|
||||
"Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, "
|
||||
+ "created in a previous run and not already consumed.",
|
||||
{
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
targetKeys,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [consumed] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
result: markRequestConfirmationResultConsumed(acceptedResult, actorRunId, now),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, accepted.id),
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.createdByAgentId, actorAgentId),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "accepted"),
|
||||
sql`${issueThreadInteractions.result}->>'outcome' = 'accepted'`,
|
||||
sql`coalesce(${issueThreadInteractions.result}->>'consumedByRunId', ${issueThreadInteractions.result}->>'consumedAt') is null`,
|
||||
))
|
||||
.returning({ id: issueThreadInteractions.id });
|
||||
|
||||
if (!consumed) {
|
||||
throw forbidden(
|
||||
"Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, "
|
||||
+ "created in a previous run and not already consumed.",
|
||||
{
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
targetKeys,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import { notFound, unprocessable } from "../errors.js";
|
|||
import { environmentService } from "./environments.js";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { builtInAgentService } from "./built-in-agents.js";
|
||||
|
||||
export interface CompanyActivityActor {
|
||||
actorType: "user" | "agent" | "system" | "plugin";
|
||||
|
|
@ -52,6 +53,7 @@ export function companyService(db: Db) {
|
|||
const ISSUE_PREFIX_FALLBACK = "CMP";
|
||||
const environmentsSvc = environmentService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const builtInAgents = builtInAgentService(db);
|
||||
|
||||
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
|
|
@ -263,6 +265,7 @@ export function companyService(db: Db) {
|
|||
create: async (data: typeof companies.$inferInsert) => {
|
||||
const created = await createCompanyWithUniquePrefix(data);
|
||||
await environmentsSvc.ensureLocalEnvironment(created.id);
|
||||
await builtInAgents.autoProvisionBundledAgents(created.id);
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, created.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function grantsForHumanRole(
|
|||
case "owner":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "agents:configure", scope: null },
|
||||
{ permissionKey: "skills:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
|
|
@ -38,6 +39,7 @@ export function grantsForHumanRole(
|
|||
case "admin":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "agents:configure", scope: null },
|
||||
{ permissionKey: "skills:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { promises as fs } from "node:fs";
|
|||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { builtInManagedResources, principalPermissionGrants, type Db } from "@paperclipai/db";
|
||||
import type {
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
|
|
@ -29,6 +30,7 @@ import type {
|
|||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanySkill,
|
||||
AgentEnvConfig,
|
||||
PermissionKey,
|
||||
RoutineVariable,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
|
|
@ -48,6 +50,7 @@ import {
|
|||
issueCommentMetadataSchema,
|
||||
issueCommentPresentationSchema,
|
||||
normalizeAgentUrlKey,
|
||||
PERMISSION_KEYS,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
|
|
@ -77,6 +80,7 @@ import {
|
|||
readCatalogStringList,
|
||||
readPortableCatalogProvenance,
|
||||
} from "./catalog-provenance.js";
|
||||
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
import { normalizePortablePath } from "./portable-path.js";
|
||||
|
||||
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
|
||||
|
|
@ -718,6 +722,23 @@ function asBoolean(value: unknown): boolean | null {
|
|||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
type PortableAgentPermissionGrant = CompanyPortabilityAgentManifestEntry["permissionGrants"][number];
|
||||
|
||||
const VALID_PERMISSION_KEYS = new Set<PermissionKey>(PERMISSION_KEYS);
|
||||
|
||||
function normalizePortablePermissionGrants(value: unknown): PortableAgentPermissionGrant[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry): PortableAgentPermissionGrant[] => {
|
||||
if (!isPlainRecord(entry)) return [];
|
||||
const permissionKey = asString(entry.permissionKey);
|
||||
if (!permissionKey || !VALID_PERMISSION_KEYS.has(permissionKey as PermissionKey)) return [];
|
||||
return [{
|
||||
permissionKey: permissionKey as PermissionKey,
|
||||
scope: isPlainRecord(entry.scope) ? entry.scope : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
|
@ -1924,6 +1945,7 @@ const YAML_KEY_PRIORITY = [
|
|||
"adapter",
|
||||
"runtime",
|
||||
"permissions",
|
||||
"permissionGrants",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
|
@ -2690,6 +2712,7 @@ function buildManifestFromPackageFiles(
|
|||
const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null;
|
||||
const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null;
|
||||
const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null;
|
||||
const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants);
|
||||
const extensionMetadata = isPlainRecord(extension.metadata) ? extension.metadata : null;
|
||||
const adapterConfig = isPlainRecord(extensionAdapter?.config)
|
||||
? extensionAdapter.config
|
||||
|
|
@ -2713,6 +2736,7 @@ function buildManifestFromPackageFiles(
|
|||
adapterConfig,
|
||||
runtimeConfig,
|
||||
permissions: extensionPermissions ?? {},
|
||||
permissionGrants: extensionPermissionGrants,
|
||||
budgetMonthlyCents:
|
||||
typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents)
|
||||
? Math.max(0, Math.floor(extension.budgetMonthlyCents))
|
||||
|
|
@ -3002,6 +3026,27 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const defaultSecretProvider = getConfiguredSecretProvider();
|
||||
|
||||
async function applyImportedAgentPermissionGrants(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
permissionGrants: PortableAgentPermissionGrant[],
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
if (permissionGrants.length === 0) return;
|
||||
await access.ensureMembership(companyId, "agent", agentId, "member", "active");
|
||||
for (const grant of permissionGrants) {
|
||||
await access.setPrincipalPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
grant.permissionKey,
|
||||
true,
|
||||
grantedByUserId,
|
||||
grant.scope ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownImportAdapterType(type: string | null | undefined): string {
|
||||
const adapterType = typeof type === "string" ? type.trim() : "";
|
||||
if (!adapterType) {
|
||||
|
|
@ -3253,24 +3298,61 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
||||
let companyLogoPath: string | null = null;
|
||||
|
||||
const managedResourceRows = typeof (db as { select?: unknown }).select === "function"
|
||||
? await db
|
||||
.select({
|
||||
resourceKind: builtInManagedResources.resourceKind,
|
||||
resourceId: builtInManagedResources.resourceId,
|
||||
})
|
||||
.from(builtInManagedResources)
|
||||
.where(eq(builtInManagedResources.companyId, companyId))
|
||||
: [];
|
||||
const managedSkillIds = new Set(
|
||||
managedResourceRows
|
||||
.filter((row) => row.resourceKind === "skill")
|
||||
.map((row) => row.resourceId),
|
||||
);
|
||||
const managedRoutineIds = new Set(
|
||||
managedResourceRows
|
||||
.filter((row) => row.resourceKind === "routine")
|
||||
.map((row) => row.resourceId),
|
||||
);
|
||||
|
||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
||||
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||
const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
|
||||
const builtInAgentRows = liveAgentRows.filter((agent) => readBuiltInAgentMarker(agent.metadata));
|
||||
const portableAgentRows = liveAgentRows.filter((agent) => !readBuiltInAgentMarker(agent.metadata));
|
||||
const companySkillRowsRaw = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
|
||||
const managedSkillRows = companySkillRowsRaw.filter((skill) => managedSkillIds.has(skill.id));
|
||||
const companySkillRows = companySkillRowsRaw.filter((skill) => !managedSkillIds.has(skill.id));
|
||||
if (include.agents) {
|
||||
const skipped = allAgentRows.length - liveAgentRows.length;
|
||||
if (skipped > 0) {
|
||||
warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`);
|
||||
}
|
||||
if (builtInAgentRows.length > 0) {
|
||||
warnings.push(`Skipped ${builtInAgentRows.length} built-in managed agent${builtInAgentRows.length === 1 ? "" : "s"} from export.`);
|
||||
}
|
||||
}
|
||||
if (include.skills && managedSkillRows.length > 0) {
|
||||
warnings.push(`Skipped ${managedSkillRows.length} built-in managed skill${managedSkillRows.length === 1 ? "" : "s"} from export.`);
|
||||
}
|
||||
|
||||
const agentByReference = new Map<string, typeof liveAgentRows[number]>();
|
||||
for (const agent of liveAgentRows) {
|
||||
agentByReference.set(agent.id, agent);
|
||||
agentByReference.set(agent.name, agent);
|
||||
const builtInAgentByReference = new Map<string, typeof liveAgentRows[number]>();
|
||||
const addAgentReferences = (map: Map<string, typeof liveAgentRows[number]>, agent: typeof liveAgentRows[number]) => {
|
||||
map.set(agent.id, agent);
|
||||
map.set(agent.name, agent);
|
||||
const normalizedName = normalizeAgentUrlKey(agent.name);
|
||||
if (normalizedName) {
|
||||
agentByReference.set(normalizedName, agent);
|
||||
map.set(normalizedName, agent);
|
||||
}
|
||||
};
|
||||
for (const agent of portableAgentRows) {
|
||||
addAgentReferences(agentByReference, agent);
|
||||
}
|
||||
for (const agent of builtInAgentRows) {
|
||||
addAgentReferences(builtInAgentByReference, agent);
|
||||
}
|
||||
|
||||
const selectedAgents = new Map<string, typeof liveAgentRows[number]>();
|
||||
|
|
@ -3280,6 +3362,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const normalized = normalizeAgentUrlKey(trimmed) ?? trimmed;
|
||||
const match = agentByReference.get(trimmed) ?? agentByReference.get(normalized);
|
||||
if (!match) {
|
||||
const builtInMatch = builtInAgentByReference.get(trimmed) ?? builtInAgentByReference.get(normalized);
|
||||
if (builtInMatch) {
|
||||
warnings.push(`Agent selector "${selector}" is a built-in managed agent and was skipped.`);
|
||||
continue;
|
||||
}
|
||||
warnings.push(`Agent selector "${selector}" was not found and was skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -3287,7 +3374,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
}
|
||||
|
||||
if (include.agents && selectedAgents.size === 0) {
|
||||
for (const agent of liveAgentRows) {
|
||||
for (const agent of portableAgentRows) {
|
||||
selectedAgents.set(agent.id, agent);
|
||||
}
|
||||
}
|
||||
|
|
@ -3302,13 +3389,49 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const slug = uniqueSlug(baseSlug, usedSlugs);
|
||||
idToSlug.set(agent.id, slug);
|
||||
}
|
||||
const agentPermissionGrantRows = agentRows.length > 0 && typeof (db as { select?: unknown }).select === "function"
|
||||
? await db
|
||||
.select({
|
||||
principalId: principalPermissionGrants.principalId,
|
||||
permissionKey: principalPermissionGrants.permissionKey,
|
||||
scope: principalPermissionGrants.scope,
|
||||
})
|
||||
.from(principalPermissionGrants)
|
||||
.where(and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, "agent"),
|
||||
inArray(principalPermissionGrants.principalId, agentRows.map((agent) => agent.id)),
|
||||
))
|
||||
: [];
|
||||
const permissionGrantsByAgentId = new Map<string, PortableAgentPermissionGrant[]>();
|
||||
for (const row of agentPermissionGrantRows) {
|
||||
if (!VALID_PERMISSION_KEYS.has(row.permissionKey as PermissionKey)) continue;
|
||||
const grants = permissionGrantsByAgentId.get(row.principalId) ?? [];
|
||||
grants.push({
|
||||
permissionKey: row.permissionKey as PermissionKey,
|
||||
scope: isPlainRecord(row.scope) ? row.scope : null,
|
||||
});
|
||||
permissionGrantsByAgentId.set(row.principalId, grants);
|
||||
}
|
||||
for (const grants of permissionGrantsByAgentId.values()) {
|
||||
grants.sort((left, right) => left.permissionKey.localeCompare(right.permissionKey));
|
||||
}
|
||||
|
||||
const projectsSvc = projectService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const routinesSvc = routineService(db);
|
||||
const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : [];
|
||||
const allProjects = allProjectsRaw.filter((project) => !project.archivedAt);
|
||||
const allRoutines = include.issues ? await routinesSvc.list(companyId) : [];
|
||||
const allRoutinesRaw = include.issues ? await routinesSvc.list(companyId) : [];
|
||||
const builtInRoutineRows = allRoutinesRaw.filter((routine) =>
|
||||
managedRoutineIds.has(routine.id) || routine.originKind === "built_in_agent_bundle"
|
||||
);
|
||||
const allRoutines = allRoutinesRaw.filter((routine) =>
|
||||
!managedRoutineIds.has(routine.id) && routine.originKind !== "built_in_agent_bundle"
|
||||
);
|
||||
if (include.issues && builtInRoutineRows.length > 0) {
|
||||
warnings.push(`Skipped ${builtInRoutineRows.length} built-in managed routine${builtInRoutineRows.length === 1 ? "" : "s"} from export.`);
|
||||
}
|
||||
const projectById = new Map(allProjects.map((project) => [project.id, project]));
|
||||
const projectByReference = new Map<string, typeof allProjects[number]>();
|
||||
for (const project of allProjects) {
|
||||
|
|
@ -3330,6 +3453,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const selectedIssues = new Map<string, Awaited<ReturnType<typeof issuesSvc.getById>>>();
|
||||
const selectedRoutines = new Map<string, typeof allRoutines[number]>();
|
||||
const routineById = new Map(allRoutines.map((routine) => [routine.id, routine]));
|
||||
const builtInRoutineById = new Map(builtInRoutineRows.map((routine) => [routine.id, routine]));
|
||||
const resolveIssueBySelector = async (selector: string) => {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) return null;
|
||||
|
|
@ -3340,6 +3464,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
for (const selector of input.issues ?? []) {
|
||||
const issue = await resolveIssueBySelector(selector);
|
||||
if (!issue || issue.companyId !== companyId) {
|
||||
if (builtInRoutineById.has(selector.trim())) {
|
||||
warnings.push(`Routine selector "${selector}" is a built-in managed routine and was skipped.`);
|
||||
continue;
|
||||
}
|
||||
const routine = routineById.get(selector.trim());
|
||||
if (routine) {
|
||||
selectedRoutines.set(routine.id, routine);
|
||||
|
|
@ -3551,6 +3679,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
},
|
||||
) as Record<string, unknown>;
|
||||
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
|
||||
const portablePermissionGrants = permissionGrantsByAgentId.get(agent.id) ?? [];
|
||||
const agentEnvInputs = dedupeEnvInputs(
|
||||
envInputs
|
||||
.slice(envInputsStart)
|
||||
|
|
@ -3593,6 +3722,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
},
|
||||
runtime: portableRuntimeConfig,
|
||||
permissions: portablePermissions,
|
||||
permissionGrants: portablePermissionGrants.length > 0 ? portablePermissionGrants : undefined,
|
||||
budgetMonthlyCents: (agent.budgetMonthlyCents ?? 0) > 0 ? agent.budgetMonthlyCents : undefined,
|
||||
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
||||
});
|
||||
|
|
@ -4593,6 +4723,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
await applyImportedAgentPermissionGrants(
|
||||
targetCompany.id,
|
||||
updated.id,
|
||||
manifestAgent.permissionGrants ?? [],
|
||||
actorUserId ?? null,
|
||||
);
|
||||
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
|
||||
await secrets.syncEnvBindingsForTarget?.(
|
||||
targetCompany.id,
|
||||
|
|
@ -4634,6 +4770,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
await applyImportedAgentPermissionGrants(
|
||||
targetCompany.id,
|
||||
created.id,
|
||||
manifestAgent.permissionGrants ?? [],
|
||||
actorUserId ?? null,
|
||||
);
|
||||
agentStatusById.set(created.id, created.status ?? createdStatus);
|
||||
await secrets.syncEnvBindingsForTarget?.(
|
||||
targetCompany.id,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ export { companySearchService } from "./company-search.js";
|
|||
export { feedbackService } from "./feedback.js";
|
||||
export { companySkillService } from "./company-skills.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
export {
|
||||
builtInAgentService,
|
||||
deriveBuiltInAgentStatus,
|
||||
getBuiltInAgentDefinition,
|
||||
listBuiltInAgentDefinitions,
|
||||
reconcileBuiltInAgentsOnStartup,
|
||||
validateBuiltInAgentDefinitions,
|
||||
type BuiltInAgentDefinition,
|
||||
type BuiltInManagedResourceState,
|
||||
type BuiltInManagedResourceStockStatus,
|
||||
type BuiltInAgentState,
|
||||
type BuiltInAgentStatus,
|
||||
} from "./built-in-agents.js";
|
||||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||
export { assetService } from "./assets.js";
|
||||
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
|
||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
||||
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
|
||||
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
|
||||
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
|
|
@ -76,6 +77,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Dashboard } from "./pages/Dashboard";
|
|||
import { DashboardLive } from "./pages/DashboardLive";
|
||||
import { Timeline } from "./pages/Timeline";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
import { AGENT_FILTER_TABS, Agents } from "./pages/Agents";
|
||||
import { AgentDetail } from "./pages/AgentDetail";
|
||||
import { Projects } from "./pages/Projects";
|
||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
|
|
@ -116,10 +116,9 @@ function boardRoutes() {
|
|||
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
||||
<Route path="org" element={<OrgChart />} />
|
||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
||||
<Route path="agents/all" element={<Agents />} />
|
||||
<Route path="agents/active" element={<Agents />} />
|
||||
<Route path="agents/paused" element={<Agents />} />
|
||||
<Route path="agents/error" element={<Agents />} />
|
||||
{AGENT_FILTER_TABS.map((tab) => (
|
||||
<Route key={tab} path={`agents/${tab}`} element={<Agents />} />
|
||||
))}
|
||||
<Route path="agents/new" element={<NewAgent />} />
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||
|
|
@ -461,6 +460,9 @@ export function App() {
|
|||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
||||
{AGENT_FILTER_TABS.map((tab) => (
|
||||
<Route key={tab} path={`agents/${tab}`} element={<UnprefixedBoardRedirect />} />
|
||||
))}
|
||||
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import type { Agent, Approval } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
/**
|
||||
* Lifecycle of a built-in agent, derived server-side from row existence,
|
||||
* adapter-config completeness, board-approval state, and `pausedAt`.
|
||||
*
|
||||
* `not_provisioned → pending_approval → needs_setup → ready ⇄ paused`
|
||||
*
|
||||
* `pending_approval` only occurs when the company requires board approval for
|
||||
* new agents; otherwise provisioning goes straight to `needs_setup`/`ready`.
|
||||
*/
|
||||
export type BuiltInAgentStatus =
|
||||
| "not_provisioned"
|
||||
| "pending_approval"
|
||||
| "needs_setup"
|
||||
| "ready"
|
||||
| "paused";
|
||||
|
||||
/**
|
||||
* Redacted bundle metadata returned alongside a built-in agent that ships a
|
||||
* managed resource bundle (instructions + skill + routine). The server strips
|
||||
* file bodies to key lists; the UI only needs the identity/labels to render the
|
||||
* bundle status panel. Present only on bundle-backed built-ins (Reflection
|
||||
* Coach); flat built-ins (briefs/learning) omit it.
|
||||
*/
|
||||
export interface BuiltInAgentBundleMeta {
|
||||
stockVersion: string;
|
||||
instructions: { entryFile: string; files: string[] };
|
||||
skill: {
|
||||
skillKey: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
canonicalKey: string;
|
||||
files: string[];
|
||||
};
|
||||
routine: {
|
||||
routineKey: string;
|
||||
title: string;
|
||||
status: "active" | "paused";
|
||||
triggerCount: number;
|
||||
scheduleLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuiltInAgentDefinition {
|
||||
key: string;
|
||||
displayName: string;
|
||||
featureKeys: string[];
|
||||
shortPurpose: string;
|
||||
defaultInstructions: string;
|
||||
defaultRole: string;
|
||||
allowedAdapterTypes?: string[];
|
||||
defaultBudgetMonthlyCents?: number;
|
||||
bundle?: BuiltInAgentBundleMeta;
|
||||
}
|
||||
|
||||
/** Managed resources a bundle materializes; drift is tracked per kind. */
|
||||
export type BuiltInManagedResourceKind = "instructions" | "skill" | "routine";
|
||||
|
||||
/**
|
||||
* Drift status of one managed resource versus the shipped stock default:
|
||||
* - `missing` — expected resource absent; a reconcile will recreate it.
|
||||
* - `stock_current` — present and byte-identical to the shipped default.
|
||||
* - `stock_update_available` — unedited, but Paperclip shipped a newer default.
|
||||
* - `operator_modified` — operator-edited; reconcile preserves these edits.
|
||||
*/
|
||||
export type BuiltInManagedResourceStockStatus =
|
||||
| "missing"
|
||||
| "stock_current"
|
||||
| "stock_update_available"
|
||||
| "operator_modified";
|
||||
|
||||
export interface BuiltInManagedResourceState {
|
||||
resourceKind: BuiltInManagedResourceKind;
|
||||
resourceKey: string;
|
||||
resourceId: string | null;
|
||||
stockVersion: string;
|
||||
stockHash: string;
|
||||
currentHash: string | null;
|
||||
stockStatus: BuiltInManagedResourceStockStatus;
|
||||
/** True when an unedited resource has a newer shipped default to apply. */
|
||||
updateAvailable: boolean;
|
||||
/** True when the resource has drifted and can be reset to the default. */
|
||||
resetAvailable: boolean;
|
||||
changedFiles?: string[];
|
||||
/** True when the managed weekly schedule is active and can create background work. */
|
||||
scheduleEnabled?: boolean;
|
||||
/** Pending request_confirmation for a Reflection Coach update proposal, when one exists. */
|
||||
pendingUpdateInteractionId?: string | null;
|
||||
/** Issue containing the pending proposal interaction. */
|
||||
pendingUpdateIssueId?: string | null;
|
||||
pendingUpdateIssueIdentifier?: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltInAgentState {
|
||||
definition: BuiltInAgentDefinition;
|
||||
status: BuiltInAgentStatus;
|
||||
agentId: string | null;
|
||||
agent: Agent | null;
|
||||
pauseReason: string | null;
|
||||
/** Per-resource drift/readiness for bundle-backed built-ins (may be empty). */
|
||||
resources?: BuiltInManagedResourceState[];
|
||||
/** Present when provisioning queued a board hire approval (HTTP 202). */
|
||||
approval?: Approval | null;
|
||||
}
|
||||
|
||||
export interface BuiltInAgentProvisionInput {
|
||||
adapterType?: string;
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
budgetMonthlyCents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectors accepted by the reset endpoint. `agent` resets the agent config
|
||||
* (adapter/model/budget defaults) only; the resource kinds each reset a single
|
||||
* managed resource back to its shipped default. Omitting the array resets
|
||||
* everything (the agent-level "Reset to defaults" button).
|
||||
*/
|
||||
export type BuiltInResetResource = "agent" | BuiltInManagedResourceKind;
|
||||
|
||||
/**
|
||||
* Error `code` thrown as HTTP 412 by `requireBuiltInAgent` on the server when a
|
||||
* feature needs a built-in agent that is missing or not fully configured. The
|
||||
* configure-on-first-use modal is triggered from this signal.
|
||||
*/
|
||||
export const BUILT_IN_AGENT_NOT_CONFIGURED_CODE = "built_in_agent_not_configured";
|
||||
|
||||
/**
|
||||
* Warning `code` returned alongside a paused built-in agent so callers can
|
||||
* surface the use-while-paused toast without treating the agent as ready.
|
||||
*/
|
||||
export const BUILT_IN_AGENT_PAUSED_CODE = "built_in_agent_paused";
|
||||
|
||||
export const builtInAgentsApi = {
|
||||
list: (companyId: string) =>
|
||||
api.get<BuiltInAgentState[]>(`/companies/${companyId}/built-in-agents`),
|
||||
provision: (companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/provision`, input),
|
||||
/**
|
||||
* Reset built-in defaults. Pass `resources` to scope the reset to specific
|
||||
* managed resources (e.g. `["skill"]`); omit it to reset the whole agent +
|
||||
* bundle. A single-resource reset re-applies that resource's newest shipped
|
||||
* default — the same path used for both "reset drifted edits" and "apply an
|
||||
* available stock update".
|
||||
*/
|
||||
reset: (companyId: string, key: string, resources?: BuiltInResetResource[]) =>
|
||||
api.post<BuiltInAgentState>(
|
||||
`/companies/${companyId}/built-in-agents/${key}/reset`,
|
||||
resources ? { resources } : {},
|
||||
),
|
||||
/**
|
||||
* Re-materialize the bundle. Applies the newest shipped defaults to unedited
|
||||
* (`stock_update_available`) and `missing` resources while preserving
|
||||
* `operator_modified` edits — it is the safe "apply available updates" path.
|
||||
*/
|
||||
reconcile: (companyId: string, key: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}),
|
||||
runRoutine: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<unknown>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/run`, {}),
|
||||
enableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/enable`, {}),
|
||||
disableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/disable`, {}),
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -18,6 +18,16 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
|
|
@ -155,6 +165,8 @@ export function AgentActionButtons({
|
|||
workActionsDisabledReason,
|
||||
navigateToRunOnInvoke = true,
|
||||
onActionError,
|
||||
pauseConfirm,
|
||||
hideTerminate = false,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
|
|
@ -168,6 +180,13 @@ export function AgentActionButtons({
|
|||
workActionsDisabled?: boolean;
|
||||
workActionsDisabledReason?: string;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/**
|
||||
* When set, pausing prompts a confirmation dialog first (e.g. for built-in
|
||||
* agents that power a feature). Omit for the immediate-pause default.
|
||||
*/
|
||||
pauseConfirm?: { title: string; description: ReactNode };
|
||||
/** Hide the Terminate action (e.g. built-in agents are undeletable). */
|
||||
hideTerminate?: boolean;
|
||||
/**
|
||||
* Optional inline error reporter. When provided it is used instead of a toast
|
||||
* for action failures (preserves the detail page's inline error banner). When
|
||||
|
|
@ -183,6 +202,7 @@ export function AgentActionButtons({
|
|||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [pauseConfirmOpen, setPauseConfirmOpen] = useState(false);
|
||||
|
||||
const resolvedCompanyId = companyId ?? agent.companyId;
|
||||
const canonicalAgentRef = agentRouteRef(agent);
|
||||
|
|
@ -321,12 +341,30 @@ export function AgentActionButtons({
|
|||
) : (
|
||||
<PauseResumeButton
|
||||
isPaused={isPaused}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onPause={() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause"))}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={pauseResumeDisabled}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{pauseConfirm && (
|
||||
<AlertDialog open={pauseConfirmOpen} onOpenChange={setPauseConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{pauseConfirm.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>{pauseConfirm.description}</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => agentAction.mutate("pause")}>
|
||||
Pause anyway
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{showStatus && (
|
||||
<span className="hidden sm:inline">
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
|
|
@ -372,16 +410,18 @@ export function AgentActionButtons({
|
|||
<RotateCcw className="h-3 w-3" />
|
||||
Reset Sessions
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Terminate
|
||||
</button>
|
||||
{!hideTerminate && (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Terminate
|
||||
</button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1579,7 +1579,7 @@ export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmen
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
function AdapterTypeDropdown({
|
||||
export function AdapterTypeDropdown({
|
||||
value,
|
||||
onChange,
|
||||
disabledTypes,
|
||||
|
|
@ -1652,7 +1652,7 @@ function ExperimentalBadge() {
|
|||
);
|
||||
}
|
||||
|
||||
function ModelDropdown({
|
||||
export function ModelDropdown({
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { brandChipBadge } from "@/lib/status-colors";
|
||||
import type { BuiltInAgentStatus } from "@/api/builtInAgents";
|
||||
|
||||
/**
|
||||
* Provenance label ("Built-in"). Constant for the life of a built-in agent —
|
||||
* this is NOT a lifecycle/status chip, so it never routes through
|
||||
* `StatusBadge`/`AgentStatusBadge` (ux-spec D2).
|
||||
*/
|
||||
export function BuiltInAgentBadge({
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
brandChipBadge.blue,
|
||||
compact && "px-1.5 py-0 text-(length:--text-nano)",
|
||||
className,
|
||||
)}
|
||||
title="Ships with Paperclip"
|
||||
>
|
||||
Built-in
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived lifecycle chip. Rendered for the amber attention states
|
||||
* (`needs_setup`, `pending_approval`). Kept separate from the real agent status
|
||||
* (`idle/active/…`) per ux-spec D1.
|
||||
*/
|
||||
export function BuiltInLifecycleChip({
|
||||
status,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
status: BuiltInAgentStatus;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (status !== "needs_setup" && status !== "pending_approval") return null;
|
||||
const isPendingApproval = status === "pending_approval";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
brandChipBadge.amber,
|
||||
compact && "px-1.5 py-0 text-(length:--text-nano)",
|
||||
className,
|
||||
)}
|
||||
title={
|
||||
isPendingApproval
|
||||
? "Waiting on board hire approval before the feature can run"
|
||||
: "Needs adapter/model setup before the feature can run"
|
||||
}
|
||||
>
|
||||
{isPendingApproval ? (compact ? "Approval" : "Pending approval") : compact ? "Setup" : "Needs setup"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BuiltInAgentGate } from "./BuiltInAgentGate";
|
||||
import type { BuiltInAgentState, BuiltInAgentStatus } from "@/api/builtInAgents";
|
||||
|
||||
const listMock = vi.hoisted(() => vi.fn());
|
||||
const resumeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/builtInAgents", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/api/builtInAgents")>();
|
||||
return {
|
||||
...actual,
|
||||
builtInAgentsApi: { list: listMock, provision: vi.fn(), reset: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: { resume: resumeMock },
|
||||
}));
|
||||
|
||||
// The configure modal pulls in the full AgentConfigForm; stub it so the gate
|
||||
// test stays focused on state selection.
|
||||
vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({
|
||||
ConfigureBuiltInAgentModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="configure-modal" /> : null,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
function makeState(status: BuiltInAgentStatus, overrides: Partial<BuiltInAgentState> = {}): BuiltInAgentState {
|
||||
const provisioned = status !== "not_provisioned";
|
||||
return {
|
||||
definition: {
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["briefs"],
|
||||
shortPurpose: "Prepares briefs.",
|
||||
defaultInstructions: "…",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
},
|
||||
status,
|
||||
agentId: provisioned ? "agent-1" : null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent: provisioned
|
||||
? ({ id: "agent-1", pausedAt: status === "paused" ? new Date().toISOString() : null } as any)
|
||||
: null,
|
||||
pauseReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("BuiltInAgentGate (PAP-12978)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderGate() {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BuiltInAgentGate agentKey="briefs" companyId="c1" featureLabel="Briefs">
|
||||
<div data-testid="feature">brief content</div>
|
||||
</BuiltInAgentGate>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listMock.mockReset();
|
||||
resumeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the setup empty-state for needs_setup and hides the feature", async () => {
|
||||
listMock.mockResolvedValue([makeState("needs_setup")]);
|
||||
await renderGate();
|
||||
expect(container.textContent).toContain("Set up the Briefs Agent");
|
||||
expect(container.textContent).toContain("Configure its model to enable the feature");
|
||||
expect(container.querySelector('[data-testid="feature"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the setup empty-state for not_provisioned", async () => {
|
||||
listMock.mockResolvedValue([makeState("not_provisioned")]);
|
||||
await renderGate();
|
||||
expect(container.textContent).toContain("Set up the Briefs Agent");
|
||||
expect(container.querySelector('[data-testid="feature"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a pending-approval state", async () => {
|
||||
listMock.mockResolvedValue([makeState("pending_approval")]);
|
||||
await renderGate();
|
||||
expect(container.textContent).toContain("pending approval");
|
||||
expect(container.querySelector('[data-testid="feature"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the paused banner and keeps children readable (stale)", async () => {
|
||||
listMock.mockResolvedValue([makeState("paused")]);
|
||||
await renderGate();
|
||||
expect(container.textContent).toContain("Briefs is paused.");
|
||||
expect(container.textContent).toContain("Resume agent");
|
||||
// Paused ≠ hidden — children still render.
|
||||
expect(container.querySelector('[data-testid="feature"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resumes the agent from the paused banner", async () => {
|
||||
listMock.mockResolvedValue([makeState("paused")]);
|
||||
resumeMock.mockResolvedValue({});
|
||||
await renderGate();
|
||||
const resumeButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Resume agent"),
|
||||
);
|
||||
expect(resumeButton).toBeTruthy();
|
||||
flushSync(() => {
|
||||
resumeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(resumeMock).toHaveBeenCalledWith("agent-1", "c1");
|
||||
});
|
||||
|
||||
it("renders the feature when ready", async () => {
|
||||
listMock.mockResolvedValue([makeState("ready")]);
|
||||
await renderGate();
|
||||
expect(container.querySelector('[data-testid="feature"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Set up the Briefs Agent");
|
||||
});
|
||||
|
||||
it("fails open to the feature when the key is unknown", async () => {
|
||||
listMock.mockResolvedValue([makeState("ready", {
|
||||
definition: { ...makeState("ready").definition, key: "learning" },
|
||||
})]);
|
||||
await renderGate();
|
||||
expect(container.querySelector('[data-testid="feature"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bot, Clock3 } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal";
|
||||
import { builtInAgentsApi, type BuiltInAgentState } from "@/api/builtInAgents";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { agentUrl } from "@/lib/utils";
|
||||
import { relativeTime } from "@/lib/utils";
|
||||
|
||||
export interface BuiltInAgentGateProps {
|
||||
/** Registry key of the built-in agent that powers this feature (e.g. "briefs"). */
|
||||
agentKey: string;
|
||||
companyId: string | null | undefined;
|
||||
/** Human label for the gated feature. Defaults to the agent's display name. */
|
||||
featureLabel?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a feature surface that depends on a built-in agent and renders the
|
||||
* right lifecycle state (ux-spec §4):
|
||||
*
|
||||
* - loading → skeleton
|
||||
* - not_provisioned / needs_setup → setup empty-state + configure modal CTA
|
||||
* - paused → amber banner + Resume over the (stale) children
|
||||
* - ready → children
|
||||
*/
|
||||
export function BuiltInAgentGate({ agentKey, companyId, featureLabel, children }: BuiltInAgentGateProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [configureOpen, setConfigureOpen] = useState(false);
|
||||
|
||||
const { data: states, isLoading } = useQuery({
|
||||
queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__"),
|
||||
queryFn: () => builtInAgentsApi.list(companyId!),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
const state: BuiltInAgentState | undefined = states?.find((entry) => entry.definition.key === agentKey);
|
||||
|
||||
const resume = useMutation({
|
||||
mutationFn: (agentId: string) => agentsApi.resume(agentId, companyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__") });
|
||||
if (companyId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
// Unknown key or still resolving the company — fail open to the feature.
|
||||
if (!companyId) return <>{children}</>;
|
||||
if (isLoading && !states) return <PageSkeleton variant="detail" />;
|
||||
if (!state) return <>{children}</>;
|
||||
|
||||
const label = featureLabel ?? state.definition.displayName;
|
||||
|
||||
if (state.status === "not_provisioned" || state.status === "needs_setup") {
|
||||
return (
|
||||
<>
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
title={`Set up the ${state.definition.displayName}`}
|
||||
message={`${label} is generated by a built-in agent. Configure its model to enable the feature.`}
|
||||
action={`Set up ${state.definition.displayName}`}
|
||||
onAction={() => setConfigureOpen(true)}
|
||||
hideActionIcon
|
||||
/>
|
||||
<ConfigureBuiltInAgentModal
|
||||
companyId={companyId}
|
||||
state={state}
|
||||
open={configureOpen}
|
||||
onOpenChange={setConfigureOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "pending_approval") {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
title={`${state.definition.displayName} is pending approval`}
|
||||
message={`${label} will be available after the board approves this built-in agent hire.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "paused" && state.agent) {
|
||||
const pausedAt = state.agent.pausedAt ? relativeTime(state.agent.pausedAt) : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
title={`${label} is paused.`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={agentUrl(state.agent)}>View agent</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => state.agent && resume.mutate(state.agent.id)}
|
||||
disabled={resume.isPending}
|
||||
>
|
||||
{resume.isPending ? "Resuming…" : "Resume agent"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Its built-in agent was paused{pausedAt ? ` ${pausedAt}` : ""}, so new{" "}
|
||||
{label.toLowerCase()} isn't being generated.
|
||||
</InlineBanner>
|
||||
{/* Paused ≠ hidden: keep existing content readable, marked stale. */}
|
||||
<div className="opacity-70">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BuiltInBundlePanel } from "./BuiltInBundlePanel";
|
||||
import type {
|
||||
BuiltInAgentState,
|
||||
BuiltInManagedResourceState,
|
||||
BuiltInManagedResourceStockStatus,
|
||||
} from "@/api/builtInAgents";
|
||||
|
||||
// The panel links to agent tabs via `@/lib/router` (company-prefixed Link).
|
||||
// Stub it to a plain anchor so the panel test doesn't need CompanyContext.
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
function resource(
|
||||
resourceKind: BuiltInManagedResourceState["resourceKind"],
|
||||
stockStatus: BuiltInManagedResourceStockStatus,
|
||||
overrides: Partial<BuiltInManagedResourceState> = {},
|
||||
): BuiltInManagedResourceState {
|
||||
return {
|
||||
resourceKind,
|
||||
resourceKey: resourceKind === "skill" ? "reflection-coach" : resourceKind === "routine" ? "recent-agent-reflection" : "AGENTS.md",
|
||||
resourceId: "res-1",
|
||||
stockVersion: "2026-07-08",
|
||||
stockHash: "aaaa",
|
||||
currentHash: stockStatus === "missing" ? null : stockStatus === "stock_current" ? "aaaa" : "bbbb",
|
||||
stockStatus,
|
||||
updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified",
|
||||
resetAvailable: stockStatus !== "stock_current",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(
|
||||
status: BuiltInAgentState["status"],
|
||||
resources: BuiltInManagedResourceState[],
|
||||
): BuiltInAgentState {
|
||||
return {
|
||||
definition: {
|
||||
key: "reflection-coach",
|
||||
displayName: "Reflection Coach",
|
||||
featureKeys: ["reflection"],
|
||||
shortPurpose: "Coaches recent agents.",
|
||||
defaultInstructions: "…",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
bundle: {
|
||||
stockVersion: "2026-07-08",
|
||||
instructions: { entryFile: "AGENTS.md", files: ["AGENTS.md"] },
|
||||
skill: {
|
||||
skillKey: "reflection-coach",
|
||||
displayName: "reflection-coach",
|
||||
slug: "reflection-coach",
|
||||
canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach",
|
||||
files: ["reflection-coach/SKILL.md"],
|
||||
},
|
||||
routine: {
|
||||
routineKey: "recent-agent-reflection",
|
||||
title: "Recent agent reflection",
|
||||
status: "paused",
|
||||
triggerCount: 1,
|
||||
scheduleLabel: "Weekly · Mon 09:00 UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
status,
|
||||
agentId: "agent-1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent: { id: "agent-1", pausedAt: status === "paused" ? new Date().toISOString() : null } as any,
|
||||
pauseReason: null,
|
||||
resources,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
const READY_RESOURCES = [
|
||||
resource("skill", "stock_current"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current"),
|
||||
];
|
||||
|
||||
describe("BuiltInBundlePanel (PAP-13099)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
function render(state: BuiltInAgentState, handlers: Partial<{
|
||||
onConfigure: () => void;
|
||||
onResetResource: (kind: BuiltInManagedResourceState["resourceKind"]) => void;
|
||||
onRunRoutine: (routineKey: string) => void;
|
||||
onEnableSchedule: (routineKey: string) => void;
|
||||
onDisableSchedule: (routineKey: string) => void;
|
||||
}> = {}) {
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<BuiltInBundlePanel
|
||||
state={state}
|
||||
agentRef="reflectioncoach"
|
||||
onConfigure={handlers.onConfigure ?? (() => {})}
|
||||
onResetResource={handlers.onResetResource ?? (() => {})}
|
||||
onRunRoutine={handlers.onRunRoutine ?? (() => {})}
|
||||
onEnableSchedule={handlers.onEnableSchedule ?? (() => {})}
|
||||
onDisableSchedule={handlers.onDisableSchedule ?? (() => {})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
// Radix portals dialog content onto body; clear leftovers between tests.
|
||||
document.body.querySelectorAll("[data-slot='alert-dialog-portal']").forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it("renders four resource rows with ready + schedule-off chips when healthy", () => {
|
||||
render(makeState("ready", READY_RESOURCES));
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Bundle status");
|
||||
expect(text).toContain("Adapter");
|
||||
expect(text).toContain("Skill");
|
||||
expect(text).toContain("Instructions");
|
||||
expect(text).toContain("Routine");
|
||||
// Zero-token guarantee copy is always present on the routine row.
|
||||
expect(text).toContain("costs zero tokens by default");
|
||||
expect(text).toContain("Schedule off");
|
||||
expect(text).toContain("Ready");
|
||||
expect(text).toContain("Run once");
|
||||
expect(text).toContain("Enable weekly");
|
||||
});
|
||||
|
||||
it("shows the active weekly schedule and disable action when enabled", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "stock_current"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current", { scheduleEnabled: true }),
|
||||
]));
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Weekly · Mon 09:00 UTC");
|
||||
expect(text).toContain("can create background work");
|
||||
expect(text).toContain("Disable schedule");
|
||||
expect(text).not.toContain("Enable weekly");
|
||||
});
|
||||
|
||||
it("links to a pending proposal interaction when the routine resource reports one", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "stock_current"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current", {
|
||||
pendingUpdateInteractionId: "interaction-1",
|
||||
pendingUpdateIssueId: "issue-1",
|
||||
pendingUpdateIssueIdentifier: "PAP-42",
|
||||
}),
|
||||
]));
|
||||
const link = Array.from(container.querySelectorAll("a")).find((anchor) => anchor.textContent === "Review proposal");
|
||||
expect(container.textContent).toContain("Proposal pending");
|
||||
expect(link?.getAttribute("href")).toBe("/issues/PAP-42#interaction-interaction-1");
|
||||
});
|
||||
|
||||
it("shows Needs setup for the adapter when the agent is not configured yet", () => {
|
||||
render(makeState("needs_setup", READY_RESOURCES));
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Needs setup");
|
||||
expect(text).toContain("Pick an adapter this coach can run on");
|
||||
});
|
||||
|
||||
it("surfaces an update-available chip and Update action for unedited stock drift", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "stock_current"),
|
||||
resource("instructions", "stock_update_available"),
|
||||
resource("routine", "stock_current"),
|
||||
]));
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Update available");
|
||||
expect(text).toContain("Paperclip shipped a newer default");
|
||||
// The per-resource Update trigger button is present.
|
||||
const buttons = Array.from(container.querySelectorAll("button")).map((b) => b.textContent);
|
||||
expect(buttons).toContain("Update");
|
||||
});
|
||||
|
||||
it("surfaces a Drifted chip and Reset action for operator-modified resources", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "operator_modified"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current"),
|
||||
]));
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Drifted");
|
||||
expect(text).toContain("Your changes are kept");
|
||||
const buttons = Array.from(container.querySelectorAll("button")).map((b) => b.textContent);
|
||||
expect(buttons).toContain("Reset");
|
||||
});
|
||||
|
||||
it("shows a Missing chip when a resource is not materialized", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "missing"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current"),
|
||||
]));
|
||||
expect(container.textContent).toContain("Missing");
|
||||
});
|
||||
|
||||
it("fires onConfigure when the adapter Configure button is clicked", () => {
|
||||
const onConfigure = vi.fn();
|
||||
render(makeState("needs_setup", READY_RESOURCES), { onConfigure });
|
||||
const configureBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Configure",
|
||||
);
|
||||
expect(configureBtn).toBeTruthy();
|
||||
flushSync(() => configureBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onConfigure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing for a built-in without a bundle", () => {
|
||||
const flat = makeState("ready", READY_RESOURCES);
|
||||
delete flat.definition.bundle;
|
||||
render(flat);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ResourceStatusChip, type ResourceStatusVariant } from "@/components/ResourceStatusChip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BuiltInAgentState,
|
||||
BuiltInManagedResourceKind,
|
||||
BuiltInManagedResourceState,
|
||||
} from "@/api/builtInAgents";
|
||||
|
||||
/**
|
||||
* Bundle status panel for a bundle-backed built-in agent (Reflection Coach —
|
||||
* [PAP-13099], ux-spec §3–§8). Renders one row per managed resource
|
||||
* (adapter · skill · instructions · routine, dependency order) with a readiness
|
||||
* chip, drift chip, inline copy, and the wireable per-resource actions.
|
||||
*
|
||||
* Presentational: the parent owns queries/mutations and passes handlers. The
|
||||
* confirm-before-mutate dialogs and copy live here (ux-spec §8). Adapter
|
||||
* readiness is derived from the agent lifecycle `status` (there is no adapter
|
||||
* resource in `resources[]`); skill/instructions/routine come from
|
||||
* `state.resources`.
|
||||
*
|
||||
* Both "apply an available stock update" and "reset drifted edits" route
|
||||
* through the same scoped reset (`onResetResource(kind)` →
|
||||
* `built-in-agents/:key/reset { resources: [kind] }`), which re-materializes
|
||||
* that one resource to Paperclip's newest shipped default without touching
|
||||
* adapter credentials or the other resources.
|
||||
*/
|
||||
|
||||
function findResource(
|
||||
resources: BuiltInManagedResourceState[] | undefined,
|
||||
kind: BuiltInManagedResourceKind,
|
||||
): BuiltInManagedResourceState | undefined {
|
||||
return resources?.find((resource) => resource.resourceKind === kind);
|
||||
}
|
||||
|
||||
/** Readiness chip for a materialized resource. */
|
||||
function readinessVariant(resource: BuiltInManagedResourceState): ResourceStatusVariant {
|
||||
if (resource.stockStatus === "missing") return "missing";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
/** Drift chip shown alongside a `ready` readiness chip, or `null`. */
|
||||
function driftVariant(resource: BuiltInManagedResourceState): ResourceStatusVariant | null {
|
||||
if (resource.stockStatus === "missing") return null; // readiness wins; drift suppressed
|
||||
if (resource.stockStatus === "stock_update_available") return "update_available";
|
||||
if (resource.stockStatus === "operator_modified") return "drifted";
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ResourceActionCopy {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
triggerLabel: string;
|
||||
}
|
||||
|
||||
/** Confirm-dialog copy per drift state (ux-spec §8 copy deck). */
|
||||
function resourceActionCopy(
|
||||
resource: BuiltInManagedResourceState,
|
||||
label: string,
|
||||
): ResourceActionCopy | null {
|
||||
if (resource.stockStatus === "stock_update_available") {
|
||||
return {
|
||||
title: `Update ${label} to the newest default?`,
|
||||
body: `You haven't edited this, so Paperclip will replace it with the newer shipped version. Nothing you customized is affected, and your adapter credentials and settings are not touched.`,
|
||||
confirmLabel: "Update",
|
||||
triggerLabel: "Update",
|
||||
};
|
||||
}
|
||||
if (resource.stockStatus === "operator_modified") {
|
||||
return {
|
||||
title: `Reset ${label} to the shipped default?`,
|
||||
body: `This replaces your edited version with Paperclip's current default. Your edits can't be recovered. Adapter credentials and settings are not touched.`,
|
||||
confirmLabel: `Reset ${label}`,
|
||||
triggerLabel: "Reset",
|
||||
};
|
||||
}
|
||||
if (resource.stockStatus === "missing") {
|
||||
return {
|
||||
title: `Recreate ${label}?`,
|
||||
body: `This resource is missing. Paperclip will recreate it from the shipped default. Adapter credentials and settings are not touched.`,
|
||||
confirmLabel: "Recreate",
|
||||
triggerLabel: "Recreate",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResourceActionButton({
|
||||
resource,
|
||||
label,
|
||||
onConfirm,
|
||||
pending,
|
||||
}: {
|
||||
resource: BuiltInManagedResourceState;
|
||||
label: string;
|
||||
onConfirm: () => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const copy = resourceActionCopy(resource, label);
|
||||
if (!copy) return null;
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={pending}>
|
||||
{pending ? "Working…" : copy.triggerLabel}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{copy.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{copy.body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>{copy.confirmLabel}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmActionButton({
|
||||
title,
|
||||
body,
|
||||
triggerLabel,
|
||||
confirmLabel,
|
||||
pending,
|
||||
onConfirm,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
triggerLabel: string;
|
||||
confirmLabel: string;
|
||||
pending: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={pending}>
|
||||
{pending ? "Working…" : triggerLabel}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface BundleRowProps {
|
||||
label: string;
|
||||
secondary?: string;
|
||||
chips: ReactNode;
|
||||
detail?: ReactNode;
|
||||
detailTone?: "muted" | "error";
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
function BundleRow({ label, secondary, chips, detail, detailTone = "muted", actions }: BundleRowProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{secondary && (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">{secondary}</span>
|
||||
)}
|
||||
{chips}
|
||||
</div>
|
||||
{detail && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-(length:--text-micro) leading-snug",
|
||||
detailTone === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function driftDetail(resource: BuiltInManagedResourceState): string | undefined {
|
||||
switch (resource.stockStatus) {
|
||||
case "operator_modified":
|
||||
return "You've edited this. Your changes are kept until you reset.";
|
||||
case "stock_update_available":
|
||||
return "Paperclip shipped a newer default.";
|
||||
case "missing":
|
||||
return "Not materialized yet — recreate it from the shipped default.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BuiltInBundlePanelProps {
|
||||
state: BuiltInAgentState;
|
||||
/** Route ref used to link View › actions to the agent's tabs. */
|
||||
agentRef: string;
|
||||
/** Opens the adapter configure modal. */
|
||||
onConfigure: () => void;
|
||||
/** Scoped reset for one resource (apply update / reset drift / recreate). */
|
||||
onResetResource: (kind: BuiltInManagedResourceKind) => void;
|
||||
/** Trigger the managed routine once without enabling its weekly schedule. */
|
||||
onRunRoutine?: (routineKey: string) => void;
|
||||
/** Enable the managed routine's weekly schedule. */
|
||||
onEnableSchedule?: (routineKey: string) => void;
|
||||
/** Disable the managed routine's weekly schedule. */
|
||||
onDisableSchedule?: (routineKey: string) => void;
|
||||
/** The resource kind whose reset is currently in flight, if any. */
|
||||
resettingResource?: BuiltInManagedResourceKind | null;
|
||||
routineActionPending?: "run" | "enable" | "disable" | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BuiltInBundlePanel({
|
||||
state,
|
||||
agentRef,
|
||||
onConfigure,
|
||||
onResetResource,
|
||||
onRunRoutine,
|
||||
onEnableSchedule,
|
||||
onDisableSchedule,
|
||||
resettingResource = null,
|
||||
routineActionPending = null,
|
||||
className,
|
||||
}: BuiltInBundlePanelProps) {
|
||||
const { status, definition, resources } = state;
|
||||
const bundle = definition.bundle;
|
||||
if (!bundle) return null;
|
||||
|
||||
const adapterReady = status === "ready" || status === "paused";
|
||||
|
||||
// --- Adapter row (derived from the agent lifecycle status) -----------------
|
||||
let adapterChip: ResourceStatusVariant = "ready";
|
||||
let adapterDetail: string | undefined;
|
||||
if (status === "pending_approval") {
|
||||
adapterChip = "pending_approval";
|
||||
adapterDetail = "Waiting on board hire approval before this coach can run.";
|
||||
} else if (!adapterReady) {
|
||||
adapterChip = "needs_setup";
|
||||
adapterDetail = "Pick an adapter this coach can run on.";
|
||||
}
|
||||
|
||||
const skill = findResource(resources, "skill");
|
||||
const instructions = findResource(resources, "instructions");
|
||||
const routine = findResource(resources, "routine");
|
||||
const scheduleEnabled = routine?.scheduleEnabled === true;
|
||||
const routineKey = bundle.routine.routineKey;
|
||||
const scheduleLabel = bundle.routine.scheduleLabel ?? "Weekly schedule";
|
||||
const proposalIssueRef = routine?.pendingUpdateIssueIdentifier ?? routine?.pendingUpdateIssueId ?? null;
|
||||
const proposalHref = proposalIssueRef && routine?.pendingUpdateInteractionId
|
||||
? `/issues/${proposalIssueRef}#interaction-${routine.pendingUpdateInteractionId}`
|
||||
: null;
|
||||
|
||||
const renderResourceRow = (
|
||||
kind: BuiltInManagedResourceKind,
|
||||
label: string,
|
||||
secondary: string,
|
||||
viewHref: string,
|
||||
resource: BuiltInManagedResourceState,
|
||||
) => {
|
||||
const drift = driftVariant(resource);
|
||||
return (
|
||||
<BundleRow
|
||||
key={kind}
|
||||
label={label}
|
||||
secondary={secondary}
|
||||
chips={
|
||||
<>
|
||||
<ResourceStatusChip variant={readinessVariant(resource)} />
|
||||
{drift && <ResourceStatusChip variant={drift} />}
|
||||
</>
|
||||
}
|
||||
detail={driftDetail(resource)}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="link" size="sm">
|
||||
<Link to={viewHref}>View</Link>
|
||||
</Button>
|
||||
<ResourceActionButton
|
||||
resource={resource}
|
||||
label={label}
|
||||
onConfirm={() => onResetResource(kind)}
|
||||
pending={resettingResource === kind}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cn("space-y-2", className)} aria-label="Bundle status">
|
||||
<h3 className="text-sm font-medium">Bundle status</h3>
|
||||
|
||||
<div className="divide-y rounded-lg border px-4">
|
||||
{/* Adapter — no resource entry; readiness is the agent lifecycle. */}
|
||||
<BundleRow
|
||||
label="Adapter"
|
||||
chips={<ResourceStatusChip variant={adapterChip} />}
|
||||
detail={adapterDetail}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={onConfigure}>
|
||||
Configure
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{skill &&
|
||||
renderResourceRow(
|
||||
"skill",
|
||||
"Skill",
|
||||
bundle.skill.displayName || skill.resourceKey,
|
||||
`/agents/${agentRef}/skills`,
|
||||
skill,
|
||||
)}
|
||||
|
||||
{instructions &&
|
||||
renderResourceRow(
|
||||
"instructions",
|
||||
"Instructions",
|
||||
bundle.instructions.entryFile,
|
||||
`/agents/${agentRef}/instructions`,
|
||||
instructions,
|
||||
)}
|
||||
|
||||
{/* Routine — zero-token-by-default; the weekly schedule ships off. */}
|
||||
<BundleRow
|
||||
label="Routine"
|
||||
secondary={bundle.routine.title}
|
||||
chips={
|
||||
<>
|
||||
<ResourceStatusChip
|
||||
variant={scheduleEnabled ? "schedule_on" : "schedule_off"}
|
||||
label={scheduleEnabled ? scheduleLabel : undefined}
|
||||
/>
|
||||
{routine && driftVariant(routine) && (
|
||||
<ResourceStatusChip variant={driftVariant(routine)!} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
detail={
|
||||
scheduleEnabled
|
||||
? "The weekly schedule is enabled and can create background work."
|
||||
: "Nothing runs until you enable the weekly schedule — it costs zero tokens by default."
|
||||
}
|
||||
actions={
|
||||
routine ? (
|
||||
<>
|
||||
{onRunRoutine && (
|
||||
<ConfirmActionButton
|
||||
title="Run Reflection Coach once?"
|
||||
body="Paperclip will create one routine task now. This does not enable the weekly schedule or turn on background work."
|
||||
triggerLabel="Run once"
|
||||
confirmLabel="Run once"
|
||||
pending={routineActionPending === "run"}
|
||||
onConfirm={() => onRunRoutine(routineKey)}
|
||||
/>
|
||||
)}
|
||||
{scheduleEnabled
|
||||
? onDisableSchedule && (
|
||||
<ConfirmActionButton
|
||||
title="Disable the weekly schedule?"
|
||||
body="Paperclip will stop future scheduled Reflection Coach runs. Manual Run once remains available."
|
||||
triggerLabel="Disable schedule"
|
||||
confirmLabel="Disable schedule"
|
||||
pending={routineActionPending === "disable"}
|
||||
onConfirm={() => onDisableSchedule(routineKey)}
|
||||
/>
|
||||
)
|
||||
: onEnableSchedule && (
|
||||
<ConfirmActionButton
|
||||
title="Enable the weekly schedule?"
|
||||
body="Paperclip will allow Reflection Coach to create routine tasks on the weekly schedule. It can spend tokens when those tasks run."
|
||||
triggerLabel="Enable weekly"
|
||||
confirmLabel="Enable weekly"
|
||||
pending={routineActionPending === "enable"}
|
||||
onConfirm={() => onEnableSchedule(routineKey)}
|
||||
/>
|
||||
)}
|
||||
{driftVariant(routine) && (
|
||||
<ResourceActionButton
|
||||
resource={routine}
|
||||
label="routine"
|
||||
onConfirm={() => onResetResource("routine")}
|
||||
pending={resettingResource === "routine"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{proposalHref && (
|
||||
<BundleRow
|
||||
label="Proposal"
|
||||
chips={<ResourceStatusChip variant="proposal_pending" />}
|
||||
detail="A proposed Reflection Coach update is waiting for review."
|
||||
actions={
|
||||
<Button asChild variant="link" size="sm">
|
||||
<Link to={proposalHref}>Review proposal</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ConfigureBuiltInAgentModal } from "./ConfigureBuiltInAgentModal";
|
||||
import type { BuiltInAgentState } from "@/api/builtInAgents";
|
||||
|
||||
const provisionMock = vi.hoisted(() => vi.fn());
|
||||
const updateMock = vi.hoisted(() => vi.fn());
|
||||
const adapterModelsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/builtInAgents", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/api/builtInAgents")>();
|
||||
return { ...actual, builtInAgentsApi: { list: vi.fn(), provision: provisionMock, reset: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: { update: updateMock, adapterModels: adapterModelsMock },
|
||||
}));
|
||||
|
||||
vi.mock("@/adapters/metadata", () => ({
|
||||
listAdapterOptions: () => [
|
||||
{ value: "codex_local", label: "Codex" },
|
||||
{ value: "claude_local", label: "Claude" },
|
||||
{ value: "process", label: "Process" },
|
||||
],
|
||||
}));
|
||||
|
||||
// Stub the shared pickers so the test can drive them without the full form.
|
||||
vi.mock("@/components/AgentConfigForm", () => ({
|
||||
AdapterTypeDropdown: ({ value }: { value: string }) => (
|
||||
<div data-testid="adapter-dropdown" data-value={value} />
|
||||
),
|
||||
ModelDropdown: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<input
|
||||
data-testid="model-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/agent-config-primitives", () => ({
|
||||
Field: ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<label>
|
||||
{label}
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
function makeState(overrides: Partial<BuiltInAgentState> = {}): BuiltInAgentState {
|
||||
return {
|
||||
definition: {
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["briefs"],
|
||||
shortPurpose: "Prepares briefs.",
|
||||
defaultInstructions: "…",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local", "claude_local"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
},
|
||||
status: "not_provisioned",
|
||||
agentId: null,
|
||||
agent: null,
|
||||
pauseReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
function findButton(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.body.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
describe("ConfigureBuiltInAgentModal (PAP-12978)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
const onOpenChange = vi.fn();
|
||||
const onConfigured = vi.fn();
|
||||
|
||||
async function renderModal(state: BuiltInAgentState = makeState()) {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigureBuiltInAgentModal
|
||||
companyId="c1"
|
||||
state={state}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onConfigured={onConfigured}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
provisionMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
adapterModelsMock.mockReset().mockResolvedValue([]);
|
||||
onOpenChange.mockReset();
|
||||
onConfigured.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("disables submit until a model is chosen, then provisions with adapter + model", async () => {
|
||||
provisionMock.mockResolvedValue({ ...makeState(), status: "ready", agentId: "a1" });
|
||||
await renderModal();
|
||||
|
||||
const submit = findButton("Configure");
|
||||
expect(submit).toBeTruthy();
|
||||
expect(submit!.disabled).toBe(true);
|
||||
|
||||
const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement;
|
||||
expect(modelInput).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
flushSync(() => {
|
||||
setter.call(modelInput, "gpt-5");
|
||||
modelInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const submitReady = findButton("Configure")!;
|
||||
expect(submitReady.disabled).toBe(false);
|
||||
flushSync(() => {
|
||||
submitReady.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", {
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5" },
|
||||
});
|
||||
expect(onConfigured).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("sends the budget with provisioning so approval-gated setup preserves it", async () => {
|
||||
provisionMock.mockResolvedValue({
|
||||
...makeState(),
|
||||
status: "pending_approval",
|
||||
agentId: "a1",
|
||||
approval: { id: "approval-1", status: "pending" },
|
||||
});
|
||||
await renderModal();
|
||||
|
||||
const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
flushSync(() => {
|
||||
setter.call(modelInput, "gpt-5");
|
||||
modelInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const budgetInput = document.body.querySelector('input[type="number"]') as HTMLInputElement;
|
||||
flushSync(() => {
|
||||
setter.call(budgetInput, "50");
|
||||
budgetInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
flushSync(() => {
|
||||
findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(provisionMock).toHaveBeenCalled();
|
||||
expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", {
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5" },
|
||||
budgetMonthlyCents: 5000,
|
||||
});
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("provisions non-model adapters so command fields can be completed later", async () => {
|
||||
provisionMock.mockResolvedValue({ ...makeState(), status: "needs_setup", agentId: "a1" });
|
||||
await renderModal(makeState({
|
||||
definition: {
|
||||
...makeState().definition,
|
||||
allowedAdapterTypes: ["process"],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(document.body.textContent).toContain("needs command or endpoint fields");
|
||||
expect(document.body.querySelector('[data-testid="model-input"]')).toBeNull();
|
||||
const submit = findButton("Provision");
|
||||
expect(submit).toBeTruthy();
|
||||
expect(submit!.disabled).toBe(false);
|
||||
flushSync(() => {
|
||||
submit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", {
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
});
|
||||
expect(onConfigured).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("surfaces provision errors inline instead of closing", async () => {
|
||||
const { ApiError } = await import("@/api/client");
|
||||
provisionMock.mockRejectedValue(new ApiError("Adapter not allowed", 422, null));
|
||||
await renderModal();
|
||||
|
||||
const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
flushSync(() => {
|
||||
setter.call(modelInput, "gpt-5");
|
||||
modelInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
flushSync(() => {
|
||||
findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Adapter not allowed");
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field } from "@/components/agent-config-primitives";
|
||||
import { AdapterTypeDropdown, ModelDropdown } from "@/components/AgentConfigForm";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { listAdapterOptions } from "@/adapters/metadata";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ApiError } from "@/api/client";
|
||||
import {
|
||||
builtInAgentsApi,
|
||||
type BuiltInAgentState,
|
||||
} from "@/api/builtInAgents";
|
||||
|
||||
/** Adapters whose config completeness is keyed on a non-empty `model`. */
|
||||
function isModelBasedAdapter(adapterType: string): boolean {
|
||||
return !["process", "command", "http", "openclaw_gateway", "hermes_gateway"].includes(adapterType);
|
||||
}
|
||||
|
||||
function defaultAdapterType(state: BuiltInAgentState): string {
|
||||
return state.definition.allowedAdapterTypes?.[0] ?? "codex_local";
|
||||
}
|
||||
|
||||
function parseBudgetMonthlyCents(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const cents = Math.round(Number(trimmed) * 100);
|
||||
return Number.isFinite(cents) && cents >= 0 ? cents : undefined;
|
||||
}
|
||||
|
||||
export interface ConfigureBuiltInAgentModalProps {
|
||||
companyId: string;
|
||||
state: BuiltInAgentState;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called after a successful provision (e.g. to navigate to the agent). */
|
||||
onConfigured?: (result: BuiltInAgentState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure-on-first-use modal for a built-in agent. Reuses the shared
|
||||
* `AdapterTypeDropdown` + `ModelDropdown` (ux-spec D6 — no second model picker),
|
||||
* plus an optional monthly budget, and submits to the provision endpoint.
|
||||
*/
|
||||
export function ConfigureBuiltInAgentModal({
|
||||
companyId,
|
||||
state,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfigured,
|
||||
}: ConfigureBuiltInAgentModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { definition } = state;
|
||||
|
||||
const [adapterType, setAdapterType] = useState<string>(
|
||||
() => state.agent?.adapterType ?? defaultAdapterType(state),
|
||||
);
|
||||
const [model, setModel] = useState<string>(() => {
|
||||
const config = state.agent?.adapterConfig;
|
||||
return typeof config === "object" && config !== null && typeof (config as Record<string, unknown>).model === "string"
|
||||
? ((config as Record<string, unknown>).model as string)
|
||||
: "";
|
||||
});
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [budgetDollars, setBudgetDollars] = useState<string>(() => {
|
||||
const cents = definition.defaultBudgetMonthlyCents ?? 0;
|
||||
return cents > 0 ? String(cents / 100) : "";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Restrict adapter choices to the registry's allow-list. Non-model adapters
|
||||
// are still selectable: provisioning creates the row, then full agent config
|
||||
// collects command/endpoint fields while the built-in remains `needs_setup`.
|
||||
const disabledTypes = useMemo(() => {
|
||||
const allowed = new Set(definition.allowedAdapterTypes ?? []);
|
||||
return new Set(
|
||||
listAdapterOptions()
|
||||
.map((option) => option.value)
|
||||
.filter((value) => allowed.size > 0 && !allowed.has(value)),
|
||||
);
|
||||
}, [definition.allowedAdapterTypes]);
|
||||
|
||||
const setupSupportedInModal = isModelBasedAdapter(adapterType);
|
||||
|
||||
const { data: fetchedModels } = useQuery({
|
||||
queryKey: queryKeys.agents.adapterModels(companyId, adapterType, null),
|
||||
queryFn: () => agentsApi.adapterModels(companyId, adapterType, {}),
|
||||
enabled: open && Boolean(companyId) && setupSupportedInModal,
|
||||
});
|
||||
const models = fetchedModels ?? [];
|
||||
|
||||
const modelRequired = setupSupportedInModal;
|
||||
const budgetMonthlyCents = parseBudgetMonthlyCents(budgetDollars);
|
||||
const budgetValid = !budgetDollars.trim() || budgetMonthlyCents !== undefined;
|
||||
const canSubmit = budgetValid && (setupSupportedInModal ? !modelRequired || model.trim().length > 0 : true);
|
||||
const submitLabel = setupSupportedInModal
|
||||
? `Configure & enable ${definition.displayName}`
|
||||
: `Provision ${definition.displayName}`;
|
||||
|
||||
const provision = useMutation({
|
||||
mutationFn: async () => {
|
||||
const adapterConfig: Record<string, unknown> = {};
|
||||
if (model.trim()) adapterConfig.model = model.trim();
|
||||
const result = await builtInAgentsApi.provision(companyId, definition.key, {
|
||||
adapterType,
|
||||
adapterConfig,
|
||||
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
if (result.agentId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(result.agentId) });
|
||||
}
|
||||
onConfigured?.(result);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to configure the built-in agent.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (provision.isPending ? undefined : onOpenChange(next))}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set up the {definition.displayName}</DialogTitle>
|
||||
<DialogDescription>{definition.shortPurpose}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<InlineBanner tone="info" compact>
|
||||
Creates <strong>{definition.displayName}</strong> in your roster, badged{" "}
|
||||
<strong>Built-in</strong>. Companies that require hire approval will queue this for the
|
||||
board.
|
||||
</InlineBanner>
|
||||
|
||||
<Field label="Adapter type">
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
onChange={(next) => {
|
||||
setAdapterType(next);
|
||||
setModel("");
|
||||
}}
|
||||
disabledTypes={disabledTypes}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{modelRequired && (
|
||||
// ModelDropdown supplies its own "Model" Field label + hint.
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required
|
||||
groupByProvider={false}
|
||||
creatable
|
||||
/>
|
||||
)}
|
||||
|
||||
{!setupSupportedInModal && (
|
||||
<InlineBanner tone="warning" compact>
|
||||
This adapter needs command or endpoint fields before it can run. Provision the
|
||||
built-in row now, then finish those fields from the full agent configuration.
|
||||
</InlineBanner>
|
||||
)}
|
||||
|
||||
<Field label="Monthly budget (optional)" hint="Leave blank for no cap.">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
placeholder="0"
|
||||
value={budgetDollars}
|
||||
onChange={(event) => setBudgetDollars(event.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">/ month</span>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={provision.isPending}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
provision.mutate();
|
||||
}}
|
||||
disabled={!canSubmit || provision.isPending}
|
||||
>
|
||||
{provision.isPending ? "Configuring…" : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,21 +4,33 @@ import { Button } from "@/components/ui/button";
|
|||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
/** Optional bold heading rendered above the message. */
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
/** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */
|
||||
hideActionIcon?: boolean;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) {
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
onAction,
|
||||
hideActionIcon = false,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="bg-muted/50 p-4 mb-4">
|
||||
<Icon className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">{message}</p>
|
||||
{title && <p className="text-base font-semibold text-foreground mb-1.5">{title}</p>}
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">{message}</p>
|
||||
{action && onAction && (
|
||||
<Button onClick={onAction}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{!hideActionIcon && <Plus className="h-4 w-4 mr-1.5" />}
|
||||
{action}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,4 +59,37 @@ describe("EntityRow", () => {
|
|||
const markup = renderToStaticMarkup(<EntityRow title="Alpha" />);
|
||||
expect(markup).toContain("min-w-0 flex-1");
|
||||
});
|
||||
|
||||
it("gives the title a min-width floor and lets meta shrink under titlePriority (PAP-12988)", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EntityRow
|
||||
title="Alpha"
|
||||
titlePriority
|
||||
meta={<span data-testid="meta-cell">chips</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The name keeps a usable floor instead of collapsing to zero...
|
||||
expect(markup).toContain("min-w-(--sz-6rem)");
|
||||
// ...and the meta cluster is the item that yields (shrinks), not the title.
|
||||
expect(markup).toContain("min-w-0 shrink");
|
||||
expect(markup).not.toContain('class="flex items-center gap-2 shrink-0"');
|
||||
});
|
||||
|
||||
it("stacks a secondaryRow on its own line beneath the main row (PAP-12988)", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EntityRow
|
||||
title="Alpha"
|
||||
titlePriority
|
||||
meta={<span>chips-inline</span>}
|
||||
secondaryRow={<span data-testid="secondary-cell">chips-stacked</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The secondary content renders, and the shell switches from a single flex
|
||||
// row to a stacked block layout so the cluster gets its own full-width line.
|
||||
expect(markup).toContain("secondary-cell");
|
||||
expect(markup).toContain("chips-stacked");
|
||||
expect(markup).not.toMatch(/^<div class="flex items-center gap-3 px-4/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,21 @@ interface EntityRowProps {
|
|||
titleTextClassName?: string;
|
||||
subtitleClassName?: string;
|
||||
reserveSubtitleSpace?: boolean;
|
||||
/**
|
||||
* Make the title (the row's primary identifier) win the flex fight: it keeps a
|
||||
* usable min-width floor and ellipsizes, while the `meta` cluster is the item
|
||||
* that shrinks (and can wrap its own children). Without this, a wide `meta`
|
||||
* cluster starves the title down to zero at narrow widths. Opt-in so existing
|
||||
* callers keep the "title shrinks first" behavior.
|
||||
*/
|
||||
titlePriority?: boolean;
|
||||
/**
|
||||
* Optional content rendered on its own full-width line beneath the main row.
|
||||
* Use this for a chip/action cluster that would otherwise starve the title at
|
||||
* narrow widths — gate it with `xl:hidden` and keep the inline copy in `meta`
|
||||
* behind `hidden xl:flex` so wide layouts are unchanged.
|
||||
*/
|
||||
secondaryRow?: ReactNode;
|
||||
}
|
||||
|
||||
export function EntityRow({
|
||||
|
|
@ -41,10 +56,15 @@ export function EntityRow({
|
|||
titleTextClassName,
|
||||
subtitleClassName,
|
||||
reserveSubtitleSpace,
|
||||
titlePriority,
|
||||
secondaryRow,
|
||||
}: EntityRowProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
const classes = cn(
|
||||
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
|
||||
const shellClasses = cn(
|
||||
// When a secondaryRow is present the shell stacks (main line + secondary
|
||||
// line); otherwise the shell itself is the single flex row.
|
||||
secondaryRow ? "block" : "flex items-center gap-3",
|
||||
"px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
|
||||
isClickable && "cursor-pointer hover:bg-accent/50",
|
||||
selected && "bg-accent/30",
|
||||
className
|
||||
|
|
@ -53,7 +73,15 @@ export function EntityRow({
|
|||
const content = (
|
||||
<>
|
||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||
<div className={cn("min-w-0", !meta && "flex-1", titleClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
// `titlePriority` gives the name a floor so it ellipsizes instead of
|
||||
// collapsing to zero; otherwise the title may shrink to nothing.
|
||||
titlePriority ? "min-w-(--sz-6rem)" : "min-w-0",
|
||||
!meta && "flex-1",
|
||||
titleClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{identifier && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-(--sz-1px)">
|
||||
|
|
@ -78,23 +106,46 @@ export function EntityRow({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
|
||||
{meta && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
// In title-priority mode the meta cluster yields (shrinks/wraps)
|
||||
// before the name does; otherwise it holds its width.
|
||||
titlePriority ? "min-w-0 shrink" : "shrink-0",
|
||||
)}
|
||||
>
|
||||
{meta}
|
||||
</div>
|
||||
)}
|
||||
{meta && <div className={cn("flex-1", metaSpacerClassName)} />}
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
// With a secondaryRow, wrap the main line in its own flex row and stack the
|
||||
// secondary content beneath it (indented to align under the title, past the
|
||||
// leading capsule). Without it, `content` is rendered directly (unchanged).
|
||||
const body = secondaryRow ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">{content}</div>
|
||||
<div className="mt-1 pl-5">{secondaryRow}</div>
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className={cn("no-underline text-inherit", classes)} onClick={onClick}>
|
||||
{content}
|
||||
<Link to={to} className={cn("no-underline text-inherit", shellClasses)} onClick={onClick}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes} onClick={onClick}>
|
||||
{content}
|
||||
<div className={shellClasses} onClick={onClick}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import { Info, AlertTriangle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { brandBanner, type BannerTone } from "@/lib/status-colors";
|
||||
|
||||
const TONE_ICON: Record<BannerTone, LucideIcon> = {
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
};
|
||||
|
||||
export interface InlineBannerProps {
|
||||
/** Visual tone. `info` (blue) for provenance/context, `warning` (amber) for paused/attention. */
|
||||
tone?: BannerTone;
|
||||
/** Optional bold heading rendered above the body. */
|
||||
title?: ReactNode;
|
||||
/** Body content. */
|
||||
children?: ReactNode;
|
||||
/** Override the leading icon, or pass `false` to omit it. */
|
||||
icon?: LucideIcon | false;
|
||||
/** Optional trailing actions (buttons/links) rendered on the right at ≥sm, wrapped below on mobile. */
|
||||
actions?: ReactNode;
|
||||
/** Denser padding for embedding inside modals/dialogs. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-backed inline banner used for full-width informational and warning
|
||||
* notices. Follows the existing bespoke-banner convention (`border … bg-…
|
||||
* rounded-lg p-4`) but centralizes the color recipe in `brandBanner` so
|
||||
* feature surfaces don't hand-roll `bg-yellow-*`/`bg-blue-*` variants.
|
||||
*
|
||||
* See `/design-guide` for tone examples.
|
||||
*/
|
||||
export function InlineBanner({
|
||||
tone = "info",
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
actions,
|
||||
compact = false,
|
||||
className,
|
||||
}: InlineBannerProps) {
|
||||
const Icon = icon === false ? null : (icon ?? TONE_ICON[tone]);
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-lg border sm:flex-row sm:items-start sm:justify-between",
|
||||
compact ? "p-3" : "p-4",
|
||||
brandBanner[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{Icon && <Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />}
|
||||
<div className="space-y-1 text-sm">
|
||||
{title && <p className="font-medium leading-tight">{title}</p>}
|
||||
{children && <div className="leading-snug opacity-90">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 pl-6 sm:pl-0">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { brandChipBadge, type BrandChipColor } from "@/lib/status-colors";
|
||||
|
||||
/**
|
||||
* The load-bearing visual grammar for the built-in bundle status panel
|
||||
* (Reflection Coach — [PAP-13099], ux-spec §4). Each variant double-encodes
|
||||
* state as glyph + word + color so it never relies on color alone
|
||||
* (WCAG 1.4.1). Colors route through the shared `brandChipBadge` families — no
|
||||
* bespoke tints are minted here (ux-spec §10).
|
||||
*
|
||||
* A single resource shows at most one readiness chip and at most one drift
|
||||
* chip; when both a readiness problem and a drift state coexist, the caller
|
||||
* suppresses the drift chip until readiness is `ready` (ux-spec §4).
|
||||
*/
|
||||
export type ResourceStatusVariant =
|
||||
| "ready"
|
||||
| "needs_setup"
|
||||
| "missing"
|
||||
| "error"
|
||||
| "update_available"
|
||||
| "drifted"
|
||||
| "schedule_off"
|
||||
| "schedule_on"
|
||||
| "pending_approval"
|
||||
| "proposal_pending";
|
||||
|
||||
interface VariantSpec {
|
||||
color: BrandChipColor;
|
||||
glyph: string;
|
||||
label: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const VARIANTS: Record<ResourceStatusVariant, VariantSpec> = {
|
||||
ready: { color: "green", glyph: "●", label: "Ready", title: "Materialized and matches the shipped default" },
|
||||
needs_setup: { color: "amber", glyph: "⚠", label: "Needs setup", title: "Present but not usable yet" },
|
||||
missing: { color: "amber", glyph: "⚠", label: "Missing", title: "Expected resource absent; reconcile will recreate it" },
|
||||
error: { color: "red", glyph: "✕", label: "Error", title: "Failed to load or reconcile" },
|
||||
update_available: {
|
||||
color: "blue",
|
||||
glyph: "↑",
|
||||
label: "Update available",
|
||||
title: "Unedited — a newer shipped default can be applied",
|
||||
},
|
||||
drifted: {
|
||||
color: "gray",
|
||||
glyph: "✎",
|
||||
label: "Drifted",
|
||||
title: "You've edited this; your changes are kept, not overwritten",
|
||||
},
|
||||
schedule_off: {
|
||||
color: "gray",
|
||||
glyph: "◌",
|
||||
label: "Schedule off",
|
||||
title: "No background work runs until you enable it — costs zero tokens",
|
||||
},
|
||||
schedule_on: { color: "green", glyph: "●", label: "Weekly", title: "Runs on the weekly schedule" },
|
||||
pending_approval: {
|
||||
color: "amber",
|
||||
glyph: "⚠",
|
||||
label: "Pending approval",
|
||||
title: "Waiting on board hire approval before it can run",
|
||||
},
|
||||
proposal_pending: {
|
||||
color: "blue",
|
||||
glyph: "↑",
|
||||
label: "Proposal pending",
|
||||
title: "A proposed update is waiting for your review",
|
||||
},
|
||||
};
|
||||
|
||||
export function ResourceStatusChip({
|
||||
variant,
|
||||
label,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
variant: ResourceStatusVariant;
|
||||
/** Override the default label (e.g. "Weekly · Mon 09:00 UTC"). */
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const spec = VARIANTS[variant];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
brandChipBadge[spec.color],
|
||||
"font-medium",
|
||||
compact && "px-1.5 py-0 text-(length:--text-nano)",
|
||||
className,
|
||||
)}
|
||||
title={spec.title}
|
||||
>
|
||||
<span aria-hidden="true">{spec.glyph}</span>
|
||||
{label ?? spec.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ import { useDialogActions } from "../context/DialogContext";
|
|||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { builtInAgentsApi, type BuiltInAgentStatus } from "../api/builtInAgents";
|
||||
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "./BuiltInAgentBadges";
|
||||
import { authApi } from "../api/auth";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
|
|
@ -112,6 +114,7 @@ function SidebarAgentItem({
|
|||
rail,
|
||||
runCount,
|
||||
setSidebarOpen,
|
||||
builtInStatus,
|
||||
starred = false,
|
||||
onToggleStar,
|
||||
starPending = false,
|
||||
|
|
@ -127,6 +130,7 @@ function SidebarAgentItem({
|
|||
rail: boolean;
|
||||
runCount: number;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
builtInStatus?: BuiltInAgentStatus;
|
||||
starred?: boolean;
|
||||
onToggleStar?: (agent: Agent, starred: boolean) => void;
|
||||
starPending?: boolean;
|
||||
|
|
@ -147,6 +151,10 @@ function SidebarAgentItem({
|
|||
: isPaused && hasInvalidOrgChain
|
||||
? "Invalid org chain"
|
||||
: pauseResumeLabel;
|
||||
const trailingLabel = [
|
||||
builtInStatus ? `Built-in agent ${builtInStatus.replace(/_/g, " ")}` : null,
|
||||
hasInvalidOrgChain ? "Invalid reporting chain" : null,
|
||||
].filter(Boolean).join(", ") || undefined;
|
||||
|
||||
// C11 (DECISION-SHEET.md): the row itself is a SidebarNavItem, so agent rows
|
||||
// share the nav-row chrome (type, active state, rail tooltip, live dot).
|
||||
|
|
@ -157,6 +165,7 @@ function SidebarAgentItem({
|
|||
iconNode={<AgentIcon icon={agent.icon} className="shrink-0 h-4 w-4" />}
|
||||
active={isActive}
|
||||
liveCount={runCount}
|
||||
labelClassName={builtInStatus ? "min-w-(--sz-4_5rem) flex-initial" : undefined}
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
// Reserve room for the hover ⋯ menu; starred rows widen it for the
|
||||
|
|
@ -164,11 +173,21 @@ function SidebarAgentItem({
|
|||
starred && !isMobile ? "pr-14" : "pr-8",
|
||||
)}
|
||||
trailing={
|
||||
hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
builtInStatus || hasInvalidOrgChain ? (
|
||||
<span className="ml-1 flex shrink-0 items-center gap-1">
|
||||
{builtInStatus ? (
|
||||
<>
|
||||
<BuiltInAgentBadge compact />
|
||||
<BuiltInLifecycleChip status={builtInStatus} compact />
|
||||
</>
|
||||
) : null}
|
||||
{hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : null}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
trailingLabel={hasInvalidOrgChain ? "Invalid reporting chain" : undefined}
|
||||
trailingLabel={trailingLabel}
|
||||
liveAccessory={
|
||||
agent.pauseReason === "budget" ? <BudgetSidebarMarker title="Agent paused by budget" /> : undefined
|
||||
}
|
||||
|
|
@ -291,6 +310,18 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
|||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: builtInAgents } = useQuery({
|
||||
queryKey: queryKeys.builtInAgents.list(selectedCompanyId!),
|
||||
queryFn: () => builtInAgentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const builtInStatusByAgentId = useMemo(() => {
|
||||
const map = new Map<string, BuiltInAgentStatus>();
|
||||
for (const entry of builtInAgents ?? []) {
|
||||
if (entry.agentId) map.set(entry.agentId, entry.status);
|
||||
}
|
||||
return map;
|
||||
}, [builtInAgents]);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
|
@ -517,6 +548,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
|||
rail={rail}
|
||||
runCount={liveCountByAgent.get(agent.id) ?? 0}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
builtInStatus={builtInStatusByAgentId.get(agent.id)}
|
||||
starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)}
|
||||
onToggleStar={toggleStarAgent}
|
||||
starPending={agentStarPending(agent)}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ describe("SidebarNavItem", () => {
|
|||
return container.querySelector("a") as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
function classTokens(element: Element | null | undefined) {
|
||||
return element?.className.toString().split(/\s+/).filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
it("shows the full label and numeric badge when expanded", () => {
|
||||
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
|
||||
|
||||
|
|
@ -89,8 +93,8 @@ describe("SidebarNavItem", () => {
|
|||
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.className).not.toContain("sr-only");
|
||||
expect(label?.className).toContain("w-0");
|
||||
expect(label?.className).toContain("overflow-hidden");
|
||||
expect(classTokens(label)).toContain("w-0");
|
||||
expect(classTokens(label)).toContain("overflow-hidden");
|
||||
|
||||
// The numeric count is no longer rendered as text; it is a dot with an
|
||||
// accessible text equivalent on the link.
|
||||
|
|
@ -142,8 +146,8 @@ describe("SidebarNavItem", () => {
|
|||
);
|
||||
|
||||
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
|
||||
expect(label?.className).not.toContain("w-0");
|
||||
expect(label?.className).toContain("flex-1");
|
||||
expect(classTokens(label)).not.toContain("w-0");
|
||||
expect(classTokens(label)).toContain("flex-1");
|
||||
// Full numeric badge, no rail aria-label, no tooltip wrapper.
|
||||
expect(container.textContent).toContain("28");
|
||||
expect(link().getAttribute("aria-label")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ interface SidebarNavItemProps {
|
|||
iconNode?: ReactNode;
|
||||
end?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
/**
|
||||
|
|
@ -75,6 +76,7 @@ export function SidebarNavItem({
|
|||
iconNode,
|
||||
end,
|
||||
className,
|
||||
labelClassName,
|
||||
badge,
|
||||
badgeTone = "default",
|
||||
badgeLabel,
|
||||
|
|
@ -156,7 +158,7 @@ export function SidebarNavItem({
|
|||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{label}</span>
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : cn("min-w-0 flex-1 truncate", labelClassName)}>{label}</span>
|
||||
{!rail && trailing}
|
||||
{!rail && textBadge && (
|
||||
<Badge variant="ghost"
|
||||
|
|
|
|||
|
|
@ -1565,6 +1565,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
--sz-80vh: 80vh; /* Extracted from ui/src/components/DocumentAnnotationPanel.tsx (max-h-[80vh]). */
|
||||
--sz-360px: 360px; /* Extracted from ui/src/components/DocumentAnnotationPanel.tsx (w-[360px]). */
|
||||
--sz-85vh: 85vh; /* Extracted from ui/src/components/DocumentDiffModal.tsx (max-h-[85vh]). */
|
||||
--sz-6rem: 6rem; /* Extracted from ui/src/components/EntityRow.tsx (min-w-[6rem]). */
|
||||
--sz-1px: 1px; /* Extracted from ui/src/components/EntityRow.tsx (top-[1px]). */
|
||||
--sz-100px: 100px; /* Extracted from ui/src/components/ExecutionParticipantPicker.tsx (max-w-[100px]). */
|
||||
--sz-calc-3: min(840px,calc(100dvh - 2rem)); /* Extracted from ui/src/components/FileViewerSheet.tsx (h-[min(840px,calc(100dvh-2rem))]). */
|
||||
|
|
@ -1618,6 +1619,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
--sz-calc-23: min(32rem,calc(100vw - 2rem)); /* Extracted from ui/src/components/SearchableSelect.tsx (max-w-[min(32rem,calc(100vw-2rem))]). */
|
||||
--sz-277px: 277px; /* Extracted from ui/src/components/SidebarAccountMenu.test.tsx (w-[277px]). */
|
||||
--sz-calc-24: calc(100vw - 1rem); /* Extracted from ui/src/components/SidebarAccountMenu.tsx (max-w-[calc(100vw-1rem)]). */
|
||||
--sz-4_5rem: 4.5rem; /* Extracted from ui/src/components/SidebarAgents.tsx (min-w-[4.5rem]). */
|
||||
--sz-36px: 36px; /* Extracted from ui/src/components/WorkspaceFileBrowser.tsx (min-h-[36px]). */
|
||||
--sz-32px: 32px; /* Extracted from ui/src/components/WorkspaceFileBrowser.tsx (min-h-[32px]). */
|
||||
--sz-30px: 30px; /* Extracted from ui/src/components/WorkspaceFileBrowser.tsx (min-h-[30px]). */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import type { ToastInput } from "@/context/ToastContext";
|
||||
|
||||
export interface BuiltInAgentPausedToastOptions {
|
||||
/** Display name of the paused built-in agent, e.g. "Briefs Agent". */
|
||||
displayName: string;
|
||||
/** Deep link to the agent page (from `agentUrl(agent)`). */
|
||||
agentHref: string;
|
||||
/** Noun for the feature item, e.g. "brief". */
|
||||
featureNoun?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "use-while-paused" toast payload (ux-spec §5 / D9).
|
||||
*
|
||||
* `ToastAction` supports a single `href` link only, so v1 carries one
|
||||
* "View agent" link and Resume happens on the agent page. Deduped so repeated
|
||||
* feature actions don't stack duplicate toasts.
|
||||
*/
|
||||
export function buildBuiltInAgentPausedToast(options: BuiltInAgentPausedToastOptions): ToastInput {
|
||||
const noun = options.featureNoun ?? "item";
|
||||
return {
|
||||
dedupeKey: `built-in-agent-paused:${options.displayName}`,
|
||||
title: `${options.displayName} is paused`,
|
||||
body: `Resume the agent to generate this ${noun}.`,
|
||||
tone: "warn",
|
||||
action: { label: "View agent", href: options.agentHref },
|
||||
};
|
||||
}
|
||||
|
|
@ -57,6 +57,9 @@ export const queryKeys = {
|
|||
detectModel: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "detect-model", adapterType] as const,
|
||||
},
|
||||
builtInAgents: {
|
||||
list: (companyId: string) => ["built-in-agents", companyId] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
mentionPool: (companyId: string) => ["issues", companyId, "mention-pool"] as const,
|
||||
|
|
|
|||
|
|
@ -167,6 +167,24 @@ export const runningLabelText = "text-[#1D4ED8] dark:text-[#2563EB]";
|
|||
* (liveness), `todo` amber (queued), `in_review` violet (awaiting review),
|
||||
* `done` green, `blocked` red, `backlog`/`cancelled` gray (inert).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline banner tones (built-in agents provenance / paused notices)
|
||||
//
|
||||
// Softer, full-width banner surface derived from the same brand hue anchors as
|
||||
// `brandChipBadge`. `info` (blue) carries provenance/informational context;
|
||||
// `warning` (amber) carries paused/attention context. Consumed by
|
||||
// `<InlineBanner>` so feature banners stay token-backed instead of hand-rolling
|
||||
// per-instance `bg-yellow-*`/`bg-blue-*` recipes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BannerTone = "info" | "warning";
|
||||
|
||||
export const brandBanner: Record<BannerTone, string> = {
|
||||
info: "border-[#2563EB]/40 bg-[#DBEAFE]/50 text-[#1D4ED8] dark:border-[#2563eb59] dark:bg-[#2563eb14] dark:text-[#93C5FD]",
|
||||
warning: "border-[#F59E0B]/50 bg-[#FEF3C7]/60 text-[#B45309] dark:border-[#f59e0b59] dark:bg-[#f59e0b12] dark:text-[#F59E0B]",
|
||||
};
|
||||
|
||||
export const issueStatusColor: Record<string, BrandChipColor> = {
|
||||
backlog: "gray",
|
||||
todo: "amber",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
type ClaudeLoginResult,
|
||||
type AgentPermissionUpdate,
|
||||
} from "../api/agents";
|
||||
import { builtInAgentsApi, type BuiltInManagedResourceKind } from "../api/builtInAgents";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -41,6 +43,10 @@ import { StarToggle } from "../components/StarToggle";
|
|||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||
import { InlineBanner } from "../components/InlineBanner";
|
||||
import { BuiltInAgentBadge } from "../components/BuiltInAgentBadges";
|
||||
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
|
||||
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { TrustPresetSection } from "../components/TrustPresetSection";
|
||||
import { FileTree, buildFileTree } from "../components/FileTree";
|
||||
|
|
@ -707,6 +713,80 @@ export function AgentDetail() {
|
|||
? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId)
|
||||
: "joined";
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
const builtInAgentsEnabled = experimentalSettings?.enableBuiltInAgents === true;
|
||||
const { data: builtInStates } = useQuery({
|
||||
queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!),
|
||||
queryFn: () => builtInAgentsApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId && builtInAgentsEnabled,
|
||||
});
|
||||
const builtInState = builtInAgentsEnabled
|
||||
? builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null
|
||||
: null;
|
||||
const builtInFeatureLabel = builtInState
|
||||
? builtInState.definition.featureKeys
|
||||
.map((key) => key.charAt(0).toUpperCase() + key.slice(1))
|
||||
.join(", ")
|
||||
: "";
|
||||
const invalidateBuiltIn = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!) });
|
||||
if (resolvedAgentId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(resolvedAgentId) });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
}, [queryClient, resolvedCompanyId, resolvedAgentId, routeAgentRef]);
|
||||
|
||||
const resetBuiltIn = useMutation({
|
||||
mutationFn: () => builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
});
|
||||
|
||||
const [showBuiltInConfigure, setShowBuiltInConfigure] = useState(false);
|
||||
const resetBuiltInResource = useMutation({
|
||||
mutationFn: (kind: BuiltInManagedResourceKind) =>
|
||||
builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key, [kind]),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to update bundle resource");
|
||||
},
|
||||
});
|
||||
const runBuiltInRoutine = useMutation({
|
||||
mutationFn: (routineKey: string) =>
|
||||
builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to run built-in routine");
|
||||
},
|
||||
});
|
||||
const enableBuiltInSchedule = useMutation({
|
||||
mutationFn: (routineKey: string) =>
|
||||
builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule");
|
||||
},
|
||||
});
|
||||
const disableBuiltInSchedule = useMutation({
|
||||
mutationFn: (routineKey: string) =>
|
||||
builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule");
|
||||
},
|
||||
});
|
||||
const builtInRoutineActionPending =
|
||||
runBuiltInRoutine.isPending
|
||||
? "run"
|
||||
: enableBuiltInSchedule.isPending
|
||||
? "enable"
|
||||
: disableBuiltInSchedule.isPending
|
||||
? "disable"
|
||||
: null;
|
||||
|
||||
const { data: runtimeState } = useQuery({
|
||||
queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
|
||||
queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined),
|
||||
|
|
@ -1022,7 +1102,10 @@ export function AgentDetail() {
|
|||
</button>
|
||||
</AgentIconPicker>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
|
||||
{builtInState && <BuiltInAgentBadge />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{roleLabels[agent.role] ?? agent.role}
|
||||
{agent.title ? ` - ${agent.title}` : ""}
|
||||
|
|
@ -1051,6 +1134,21 @@ export function AgentDetail() {
|
|||
workActionsDisabled={hasInvalidOrgChain}
|
||||
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
|
||||
onActionError={setActionError}
|
||||
hideTerminate={Boolean(builtInState)}
|
||||
pauseConfirm={
|
||||
builtInState
|
||||
? {
|
||||
title: `Pause the ${builtInState.definition.displayName}?`,
|
||||
description: (
|
||||
<>
|
||||
{builtInFeatureLabel} depends on this agent. While paused,{" "}
|
||||
{builtInFeatureLabel.toLowerCase()} generation is skipped and the{" "}
|
||||
{builtInFeatureLabel} page shows a warning.
|
||||
</>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{mobileLiveRun && (
|
||||
<Link
|
||||
|
|
@ -1068,6 +1166,54 @@ export function AgentDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{builtInState && (
|
||||
<InlineBanner
|
||||
tone="info"
|
||||
title="Built-in agent"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetBuiltIn.mutate()}
|
||||
disabled={resetBuiltIn.isPending}
|
||||
>
|
||||
{resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Ships with Paperclip and powers <strong>{builtInFeatureLabel}</strong>. Configure it like
|
||||
any agent — model, instructions, budget. It can be paused but not deleted; pausing it
|
||||
pauses {builtInFeatureLabel}.
|
||||
</InlineBanner>
|
||||
)}
|
||||
|
||||
{builtInState?.definition.bundle && (
|
||||
<BuiltInBundlePanel
|
||||
state={builtInState}
|
||||
agentRef={canonicalAgentRef}
|
||||
onConfigure={() => setShowBuiltInConfigure(true)}
|
||||
onResetResource={(kind) => resetBuiltInResource.mutate(kind)}
|
||||
onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)}
|
||||
onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)}
|
||||
onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)}
|
||||
resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null}
|
||||
routineActionPending={builtInRoutineActionPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{builtInState && resolvedCompanyId && (
|
||||
<ConfigureBuiltInAgentModal
|
||||
companyId={resolvedCompanyId}
|
||||
state={builtInState}
|
||||
open={showBuiltInConfigure}
|
||||
onOpenChange={setShowBuiltInConfigure}
|
||||
onConfigured={() => {
|
||||
setShowBuiltInConfigure(false);
|
||||
invalidateBuiltIn();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!urlRunId && (
|
||||
<Tabs
|
||||
value={activeView}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import type { Agent, Environment, EnvironmentCapabilities } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import type { BuiltInAgentState } from "../api/builtInAgents";
|
||||
import { Agents } from "./Agents";
|
||||
import type { AgentOrgChainHealth } from "@paperclipai/shared";
|
||||
|
||||
const mockRouterState = vi.hoisted(() => ({
|
||||
pathname: "/agents/all",
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
org: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBuiltInAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
provision: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
capabilities: vi.fn(),
|
||||
|
|
@ -41,8 +53,8 @@ vi.mock("@/lib/router", () => ({
|
|||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/agents/all", search: "", hash: "", state: null }),
|
||||
useNavigate: () => vi.fn(),
|
||||
useLocation: () => ({ pathname: mockRouterState.pathname, search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockRouterState.navigate,
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
|
|
@ -65,6 +77,10 @@ vi.mock("../api/agents", () => ({
|
|||
agentsApi: mockAgentsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/builtInAgents", () => ({
|
||||
builtInAgentsApi: mockBuiltInAgentsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/environments", () => ({
|
||||
environmentsApi: mockEnvironmentsApi,
|
||||
}));
|
||||
|
|
@ -124,6 +140,24 @@ function makeAgent(overrides: Partial<Agent>): Agent {
|
|||
};
|
||||
}
|
||||
|
||||
function makeBuiltInAgentState(overrides: Partial<BuiltInAgentState> = {}): BuiltInAgentState {
|
||||
return {
|
||||
definition: {
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["Briefs"],
|
||||
shortPurpose: "Generates briefs.",
|
||||
defaultInstructions: "You are Paperclip's built-in Briefs agent.",
|
||||
defaultRole: "engineer",
|
||||
},
|
||||
status: "ready",
|
||||
agentId: "built-in-agent",
|
||||
agent: null,
|
||||
pauseReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEnvironment(overrides: Partial<Environment>): Environment {
|
||||
return {
|
||||
id: "env-1",
|
||||
|
|
@ -181,9 +215,11 @@ const environmentCapabilities: EnvironmentCapabilities = {
|
|||
function makeInstanceSettings({
|
||||
defaultEnvironmentId = null,
|
||||
enableEnvironments = true,
|
||||
enableBuiltInAgents = false,
|
||||
}: {
|
||||
defaultEnvironmentId?: string | null;
|
||||
enableEnvironments?: boolean;
|
||||
enableBuiltInAgents?: boolean;
|
||||
} = {}) {
|
||||
return {
|
||||
id: "instance-settings-1",
|
||||
|
|
@ -209,6 +245,7 @@ function makeInstanceSettings({
|
|||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
|
|
@ -263,6 +300,8 @@ describe("Agents", () => {
|
|||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouterState.pathname = "/agents/all";
|
||||
mockRouterState.navigate.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
|
|
@ -286,6 +325,7 @@ describe("Agents", () => {
|
|||
reports: [],
|
||||
},
|
||||
]);
|
||||
mockBuiltInAgentsApi.list.mockResolvedValue([]);
|
||||
mockEnvironmentsApi.list.mockResolvedValue([
|
||||
makeEnvironment({ id: "env-daytona" }),
|
||||
]);
|
||||
|
|
@ -402,6 +442,58 @@ describe("Agents", () => {
|
|||
expect(subtitle?.classList.contains("truncate")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the built-in agents route segment as the built-in filter", async () => {
|
||||
mockRouterState.pathname = "/agents/builtin";
|
||||
mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true }));
|
||||
const builtInAgent = makeAgent({
|
||||
id: "built-in-agent",
|
||||
name: "Briefs Agent",
|
||||
urlKey: "briefs-agent",
|
||||
});
|
||||
const regularAgent = makeAgent({
|
||||
id: "regular-agent",
|
||||
name: "Regular Agent",
|
||||
urlKey: "regular-agent",
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([builtInAgent, regularAgent]);
|
||||
mockAgentsApi.org.mockResolvedValue([
|
||||
{
|
||||
id: "built-in-agent",
|
||||
name: "Briefs Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reports: [],
|
||||
},
|
||||
{
|
||||
id: "regular-agent",
|
||||
name: "Regular Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reports: [],
|
||||
},
|
||||
]);
|
||||
mockBuiltInAgentsApi.list.mockResolvedValue([
|
||||
makeBuiltInAgentState({ agentId: "built-in-agent", agent: builtInAgent }),
|
||||
]);
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<Agents />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("1 agent");
|
||||
expect(container.textContent).toContain("Briefs Agent");
|
||||
expect(container.textContent).not.toContain("Regular Agent");
|
||||
});
|
||||
|
||||
it("shows effective environment and sandbox provider beside agents", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
|
|
@ -720,6 +812,82 @@ describe("Agents", () => {
|
|||
expect(container.querySelector('select[aria-label="Group agents"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("hides built-in agent surfaces while the experimental flag is disabled", async () => {
|
||||
mockRouterState.pathname = "/agents/builtin";
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<Agents />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockBuiltInAgentsApi.list).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain("Built-in");
|
||||
expect(mockRouterState.navigate).toHaveBeenCalledWith("/agents/all", { replace: true });
|
||||
});
|
||||
|
||||
it("shows and filters built-in agents when the experimental flag is enabled", async () => {
|
||||
mockRouterState.pathname = "/agents/builtin";
|
||||
mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true }));
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
id: "built-in-agent",
|
||||
name: "Briefs Agent",
|
||||
urlKey: "briefs-agent",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "regular-agent",
|
||||
name: "Regular Agent",
|
||||
urlKey: "regular-agent",
|
||||
}),
|
||||
]);
|
||||
mockAgentsApi.org.mockResolvedValue([
|
||||
{
|
||||
id: "built-in-agent",
|
||||
name: "Briefs Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reports: [],
|
||||
},
|
||||
{
|
||||
id: "regular-agent",
|
||||
name: "Regular Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reports: [],
|
||||
},
|
||||
]);
|
||||
mockBuiltInAgentsApi.list.mockResolvedValue([
|
||||
makeBuiltInAgentState({ agentId: "built-in-agent" }),
|
||||
]);
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<Agents />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockBuiltInAgentsApi.list).toHaveBeenCalledWith("company-1");
|
||||
expect(container.textContent).toContain("Built-in");
|
||||
expect(container.textContent).toContain("Briefs Agent");
|
||||
expect(container.textContent).not.toContain("Regular Agent");
|
||||
expect(mockRouterState.navigate).not.toHaveBeenCalledWith("/agents/all", { replace: true });
|
||||
});
|
||||
|
||||
it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => {
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
|
||||
import { Link, useNavigate, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { builtInAgentsApi, type BuiltInAgentState } from "../api/builtInAgents";
|
||||
import { environmentsApi } from "../api/environments";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -15,6 +16,7 @@ import { AgentActionButtons } from "../components/AgentActionButtons";
|
|||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { StarToggle } from "../components/StarToggle";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
|
|
@ -34,7 +36,28 @@ import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
|||
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "error";
|
||||
// Lazy-loaded so the roster page doesn't statically pull in the full
|
||||
// AgentConfigForm module graph (the modal reuses its adapter/model pickers).
|
||||
const ConfigureBuiltInAgentModal = lazy(() =>
|
||||
import("../components/ConfigureBuiltInAgentModal").then((m) => ({
|
||||
default: m.ConfigureBuiltInAgentModal,
|
||||
})),
|
||||
);
|
||||
|
||||
export const AGENT_FILTER_TABS = ["all", "active", "paused", "error", "builtin"] as const;
|
||||
type FilterTab = (typeof AGENT_FILTER_TABS)[number];
|
||||
|
||||
const AGENT_FILTER_TAB_ITEMS: { value: FilterTab; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "builtin", label: "Built-in" },
|
||||
];
|
||||
|
||||
function isFilterTab(value: string): value is FilterTab {
|
||||
return (AGENT_FILTER_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
interface EnvironmentDescriptor {
|
||||
label: string;
|
||||
|
|
@ -67,9 +90,14 @@ function matchesFilter(status: string, tab: FilterTab): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
||||
function filterAgents(agents: Agent[], tab: FilterTab, builtInAgentIds: Set<string>): Agent[] {
|
||||
return agents
|
||||
.filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab))
|
||||
.filter((a) => {
|
||||
if (HIDDEN_AGENT_STATUSES.has(a.status)) return false;
|
||||
// The `builtin` filter keys on the built-in marker, not agent status.
|
||||
if (tab === "builtin") return builtInAgentIds.has(a.id);
|
||||
return matchesFilter(a.status, tab);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
|
|
@ -135,17 +163,20 @@ function resolveAgentEnvironment(
|
|||
: describeMissingEnvironment(environmentId);
|
||||
}
|
||||
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<string>): OrgNode[] {
|
||||
return nodes
|
||||
.reduce<OrgNode[]>((acc, node) => {
|
||||
const filteredReports = filterOrgTree(node.reports, tab);
|
||||
const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds);
|
||||
// Hidden agents (terminated / pending_approval) never render as a row, but
|
||||
// any visible reports are promoted so the tree doesn't lose live agents.
|
||||
if (HIDDEN_AGENT_STATUSES.has(node.status)) {
|
||||
acc.push(...filteredReports);
|
||||
return acc;
|
||||
}
|
||||
if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
|
||||
const nodeMatches = tab === "builtin"
|
||||
? builtInAgentIds.has(node.id)
|
||||
: matchesFilter(node.status, tab);
|
||||
if (nodeMatches || filteredReports.length > 0) {
|
||||
acc.push({ ...node, reports: filteredReports });
|
||||
}
|
||||
return acc;
|
||||
|
|
@ -161,11 +192,39 @@ export function Agents() {
|
|||
const location = useLocation();
|
||||
const { isMobile } = useSidebar();
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "all";
|
||||
const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all";
|
||||
const requestedTab: FilterTab = isFilterTab(pathSegment) ? pathSegment : "all";
|
||||
const [view, setView] = useState<"list" | "org">("org");
|
||||
const forceListView = isMobile;
|
||||
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
||||
|
||||
const { data: instanceSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.settings,
|
||||
queryFn: () => instanceSettingsApi.get(),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const builtInAgentsEnabled = instanceSettings?.experimental.enableBuiltInAgents === true;
|
||||
const tab: FilterTab = requestedTab === "builtin" && !builtInAgentsEnabled ? "all" : requestedTab;
|
||||
const visibleTabItems = useMemo(
|
||||
() => AGENT_FILTER_TAB_ITEMS.filter((item) => item.value !== "builtin" || builtInAgentsEnabled),
|
||||
[builtInAgentsEnabled],
|
||||
);
|
||||
|
||||
const { data: builtInAgents } = useQuery({
|
||||
queryKey: queryKeys.builtInAgents.list(selectedCompanyId!),
|
||||
queryFn: () => builtInAgentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && builtInAgentsEnabled,
|
||||
});
|
||||
const builtInByAgentId = useMemo(() => {
|
||||
const map = new Map<string, BuiltInAgentState>();
|
||||
if (!builtInAgentsEnabled) return map;
|
||||
for (const entry of builtInAgents ?? []) {
|
||||
if (entry.agentId) map.set(entry.agentId, entry);
|
||||
}
|
||||
return map;
|
||||
}, [builtInAgents, builtInAgentsEnabled]);
|
||||
const builtInAgentIds = useMemo(() => new Set(builtInByAgentId.keys()), [builtInByAgentId]);
|
||||
const [configureState, setConfigureState] = useState<BuiltInAgentState | null>(null);
|
||||
|
||||
const { data: agents, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
|
|
@ -178,11 +237,6 @@ export function Agents() {
|
|||
enabled: !!selectedCompanyId && effectiveView === "org",
|
||||
});
|
||||
|
||||
const { data: instanceSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.settings,
|
||||
queryFn: () => instanceSettingsApi.get(),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const environmentsEnabled = instanceSettings?.experimental.enableEnvironments === true;
|
||||
|
||||
const { data: environments } = useQuery({
|
||||
|
|
@ -253,6 +307,12 @@ export function Agents() {
|
|||
setBreadcrumbs([{ label: "Agents" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCompanyId && requestedTab === "builtin" && instanceSettings && !builtInAgentsEnabled) {
|
||||
navigate("/agents/all", { replace: true });
|
||||
}
|
||||
}, [builtInAgentsEnabled, instanceSettings, navigate, requestedTab, selectedCompanyId]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
||||
}
|
||||
|
|
@ -261,8 +321,8 @@ export function Agents() {
|
|||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
const filtered = filterAgents(agents ?? [], tab);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
|
||||
const filtered = filterAgents(agents ?? [], tab, builtInAgentIds);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds);
|
||||
const environmentDataLoading = environmentsEnabled && environments === undefined;
|
||||
const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1);
|
||||
const resolveRenderedEnvironment = (agentId: string) => (
|
||||
|
|
@ -280,6 +340,33 @@ export function Agents() {
|
|||
const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined;
|
||||
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
|
||||
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
|
||||
const builtInState = builtInByAgentId.get(agent.id);
|
||||
// Provenance badge + lifecycle chip + inline `Set up`. Rendered inline in
|
||||
// `meta` at xl (where there's room and the meta columns align) and on a
|
||||
// dedicated full-width line beneath the name below xl, so the chips never
|
||||
// starve the name — the row's primary identifier — at narrow widths.
|
||||
const builtInCluster = builtInState ? (
|
||||
<>
|
||||
<BuiltInAgentBadge />
|
||||
<BuiltInLifecycleChip status={builtInState.status} />
|
||||
{builtInState.status === "needs_setup" && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => setConfigureState(builtInState)}
|
||||
>
|
||||
Set up
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
return (
|
||||
<EntityRow
|
||||
key={agent.id}
|
||||
|
|
@ -304,13 +391,27 @@ export function Agents() {
|
|||
) : (
|
||||
<AgentStatusCapsule status={agent.status} />
|
||||
)}
|
||||
secondaryRow={
|
||||
builtInCluster ? (
|
||||
<div className="xl:hidden flex flex-wrap items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
meta={
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns
|
||||
agent={agent}
|
||||
environment={resolveRenderedEnvironment(agent.id)}
|
||||
showEnvironment={showEnvironmentColumn}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{builtInCluster && (
|
||||
<div className="hidden xl:flex items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns
|
||||
agent={agent}
|
||||
environment={resolveRenderedEnvironment(agent.id)}
|
||||
showEnvironment={showEnvironmentColumn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
metaSpacerClassName="hidden xl:block"
|
||||
|
|
@ -385,12 +486,7 @@ export function Agents() {
|
|||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
]}
|
||||
items={visibleTabItems}
|
||||
value={tab}
|
||||
onValueChange={(v) => navigate(`/agents/${v}`)}
|
||||
/>
|
||||
|
|
@ -476,6 +572,8 @@ export function Agents() {
|
|||
tab={tab}
|
||||
memberships={membershipsQuery.data}
|
||||
membershipMutation={membershipMutation}
|
||||
builtInByAgentId={builtInByAgentId}
|
||||
onConfigureBuiltIn={setConfigureState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -492,6 +590,18 @@ export function Agents() {
|
|||
No organizational hierarchy defined.
|
||||
</p>
|
||||
)}
|
||||
{configureState && selectedCompanyId && (
|
||||
<Suspense fallback={null}>
|
||||
<ConfigureBuiltInAgentModal
|
||||
companyId={selectedCompanyId}
|
||||
state={configureState}
|
||||
open={configureState !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfigureState(null);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -507,6 +617,8 @@ function OrgTreeNode({
|
|||
tab,
|
||||
memberships,
|
||||
membershipMutation,
|
||||
builtInByAgentId,
|
||||
onConfigureBuiltIn,
|
||||
}: {
|
||||
node: OrgNode;
|
||||
depth: number;
|
||||
|
|
@ -518,8 +630,11 @@ function OrgTreeNode({
|
|||
tab: FilterTab;
|
||||
memberships: ReturnType<typeof useResourceMemberships>["data"];
|
||||
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
|
||||
builtInByAgentId: Map<string, BuiltInAgentState>;
|
||||
onConfigureBuiltIn: (state: BuiltInAgentState) => void;
|
||||
}) {
|
||||
const agent = agentMap.get(node.id);
|
||||
const builtInState = builtInByAgentId.get(node.id);
|
||||
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
|
||||
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
||||
const pending = membershipMutation.isPending &&
|
||||
|
|
@ -544,14 +659,35 @@ function OrgTreeNode({
|
|||
) : (
|
||||
<AgentStatusCapsule status={node.status} />
|
||||
)}
|
||||
{/* min-w-0 + truncate so deep indentation on narrow screens shortens
|
||||
the name with an ellipsis instead of overflowing the row. */}
|
||||
<div className="flex-1 min-w-0 truncate">
|
||||
<span className="text-sm font-medium">{node.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{roleLabels[node.role] ?? node.role}
|
||||
{agent?.title ? ` - ${agent.title}` : ""}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-2">
|
||||
{/* Name floor + `truncate` keeps the primary identifier readable; the
|
||||
cluster wraps to a second line under pressure instead of starving
|
||||
the name at narrow widths. */}
|
||||
<div className="min-w-(--sz-7rem) truncate">
|
||||
<span className="text-sm font-medium">{node.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{roleLabels[node.role] ?? node.role}
|
||||
{agent?.title ? ` - ${agent.title}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{builtInState && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<BuiltInAgentBadge />
|
||||
<BuiltInLifecycleChip status={builtInState.status} />
|
||||
{builtInState.status === "needs_setup" && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Button size="xs" variant="outline" onClick={() => onConfigureBuiltIn(builtInState)}>
|
||||
Set up
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="sm:hidden">
|
||||
|
|
@ -639,6 +775,8 @@ function OrgTreeNode({
|
|||
tab={tab}
|
||||
memberships={memberships}
|
||||
membershipMutation={membershipMutation}
|
||||
builtInByAgentId={builtInByAgentId}
|
||||
onConfigureBuiltIn={onConfigureBuiltIn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -423,6 +425,7 @@ export function DesignGuide() {
|
|||
"StatusBadge", "StatusIcon", "PriorityIcon", "EntityRow", "EmptyState", "MetricCard",
|
||||
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
|
||||
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
|
||||
"InlineBanner", "BuiltInAgentGate", "BuiltInAgentBadge",
|
||||
].map((name) => (
|
||||
<Badge key={name} variant="ghost" className="font-mono text-(length:--text-nano)">
|
||||
{name}
|
||||
|
|
@ -1815,6 +1818,72 @@ export function DesignGuide() {
|
|||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* INLINE BANNER + BUILT-IN AGENTS */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Inline Banner">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Token-backed full-width notice (<span className="font-mono">brandBanner</span> tones). Use{" "}
|
||||
<span className="font-mono">info</span> for provenance/context and{" "}
|
||||
<span className="font-mono">warning</span> for paused/attention. Supports an optional bold
|
||||
title and a trailing actions slot. Replaces hand-rolled{" "}
|
||||
<span className="font-mono">bg-yellow-*</span>/<span className="font-mono">bg-blue-*</span>{" "}
|
||||
banners.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<InlineBanner
|
||||
tone="info"
|
||||
title="Built-in agent"
|
||||
actions={<Button variant="outline" size="sm">Reset to defaults</Button>}
|
||||
>
|
||||
Ships with Paperclip and powers <strong>Briefs</strong>. It can be paused but not deleted.
|
||||
</InlineBanner>
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
title="Briefs is paused."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm">View agent</Button>
|
||||
<Button size="sm">Resume agent</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Its built-in agent was paused 2 days ago, so new briefs aren't being generated.
|
||||
</InlineBanner>
|
||||
<InlineBanner tone="info" compact>
|
||||
Compact variant for embedding inside dialogs and modals.
|
||||
</InlineBanner>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Built-in Agent Badges">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Provenance badge (constant, blue) plus a derived lifecycle chip (amber) for attention
|
||||
states. The lifecycle chip is separate from the agent status vocabulary and only shows for{" "}
|
||||
<span className="font-mono">needs_setup</span> / <span className="font-mono">pending_approval</span>.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BuiltInAgentBadge />
|
||||
<BuiltInLifecycleChip status="needs_setup" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BuiltInAgentBadge />
|
||||
<BuiltInLifecycleChip status="pending_approval" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BuiltInAgentBadge compact />
|
||||
<BuiltInLifecycleChip status="needs_setup" compact />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
<span className="font-mono"><BuiltInAgentGate agentKey></span> composes{" "}
|
||||
<span className="font-mono">PageSkeleton</span> + <span className="font-mono">EmptyState</span>{" "}
|
||||
+ <span className="font-mono">InlineBanner</span> to render the loading / setup /
|
||||
pending-approval / paused / ready states of a feature that depends on a built-in agent.
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle goals sidebar link experimental setting"]';
|
||||
const SERVER_INFO_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle server info debug view experimental setting"]';
|
||||
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle built-in agents experimental setting"]';
|
||||
|
||||
function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
return {
|
||||
|
|
@ -59,6 +61,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
|
|
@ -274,6 +277,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders and patches the Built-in Agents experimental toggle", async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Built-in Agents");
|
||||
expect(container.textContent).toContain("Show Paperclip-managed built-in agent surfaces");
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(BUILT_IN_AGENTS_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
||||
enableBuiltInAgents: true,
|
||||
});
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders and patches the Server Info Debug View experimental toggle", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export function InstanceExperimentalSettings() {
|
|||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
|
||||
queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
]);
|
||||
},
|
||||
|
|
@ -246,6 +247,7 @@ export function InstanceExperimentalSettings() {
|
|||
const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
|
||||
const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true;
|
||||
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
|
||||
const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
|
|
@ -362,6 +364,24 @@ export function InstanceExperimentalSettings() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Built-in Agents</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Show Paperclip-managed built-in agent surfaces, including built-in roster badges, the Built-in agents
|
||||
tab, and built-in agent setup controls.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableBuiltInAgents}
|
||||
onCheckedChange={() => toggleMutation.mutate({ enableBuiltInAgents: !enableBuiltInAgents })}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle built-in agents experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Routines, buildRoutineGroups, sortRoutines } from "./Routines";
|
||||
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
|
||||
|
||||
let currentSearch = "";
|
||||
|
||||
|
|
@ -376,6 +376,28 @@ describe("Routines page", () => {
|
|||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
||||
});
|
||||
|
||||
it("keeps built-in routines in their own section after configured groups", () => {
|
||||
const groups = buildRoutineSections(
|
||||
[
|
||||
createRoutine({
|
||||
id: "routine-1",
|
||||
title: "Reflection review",
|
||||
projectId: "project-1",
|
||||
originKind: "built_in_agent_bundle",
|
||||
originId: "reflection-coach:recent-agent-reflection",
|
||||
}),
|
||||
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
||||
],
|
||||
"project",
|
||||
new Map([["project-1", { name: "Project Alpha" }]]),
|
||||
new Map([["agent-1", { name: "Agent One" }]]),
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Built-in routines"]);
|
||||
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]);
|
||||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
|
||||
});
|
||||
|
||||
it("sorts routines by selected field and direction without mutating the source list", () => {
|
||||
const routines = [
|
||||
createRoutine({
|
||||
|
|
@ -508,6 +530,55 @@ describe("Routines page", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("renders built-in routines in a dedicated section on the routines tab", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({
|
||||
id: "routine-1",
|
||||
title: "Morning sync",
|
||||
projectId: "project-1",
|
||||
}),
|
||||
createRoutine({
|
||||
id: "routine-2",
|
||||
title: "Reflection review",
|
||||
projectId: null,
|
||||
originKind: "built_in_agent_bundle",
|
||||
originId: "reflection-coach:recent-agent-reflection",
|
||||
}),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Built-in routines"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Built-in routines"));
|
||||
expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review"));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides archived routines from the routines list", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ type RoutineGroup = {
|
|||
items: RoutineListItem[];
|
||||
};
|
||||
|
||||
const builtInRoutineGroupKey = "__built_in_routines";
|
||||
|
||||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "title",
|
||||
sortDir: "asc",
|
||||
|
|
@ -170,6 +172,38 @@ export function buildRoutineGroups(
|
|||
}));
|
||||
}
|
||||
|
||||
export function isBuiltInRoutine(routine: Pick<RoutineListItem, "originKind">) {
|
||||
return routine.originKind === "built_in_agent_bundle";
|
||||
}
|
||||
|
||||
export function buildRoutineSections(
|
||||
routines: RoutineListItem[],
|
||||
groupByValue: RoutineGroupBy,
|
||||
projectById: Map<string, { name: string }>,
|
||||
agentById: Map<string, { name: string }>,
|
||||
): RoutineGroup[] {
|
||||
const builtInRoutines = routines.filter(isBuiltInRoutine);
|
||||
const customRoutines = routines.filter((routine) => !isBuiltInRoutine(routine));
|
||||
const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById)
|
||||
.filter((group) => group.items.length > 0)
|
||||
.map((group) => (
|
||||
builtInRoutines.length > 0 && groupByValue === "none" && group.key === "__all"
|
||||
? { ...group, label: "Custom routines" }
|
||||
: group
|
||||
));
|
||||
|
||||
if (builtInRoutines.length === 0) return customGroups;
|
||||
|
||||
return [
|
||||
...customGroups,
|
||||
{
|
||||
key: builtInRoutineGroupKey,
|
||||
label: "Built-in routines",
|
||||
items: builtInRoutines,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function sortRoutines(
|
||||
routines: RoutineListItem[],
|
||||
sortField: RoutineSortField,
|
||||
|
|
@ -199,6 +233,34 @@ function buildRoutinesTabHref(tab: RoutinesTab) {
|
|||
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
||||
}
|
||||
|
||||
function RoutineSectionHeader({
|
||||
label,
|
||||
count,
|
||||
isOpen,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
|
||||
isOpen ? " mb-1" : ""
|
||||
}`}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Routines() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
@ -425,8 +487,8 @@ export function Routines() {
|
|||
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
|
||||
);
|
||||
const routineGroups = useMemo(
|
||||
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
const routineSections = useMemo(
|
||||
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
|
||||
);
|
||||
const recentRunsIssueLinkState = useMemo(
|
||||
|
|
@ -886,7 +948,7 @@ export function Routines() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{routineGroups.map((group) => {
|
||||
{routineSections.map((group) => {
|
||||
const isOpen = !routineViewState.collapsedGroups.includes(group.key);
|
||||
return (
|
||||
<Collapsible
|
||||
|
|
@ -901,21 +963,11 @@ export function Routines() {
|
|||
}}
|
||||
>
|
||||
{group.label ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
|
||||
isOpen ? " mb-1" : ""
|
||||
}`}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<RoutineSectionHeader
|
||||
label={group.label}
|
||||
count={group.items.length}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((routine) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { AgentStatusBadge } from "@/components/StatusBadge";
|
||||
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
|
||||
import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal";
|
||||
import { BuiltInBundlePanel } from "@/components/BuiltInBundlePanel";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { BuiltInAgentState, BuiltInManagedResourceState } from "@/api/builtInAgents";
|
||||
import { Bot, Clock3 } from "lucide-react";
|
||||
|
||||
const briefsAgent: Agent = {
|
||||
id: "agent-briefs",
|
||||
companyId: "company-storybook",
|
||||
name: "Briefs Agent",
|
||||
urlKey: "briefs-agent",
|
||||
role: "general",
|
||||
title: null,
|
||||
icon: "sparkles",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: "Prepares concise operational briefs for the board and agent company.",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5" },
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: { paperclipBuiltInAgent: { key: "briefs", featureKeys: ["briefs"] } },
|
||||
createdAt: new Date("2026-06-01T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-01T09:00:00.000Z"),
|
||||
};
|
||||
|
||||
const definition = {
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["briefs"],
|
||||
shortPurpose: "Prepares concise operational briefs for the board and agent company.",
|
||||
defaultInstructions: "You are Paperclip's built-in Briefs agent.",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
};
|
||||
|
||||
const notProvisionedState: BuiltInAgentState = {
|
||||
definition,
|
||||
status: "not_provisioned",
|
||||
agentId: null,
|
||||
agent: null,
|
||||
pauseReason: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors Agents.tsx renderAgentRow: the built-in cluster sits inline in `meta`
|
||||
* at xl and drops to a full-width `secondaryRow` beneath the name below xl, with
|
||||
* `titlePriority` giving the name a floor so it never collapses (PAP-12988).
|
||||
*/
|
||||
function RosterRow({
|
||||
name,
|
||||
lifecycle,
|
||||
status,
|
||||
}: {
|
||||
name: string;
|
||||
lifecycle?: "needs_setup" | "pending_approval";
|
||||
status: string;
|
||||
}) {
|
||||
const cluster = (
|
||||
<>
|
||||
<BuiltInAgentBadge />
|
||||
{lifecycle && <BuiltInLifecycleChip status={lifecycle} />}
|
||||
{lifecycle === "needs_setup" && (
|
||||
<Button size="xs" variant="outline">Set up</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<EntityRow
|
||||
title={name}
|
||||
titleClassName="w-56"
|
||||
titlePriority
|
||||
subtitle="General"
|
||||
secondaryRow={<div className="xl:hidden flex flex-wrap items-center gap-1.5">{cluster}</div>}
|
||||
meta={<div className="hidden xl:flex items-center gap-1.5">{cluster}</div>}
|
||||
trailing={<AgentStatusBadge status={status} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Product/Built-in Agents",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
/** Boards 1, 2, 4, 5 — all presentational states in one gallery. */
|
||||
export const SurfaceGallery: Story = {
|
||||
render: () => (
|
||||
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 1 — Roster rows</SectionLabel>
|
||||
<div className="rounded-lg border border-border divide-y divide-border">
|
||||
<RosterRow name="Briefs Agent" lifecycle="needs_setup" status="idle" />
|
||||
<RosterRow name="Learning Agent" status="active" />
|
||||
<RosterRow name="Briefs Agent" lifecycle="pending_approval" status="idle" />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Resize below the <code>xl</code> breakpoint to see the badge/action
|
||||
cluster drop to a second line so the agent name never collapses
|
||||
(PAP-12988).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 2 — Agent detail provenance banner</SectionLabel>
|
||||
<InlineBanner
|
||||
tone="info"
|
||||
title="Built-in agent"
|
||||
actions={<Button variant="outline" size="sm">Reset to defaults</Button>}
|
||||
>
|
||||
Ships with Paperclip and powers <strong>Briefs</strong>. Configure it like any agent —
|
||||
model, instructions, budget. It can be paused but not deleted; pausing it pauses Briefs.
|
||||
</InlineBanner>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 4A — Feature gate: setup empty-state</SectionLabel>
|
||||
<div className="rounded-lg border border-border">
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
title="Set up the Briefs Agent"
|
||||
message="Briefs is generated by a built-in agent. Configure its model to enable the feature."
|
||||
action="Set up Briefs Agent"
|
||||
onAction={() => {}}
|
||||
hideActionIcon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 4 — Feature gate: pending approval</SectionLabel>
|
||||
<div className="rounded-lg border border-border">
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
title="Briefs Agent is pending approval"
|
||||
message="Briefs will be available after the board approves this built-in agent hire."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 4B — Feature gate: paused banner over stale content</SectionLabel>
|
||||
<div className="space-y-4">
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
title="Briefs is paused."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm">View agent</Button>
|
||||
<Button size="sm">Resume agent</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Its built-in agent was paused 2 days ago, so new briefs aren't being generated.
|
||||
</InlineBanner>
|
||||
<div className="opacity-70 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Previously generated briefs stay readable while the agent is paused.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 5 — Sidebar treatment</SectionLabel>
|
||||
<div className="w-64 rounded-lg border border-border p-2 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
|
||||
<span className="min-w-0 truncate">Briefs Agent</span>
|
||||
<span className="ml-1 flex items-center gap-1">
|
||||
<BuiltInAgentBadge compact />
|
||||
<BuiltInLifecycleChip status="needs_setup" compact />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
|
||||
<span className="min-w-0 truncate">Learning Agent</span>
|
||||
<span className="ml-1 flex items-center gap-1">
|
||||
<BuiltInAgentBadge compact />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionLabel>Board 5 — Use-while-paused toast</SectionLabel>
|
||||
<div className="w-80 rounded-lg border border-[#F59E0B]/50 bg-[#FEF3C7]/60 p-3 text-sm text-[#B45309] dark:bg-[#f59e0b12] dark:text-[#F59E0B]">
|
||||
<p className="font-medium">Briefs Agent is paused</p>
|
||||
<p className="opacity-90">Resume the agent to generate this brief.</p>
|
||||
<a href="#" className="mt-1 inline-block text-xs font-medium underline">View agent</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Board 3 — configure-on-first-use modal (open). */
|
||||
export const ConfigureModal: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Button onClick={() => setOpen(true)}>Open configure modal</Button>
|
||||
<ConfigureBuiltInAgentModal
|
||||
companyId="company-storybook"
|
||||
state={notProvisionedState}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Board 2 — pause confirmation dialog with dependency warning. */
|
||||
export const PauseConfirmDialog: Story = {
|
||||
render: () => (
|
||||
<div className="p-6">
|
||||
<AlertDialog open>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Pause the Briefs Agent?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
Briefs depends on this agent. While paused, briefs generation is skipped and the
|
||||
Briefs page shows a warning.
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction>Pause anyway</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reflection Coach bundle status panel (PAP-13099).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const reflectionBundle = {
|
||||
stockVersion: "2026-07-08",
|
||||
instructions: { entryFile: "AGENTS.md", files: ["AGENTS.md"] },
|
||||
skill: {
|
||||
skillKey: "reflection-coach",
|
||||
displayName: "reflection-coach",
|
||||
slug: "reflection-coach",
|
||||
canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach",
|
||||
files: ["reflection-coach/SKILL.md"],
|
||||
},
|
||||
routine: {
|
||||
routineKey: "recent-agent-reflection",
|
||||
title: "Recent agent reflection",
|
||||
status: "paused" as const,
|
||||
triggerCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const reflectionDefinition = {
|
||||
key: "reflection-coach",
|
||||
displayName: "Reflection Coach",
|
||||
featureKeys: ["reflection"],
|
||||
shortPurpose: "Reviews recent agents and coaches them.",
|
||||
defaultInstructions: "You are Paperclip's built-in Reflection Coach.",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local", "claude_local"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
bundle: reflectionBundle,
|
||||
};
|
||||
|
||||
function bundleResource(
|
||||
resourceKind: BuiltInManagedResourceState["resourceKind"],
|
||||
stockStatus: BuiltInManagedResourceState["stockStatus"],
|
||||
): BuiltInManagedResourceState {
|
||||
return {
|
||||
resourceKind,
|
||||
resourceKey:
|
||||
resourceKind === "skill"
|
||||
? "reflection-coach"
|
||||
: resourceKind === "routine"
|
||||
? "recent-agent-reflection"
|
||||
: "AGENTS.md",
|
||||
resourceId: "res-1",
|
||||
stockVersion: "2026-07-08",
|
||||
stockHash: "aaaa",
|
||||
currentHash: stockStatus === "missing" ? null : stockStatus === "stock_current" ? "aaaa" : "bbbb",
|
||||
stockStatus,
|
||||
updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified",
|
||||
resetAvailable: stockStatus !== "stock_current",
|
||||
};
|
||||
}
|
||||
|
||||
function bundleState(
|
||||
status: BuiltInAgentState["status"],
|
||||
resources: BuiltInManagedResourceState[],
|
||||
): BuiltInAgentState {
|
||||
return {
|
||||
definition: reflectionDefinition,
|
||||
status,
|
||||
agentId: "agent-reflection",
|
||||
agent: null,
|
||||
pauseReason: null,
|
||||
resources,
|
||||
};
|
||||
}
|
||||
|
||||
const READY = [
|
||||
bundleResource("skill", "stock_current"),
|
||||
bundleResource("instructions", "stock_current"),
|
||||
bundleResource("routine", "stock_current"),
|
||||
];
|
||||
|
||||
function BundleCase({ title, state }: { title: string; state: BuiltInAgentState }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-(length:--text-micro) font-medium text-muted-foreground">{title}</p>
|
||||
<BuiltInBundlePanel
|
||||
state={state}
|
||||
agentRef="reflectioncoach"
|
||||
onConfigure={() => {}}
|
||||
onResetResource={() => {}}
|
||||
onRunRoutine={() => {}}
|
||||
onEnableSchedule={() => {}}
|
||||
onDisableSchedule={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Board — Reflection Coach bundle status panel across the ux-spec states
|
||||
* (§5a needs-adapter, §5b all-ready, §5c update available, §5d drifted,
|
||||
* §5f missing). Light + dark are captured by the screenshot recipe.
|
||||
*/
|
||||
export const BundleStatusPanel: Story = {
|
||||
render: () => (
|
||||
<div className="mx-auto grid max-w-3xl gap-8 p-6">
|
||||
<BundleCase title="§5a — needs adapter (nothing runs yet)" state={bundleState("needs_setup", READY)} />
|
||||
<BundleCase title="§5b — all ready, schedule off (healthy default)" state={bundleState("ready", READY)} />
|
||||
<BundleCase
|
||||
title="§5c — update available (unedited stock, newer default shipped)"
|
||||
state={bundleState("ready", [
|
||||
bundleResource("skill", "stock_current"),
|
||||
bundleResource("instructions", "stock_update_available"),
|
||||
bundleResource("routine", "stock_current"),
|
||||
])}
|
||||
/>
|
||||
<BundleCase
|
||||
title="§5d — drifted (operator-modified, edits preserved)"
|
||||
state={bundleState("ready", [
|
||||
bundleResource("skill", "operator_modified"),
|
||||
bundleResource("instructions", "stock_current"),
|
||||
bundleResource("routine", "stock_current"),
|
||||
])}
|
||||
/>
|
||||
<BundleCase
|
||||
title="§5f — missing resource (reconcile recreates it)"
|
||||
state={bundleState("ready", [
|
||||
bundleResource("skill", "missing"),
|
||||
bundleResource("instructions", "stock_current"),
|
||||
bundleResource("routine", "stock_current"),
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
void briefsAgent;
|
||||
Loading…
Reference in New Issue