mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Merge master agent configuration editor and cancellation recovery
Preserve both the workspace preference and agent configuration exports. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
618c5a2a3f
43 files changed
+2596
-48
No files matched your search
@@ -49,6 +49,12 @@ start execution. This preference does not change the org chart or permissions.
|
|||||||
|
|
||||||
### Agent Execution
|
### Agent Execution
|
||||||
|
|
||||||
|
Agents have an **Advanced** tab for editing their settings across all configuration tabs
|
||||||
|
as a versioned YAML document. Instructions, skills, tools, channels, trust,
|
||||||
|
secret operations, API keys, and revision restore use their existing domain APIs.
|
||||||
|
It edits the same configuration as the regular settings pages, validates before saving, preserves masked secrets, and rejects
|
||||||
|
stale edits. See [Agent configuration YAML](agent-configuration-yaml.md).
|
||||||
|
|
||||||
Existing Claude, Codex, and Grok Build agents can upgrade from the legacy runner on their
|
Existing Claude, Codex, and Grok Build agents can upgrade from the legacy runner on their
|
||||||
Harness / Runtime page when Paperclip Runner is the qualified default for their
|
Harness / Runtime page when Paperclip Runner is the qualified default for their
|
||||||
environment. One action saves pending edits and translates compatible settings
|
environment. One action saves pending edits and translates compatible settings
|
||||||
|
|||||||
@@ -324,6 +324,15 @@ Invariants:
|
|||||||
- no cycles in reporting tree
|
- no cycles in reporting tree
|
||||||
- `terminated` agents cannot be resumed
|
- `terminated` agents cannot be resumed
|
||||||
|
|
||||||
|
Agent configuration also has a versioned YAML representation in the Advanced
|
||||||
|
tab, including instruction text, skills, tools, channels, permissions, secrets,
|
||||||
|
API key operations, and revision restore. Resource saves use their owning APIs
|
||||||
|
and expose partial failures; they are not a cross-resource transaction.
|
||||||
|
[Agent configuration YAML](agent-configuration-yaml.md) defines its schema,
|
||||||
|
replacement semantics, secret preservation, and revision preconditions. It uses
|
||||||
|
the existing configuration and resource APIs, retaining their authorization
|
||||||
|
and approval gates. Agent lifecycle commands remain separate actions.
|
||||||
|
|
||||||
## 7.3 `agent_api_keys`
|
## 7.3 `agent_api_keys`
|
||||||
|
|
||||||
- `id` uuid pk
|
- `id` uuid pk
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# Agent configuration YAML
|
||||||
|
|
||||||
|
Status: v1, October 28, 2026.
|
||||||
|
|
||||||
|
The **Advanced** tab at `/:companyPrefix/agents/:agentRef/advanced` edits an
|
||||||
|
agent's settings as one YAML document. It covers the configuration tabs through
|
||||||
|
their existing APIs. The database and resource services remain authoritative;
|
||||||
|
this is not a file on the agent's computer or a harness-specific format.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
| Settings surface | YAML location | Save behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Agent identity / profile | `spec.name`, `role`, `title`, `icon`, `appearance`, `reportsTo`, `capabilities`, `metadata` | Existing agent update, including avatar appearance and reporting relationships. |
|
||||||
|
| Harness / Runtime | `spec.adapterType`, `adapterConfig`, `runtimeConfig`, `defaultEnvironmentId`, `budgetMonthlyCents` | Existing adapter, environment, AI connection, heartbeat, run-policy and budget validation. Adapter-specific extensions are preserved. |
|
||||||
|
| Instructions | `spec.instructions` | Bundle mode, root, entry file, and editable text files. Managed files and entry revisions enforce concurrency preconditions; omitted text files are deleted. Legacy prompt templates remain in `adapterConfig`. |
|
||||||
|
| Skills | `spec.skills` | Desired skill keys and version pins, synchronized through the skills API. |
|
||||||
|
| Secrets & variables | `spec.adapterConfig.env`, `adapterConfig["access.<ALIAS>"]`, `spec.secrets` | Environment values, secret references, API-delivery bindings, creation, rotation, metadata/status updates, and proposal decisions. |
|
||||||
|
| Tools | `spec.tools` | Direct connection installs, profile bindings and definitions, and agent-scoped policies. Inherited access is visible under `observed`. |
|
||||||
|
| Channels | `spec.channels`, `spec.channelOperations` | Existing endpoint settings, communication instructions, execution defaults, resource selection, setup/credentials and lifecycle actions, creation, and identity revocation. |
|
||||||
|
| Permissions / Trust | `spec.permissions` | Agent creation, skill creation, task assignment, trust preset and full authorization policy, including allowed projects in `authorizationPolicy.trustBoundary.projectIds`. |
|
||||||
|
| API Keys | `spec.apiKeys` | Explicit create and revoke operations; key metadata is under `observed.apiKeys`. New tokens appear once outside the YAML. |
|
||||||
|
| Revisions | `spec.revisions.restore` | Restore an existing revision, as the only operation in a save; history is under `observed.revisions`. |
|
||||||
|
|
||||||
|
Read-only information stays read-only: organization-wide inherited access,
|
||||||
|
immutable history, binary instruction assets, live status/spend, and provider
|
||||||
|
connection state. Interactive provider login/OAuth and approval flows still use
|
||||||
|
their normal UI. The YAML can select existing accounts/connections and configure
|
||||||
|
channel credentials; it cannot synthesize provider consent or export stored
|
||||||
|
credential values. AgentMail provisioning remains owned by its connection flow.
|
||||||
|
|
||||||
|
## Document contract
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: paperclip/v1
|
||||||
|
kind: Agent
|
||||||
|
spec:
|
||||||
|
name: Codie
|
||||||
|
role: engineer
|
||||||
|
title: Software engineer
|
||||||
|
icon: null
|
||||||
|
appearance:
|
||||||
|
schemaVersion: 1
|
||||||
|
characterVersion: cap-v1
|
||||||
|
paletteId: bubblegum-sky
|
||||||
|
reportsTo: null
|
||||||
|
capabilities: |
|
||||||
|
Implement features, review code, and maintain tests.
|
||||||
|
adapterType: codex_local
|
||||||
|
adapterConfig:
|
||||||
|
model: gpt-5
|
||||||
|
cwd: /workspace/project
|
||||||
|
env:
|
||||||
|
SERVICE_TOKEN:
|
||||||
|
type: secret_ref
|
||||||
|
secretId: 11111111-1111-4111-8111-111111111111
|
||||||
|
version: latest
|
||||||
|
runtimeConfig:
|
||||||
|
heartbeat:
|
||||||
|
enabled: true
|
||||||
|
intervalSec: 3600
|
||||||
|
wakeOnDemand: true
|
||||||
|
defaultEnvironmentId: null
|
||||||
|
budgetMonthlyCents: 10000
|
||||||
|
metadata: null
|
||||||
|
permissions:
|
||||||
|
canCreateAgents: false
|
||||||
|
canCreateSkills: true
|
||||||
|
canAssignTasks: false
|
||||||
|
trustPreset: standard
|
||||||
|
instructions:
|
||||||
|
mode: managed
|
||||||
|
rootPath: /managed/agents/codie
|
||||||
|
entryFile: AGENTS.md
|
||||||
|
files:
|
||||||
|
AGENTS.md: |
|
||||||
|
# Codie
|
||||||
|
Read the task, make focused changes, and verify the result.
|
||||||
|
references/workflow.md: |
|
||||||
|
Run the relevant tests before handing work back.
|
||||||
|
skills:
|
||||||
|
- key: paperclip
|
||||||
|
versionId: null
|
||||||
|
tools:
|
||||||
|
connections: []
|
||||||
|
profiles: []
|
||||||
|
policies: []
|
||||||
|
channels: []
|
||||||
|
secrets:
|
||||||
|
create: []
|
||||||
|
rotate: []
|
||||||
|
update: []
|
||||||
|
proposals: []
|
||||||
|
channelOperations:
|
||||||
|
create: []
|
||||||
|
setup: []
|
||||||
|
revokeIdentityLinks: []
|
||||||
|
apiKeys:
|
||||||
|
create: []
|
||||||
|
revoke: []
|
||||||
|
revisions:
|
||||||
|
restore: null
|
||||||
|
observed: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
This illustrates the format; model, paths, and secret IDs depend on the
|
||||||
|
installation. Edit the document loaded for the agent. Do not replace its
|
||||||
|
`observed` block with the example's empty block.
|
||||||
|
|
||||||
|
`apiVersion`, `kind`, `spec`, and every core field from `name` through `metadata`
|
||||||
|
are required, including explicit `null` for unset nullable fields. Resource
|
||||||
|
sections (`permissions` onward) are optional: omitting an entire section
|
||||||
|
preserves it. The editor emits all
|
||||||
|
sections. Unknown fields at the document, spec, or structured resource level
|
||||||
|
are errors. Open adapter/runtime/metadata maps retain their existing extension
|
||||||
|
contracts. Future incompatible changes require a new API version.
|
||||||
|
|
||||||
|
Executable contracts:
|
||||||
|
[core document](../packages/shared/src/agent-configuration.ts) and
|
||||||
|
[resource sections](../packages/shared/src/agent-configuration-resources.ts).
|
||||||
|
Resource inputs reuse the owning APIs' validators, including trust-policy,
|
||||||
|
secret, channel, skill, and tool validators.
|
||||||
|
|
||||||
|
## Resource semantics
|
||||||
|
|
||||||
|
### Instructions and skills
|
||||||
|
|
||||||
|
`instructions.mode` is `managed`, `external`, or `null` for an unconfigured
|
||||||
|
bundle. `external` requires an absolute `rootPath`. File names and `entryFile`
|
||||||
|
are relative paths; traversal and absolute file names are rejected. Each file
|
||||||
|
value is its text content (up to 1 MiB). Adding a mapping creates a file;
|
||||||
|
changing one edits it; removing one deletes it. The entry file cannot be
|
||||||
|
removed. Binary/read-only/virtual files are described in
|
||||||
|
`observed.instructionFiles`. A legacy virtual prompt template is edited using
|
||||||
|
`adapterConfig.promptTemplate` rather than a second writable copy.
|
||||||
|
|
||||||
|
Changing the mode, root, or entry file is a separate save from editing file
|
||||||
|
contents. That save loads the destination files without writing the old YAML's
|
||||||
|
contents over them. The native bundle service may seed an empty destination or
|
||||||
|
a missing entry file from the previous bundle. Review the freshly loaded files
|
||||||
|
before editing them. A managed root is assigned by Paperclip; choose `external`
|
||||||
|
mode to select another directory.
|
||||||
|
|
||||||
|
Skills accept a key string or `{ key, versionId }`; `versionId: null` follows
|
||||||
|
the current version. An empty list unassigns skills through the normal sync
|
||||||
|
API. These keys are organization-library references, not embedded skill source.
|
||||||
|
|
||||||
|
The `instructionsBundleMode`, `instructionsRootPath`, `instructionsEntryFile`,
|
||||||
|
`instructionsFilePath`, and `paperclipSkillSync` adapter keys are represented by
|
||||||
|
the dedicated sections. Direct edits to those adapter keys are rejected so two
|
||||||
|
sections cannot disagree about the same setting.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
`tools.connections` contains `{ connectionId, installed }` for each available
|
||||||
|
connection. These are direct agent installations; inherited company
|
||||||
|
installations appear in `observed.connections`. Removing a direct installation
|
||||||
|
row has the same effect as `installed: false`. Saves preserve other agents'
|
||||||
|
installations. Company installations cannot be overridden here.
|
||||||
|
The installation service can add an access profile and binding for an installed
|
||||||
|
connection. The canonical reload includes those generated profiles.
|
||||||
|
|
||||||
|
Each profile contains:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profileId: null # null creates a profile; an existing ID binds/edits it
|
||||||
|
updateSharedDefinition: false
|
||||||
|
priority: 100
|
||||||
|
metadata: null
|
||||||
|
configuration:
|
||||||
|
profileKey: engineering
|
||||||
|
name: Engineering
|
||||||
|
description: null
|
||||||
|
status: active
|
||||||
|
defaultAction: deny
|
||||||
|
metadata: null
|
||||||
|
entries:
|
||||||
|
- selectorType: tool_name
|
||||||
|
effect: include
|
||||||
|
toolName: get_repository
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile configuration uses the native profile/entry schema. Profiles can
|
||||||
|
be shared: editing a profile's definition affects every binding to that profile.
|
||||||
|
When other targets share it, changes to the definition require explicitly
|
||||||
|
setting `updateSharedDefinition: true`. The flag resets after reload; changing
|
||||||
|
only the flag does not write anything. Binding an unchanged shared definition
|
||||||
|
does not require the flag. Use `profileId: null` for a separate profile.
|
||||||
|
Removing a profile row only unbinds it from this agent. Available profile
|
||||||
|
configurations are listed under `observed.availableProfiles`.
|
||||||
|
|
||||||
|
Keep existing profile and policy fields explicit. Removing a field from an
|
||||||
|
existing definition is rejected because the native PATCH would otherwise
|
||||||
|
preserve its old value. Use the field's supported empty value to clear it.
|
||||||
|
|
||||||
|
Policies use `{ id, configuration }`, with `id: null` creating a policy.
|
||||||
|
Configuration includes `name`, `description`, `policyType`, `priority`,
|
||||||
|
`enabled`, `selectors`, `conditions`, and `config`. Policies in this section
|
||||||
|
must target only this agent; newly created policies are scoped to it. Removing
|
||||||
|
a row deletes that agent-scoped policy. Shared/inherited policies are read-only
|
||||||
|
under `observed.inheritedPolicies`.
|
||||||
|
|
||||||
|
### Channels
|
||||||
|
|
||||||
|
Each `channels` row has immutable `id` and `provider`, plus `settings` and
|
||||||
|
`resources`. Settings follow the native endpoint update contract:
|
||||||
|
`executionDefaults`, `communicationInstructions`, `allowUnlinkedPeople`,
|
||||||
|
`allowDirectMessages`, `requireAtMention`, `allowGroupChats`, `slackApp`, and
|
||||||
|
`slackSetupMethod`, where applicable. Keep saved settings explicit; omission
|
||||||
|
cannot silently clear a PATCH field. Resources contain `{ id, enabled,
|
||||||
|
executionDefaults }`; removing a resource row disables it. Removing a channel
|
||||||
|
row removes the endpoint using its existing setup API.
|
||||||
|
|
||||||
|
`channelOperations.create` accepts the native creation fields (`provider`,
|
||||||
|
optional `applicationId`, `name`, `slackApp`, `slackSetupMethod`); the current
|
||||||
|
agent ID is supplied by the editor. Save creation first to obtain its endpoint
|
||||||
|
ID before adding setup commands. `setup` accepts `{ id, input }`, where input
|
||||||
|
has an `action` (`configure`, `verify`, `pause`, `resume`, `reconnect`, `remove`)
|
||||||
|
and optional `credentials` or Photon configuration. `revokeIdentityLinks`
|
||||||
|
accepts `{ endpointId, principalId }`. Provider authorization is still enforced
|
||||||
|
by the channel service. IDs, resources, linked people, and state are available
|
||||||
|
under `observed.channels`.
|
||||||
|
|
||||||
|
An endpoint cannot be removed and also edited, configured, or have its identities
|
||||||
|
revoked in the same save. Duplicate setup commands and identity revocations are
|
||||||
|
rejected before any writes.
|
||||||
|
|
||||||
|
### Secrets, API keys, and revisions
|
||||||
|
|
||||||
|
Use `secret_ref` and `user_secret_ref` bindings for existing credentials.
|
||||||
|
`secrets.create` accepts `{ secret, bind: { env: [], api: [] } }`; `secret` follows
|
||||||
|
the create-secret contract (name/key, provider, value or external reference,
|
||||||
|
provider configuration and metadata). After creation, `bind` can attach its ID
|
||||||
|
to environment names or API aliases. An empty bind creates without attaching.
|
||||||
|
`rotate` and `update` accept `{ id, input }` using their native contracts.
|
||||||
|
`proposals` accepts `{ id, decision: approve|reject, reason? }`; rejection
|
||||||
|
requires a reason. Secrets and pending proposals are listed without values
|
||||||
|
under `observed.secrets` and `observed.secretProposals`.
|
||||||
|
|
||||||
|
New secrets must use distinct binding targets. A target cannot also be edited
|
||||||
|
in `adapterConfig` in the same save. Binding targets are checked again before
|
||||||
|
creation and before the revision-guarded binding update. If creation succeeds
|
||||||
|
but binding fails, the receipt identifies the created secret; reload before
|
||||||
|
deciding how to bind it.
|
||||||
|
|
||||||
|
`apiKeys.create` accepts `{ name, scope }`; scope uses the existing agent key
|
||||||
|
schema, with `{ kind: standard }` as the default. `apiKeys.revoke` contains key
|
||||||
|
IDs. Tokens returned by creation appear outside the YAML and are not stored in
|
||||||
|
the configuration query cache. Copy them before leaving the page. Previously
|
||||||
|
issued tokens cannot be retrieved.
|
||||||
|
Duplicate creation names within one save are rejected.
|
||||||
|
|
||||||
|
`revisions.restore` accepts an existing revision ID or `null`. Restore must be
|
||||||
|
the only change in that save. It uses the same rollback operation as the
|
||||||
|
Revisions tab and restores only the fields covered by that revision; it is not
|
||||||
|
a rollback of all external resources.
|
||||||
|
|
||||||
|
Operation lists reset to empty after a successful save/reload. Deleting an
|
||||||
|
`observed` history row does not revoke a key or restore a revision: editing
|
||||||
|
`observed` is rejected. Lifecycle commands for the agent itself (pause, resume,
|
||||||
|
terminate) are actions outside configuration.
|
||||||
|
|
||||||
|
## YAML, secrets, and save guarantees
|
||||||
|
|
||||||
|
The parser accepts one YAML 1.2 mapping containing JSON-compatible values.
|
||||||
|
Duplicate keys, multiple documents, aliases, anchors, explicit tags, merge
|
||||||
|
keys, non-string mapping keys, non-finite numbers, and prototype-related keys
|
||||||
|
are rejected. Documents are limited to 8 MiB and 50 levels of nesting. Syntax
|
||||||
|
errors include a location; schema errors identify a field path. There is no
|
||||||
|
interpolation, shell evaluation, include directive, or parser file access.
|
||||||
|
Comments and formatting are not persisted; saving/reloading regenerates YAML.
|
||||||
|
Changing only comments or mapping order does not write resources.
|
||||||
|
The editor shows **No configuration changes** and keeps Save disabled when the
|
||||||
|
operation plan is empty, including when an optional section is omitted.
|
||||||
|
|
||||||
|
The initial core read checks configuration access before related resources are
|
||||||
|
loaded. Failed reads are shown as errors, never substituted with empty resource
|
||||||
|
lists. The editor validates document structure, known references, explicit
|
||||||
|
clears, and conflicting operations before enabling Save. Expand the change
|
||||||
|
summary to review the ordered operations, including removals and shared profile
|
||||||
|
edits. Domain-specific authorization, validation, and normalization still run
|
||||||
|
at each API write; not every server rejection can be predicted in the browser.
|
||||||
|
|
||||||
|
Saving refreshes the snapshot before the first mutation. A changed core revision or an
|
||||||
|
edited resource section that has changed elsewhere rejects the save. Concurrent
|
||||||
|
changes to untouched resource sections are preserved. These are preflight
|
||||||
|
checks, not a transaction spanning all resources.
|
||||||
|
|
||||||
|
Core changes use `PATCH /api/agents/:id` with
|
||||||
|
`expectedConfigurationRevision`, checked again under the row lock. Removing an
|
||||||
|
adapter key uses `replaceAdapterConfig: true`. The opaque revision uses a
|
||||||
|
domain-separated HMAC with the instance's private signing key. Credential edits
|
||||||
|
invalidate an open draft without exposing a public credential digest. Rotating
|
||||||
|
the signing key requires reloading open editors. Server processes for one
|
||||||
|
instance must share that key. File-based rotation takes effect on the next
|
||||||
|
revision calculation without a server restart. Managed instruction files and
|
||||||
|
entry revisions enforce hash/revision preconditions. External supporting file
|
||||||
|
writes/deletions do not enforce these preconditions. Other resources retain
|
||||||
|
their own API guarantees; changes between preflight and an unversioned resource
|
||||||
|
write are not atomically protected. Every mutation uses its existing company/actor authorization,
|
||||||
|
approval, validation, audit and revision behavior.
|
||||||
|
|
||||||
|
A multi-resource save runs sequentially and is **not atomic**. On an interrupted
|
||||||
|
save, the editor retains the draft, reports completed steps and the failing
|
||||||
|
step, preserves any credential receipts, and disables Save until **Reload
|
||||||
|
saved**. Reload reveals current state and clears pending operations so retrying
|
||||||
|
cannot blindly duplicate a successful creation. A network failure may mean the
|
||||||
|
last operation committed even when its response did not arrive. No writes are
|
||||||
|
automatically retried. Double-clicking Save starts one operation, the editor
|
||||||
|
and Reload are disabled during saving, and agent navigation is held until it
|
||||||
|
settles. Dirty drafts use the agent page's unsaved-navigation guard. Closing the
|
||||||
|
browser cannot cancel or roll back requests already sent.
|
||||||
|
|
||||||
|
A successful save reloads canonical state, clears command lists, and displays
|
||||||
|
any normalization warnings returned by the server. CEO agents and agents that
|
||||||
|
can create agents must explicitly retain task assignment authority.
|
||||||
|
|
||||||
|
Stored inline credentials use `***REDACTED***`. An unchanged marker at its
|
||||||
|
original location preserves its saved value. Removing the key removes that
|
||||||
|
setting; replacing the full value supplies a new value under normal validation.
|
||||||
|
For revision-guarded core saves, the server restores unchanged masked subtrees
|
||||||
|
before validation; moved/partially edited redactions are rejected. Secret
|
||||||
|
references are never resolved into values. New secret/channel credential inputs
|
||||||
|
are one-time commands and disappear after canonical reload. This document is
|
||||||
|
an editable settings view, not a portable credential backup.
|
||||||
|
|
||||||
|
No background polling, database migration, or new authorization bypass is
|
||||||
|
introduced by the editor.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run the browser acceptance suite against its disposable checkout instance:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/agent-configuration.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite checks resource saves and clears, one-time key receipts, secret
|
||||||
|
redaction, stale drafts, a lost creation response, instruction relocation, and
|
||||||
|
revision restore through real APIs. It enables and restores the Beta skills and
|
||||||
|
chat connector flags. Its local MCP fixture provides a connection target; it
|
||||||
|
does not qualify provider login or agent execution.
|
||||||
|
|
||||||
|
The lifecycle regressions can use a dedicated external PostgreSQL database when
|
||||||
|
embedded PostgreSQL is unavailable. Set
|
||||||
|
`PAPERCLIP_AGENT_LIFECYCLE_TEST_DATABASE_URL` to a database ending in `_test`
|
||||||
|
before running `server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts`.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Cancellation deadlock retry acceptance
|
||||||
|
|
||||||
|
This existing correctness slice (#17895) retries only the operator Stop database
|
||||||
|
transaction after PostgreSQL reports `40P01`, at most three times. Provider Stop,
|
||||||
|
workspace settlement, and activity publication remain after commit. This is not
|
||||||
|
a new polling or scheduling duty.
|
||||||
|
|
||||||
|
## Current parent and outstanding review
|
||||||
|
|
||||||
|
The previous published head is
|
||||||
|
`fffc8d0c46fbbeb3da4a23a36e638fefd3b8fe3d`, originally based on native progress
|
||||||
|
`08d07ead105dc89c2af9b62f4d90bff8cc93540c`. The slice is prepared by a normal
|
||||||
|
merge of verified native progress `6f365285f5b233a89acc714eadfead5ab145100e`.
|
||||||
|
All four original changed-file hunks retain their exact content and context.
|
||||||
|
Only the run-control blob index hashes differ because the parent also changed
|
||||||
|
that file. The automatic composition is clean and preserves those parent edits.
|
||||||
|
|
||||||
|
The prior Ponytail review said Ship with an actionable coverage request: existing
|
||||||
|
deadlock tests did not reach a correlated native failed run's claimed state before
|
||||||
|
rollback. The follow-up adds two real PostgreSQL cases in the existing run-control
|
||||||
|
suite, with no new production behavior:
|
||||||
|
|
||||||
|
1. Keep retry authority: claim the correlated Stop, fence the failed native run,
|
||||||
|
cancel its real board terminal handoff, and persist cancellation activity inside
|
||||||
|
the first transaction. Raise PostgreSQL `40P01` before commit. Retry succeeds;
|
||||||
|
exactly one cancellation activity is durable and exactly one live notice is
|
||||||
|
published. The correlation belongs to the committed attempt.
|
||||||
|
2. Revoke retry authority: after that aborted transaction rolls back, an independent
|
||||||
|
coordinator update retires retry eligibility. The second attempt returns 409,
|
||||||
|
preserves the failed run and uncancelled handoff, publishes no cancellation
|
||||||
|
activity, and performs no provider or workspace cleanup.
|
||||||
|
|
||||||
|
The existing actual two-connection lock-cycle regression and the bounded retry/
|
||||||
|
non-deadlock cases remain in the complete suite. The new tests exercise claims
|
||||||
|
and activity produced before a real PostgreSQL transaction abort; they do not
|
||||||
|
infer correctness from a mocked retry callback alone.
|
||||||
|
|
||||||
|
## Evidence and failure accounting
|
||||||
|
|
||||||
|
- Both new cases pass against the retained production reset logic.
|
||||||
|
- Removing only `publications.length = 0` makes the preserved-authority case fail:
|
||||||
|
two live cancellation notices appear for one committed activity. Production was
|
||||||
|
restored byte-for-byte to its integrated index before further validation.
|
||||||
|
`/tmp/sleep-aux-native-retry-mutation-proof.json` records that comparison.
|
||||||
|
- The negative-control harness initially expected an untruncated assertion line;
|
||||||
|
Vitest abbreviates that line. Its actual named failure and 1-versus-2 result were
|
||||||
|
inspected, and the matcher was corrected without rerunning or altering production.
|
||||||
|
- Typechecking caught an optional live-event payload in the new assertion. The
|
||||||
|
assertion now uses optional access. Preserve the original failed typecheck at
|
||||||
|
`/tmp/sleep-aux-current-parent-types.log`; the final typecheck passes at
|
||||||
|
`/tmp/sleep-aux-current-parent-types-final.log`.
|
||||||
|
- **228 distinct complete cases pass:** all 48 run-control cases, including the two
|
||||||
|
additions, and all 180 native-session runtime cases, including the existing
|
||||||
|
controlled timeout/late-append fixture. The two targeted probes are included in
|
||||||
|
48 and are not counted again.
|
||||||
|
- Server typechecking, build, and module-boundary checks pass. `server/tsconfig.json`
|
||||||
|
excludes `src/__tests__`, but includes service-local test files such as
|
||||||
|
`run-control.test.ts`; typechecking does not replace test execution. Earlier
|
||||||
|
statements about tests omitted by production typechecking must be read with
|
||||||
|
that path boundary.
|
||||||
|
|
||||||
|
The three PostgreSQL probes use separate new disposable databases on the owned
|
||||||
|
local cluster. Full-run results and exit receipts are in
|
||||||
|
`/tmp/sleep-aux-current-parent-{types-final,build,boundaries,control,runner}.log`.
|
||||||
|
The targeted positive and mutation logs are
|
||||||
|
`/tmp/sleep-aux-native-retry-regression.log` and
|
||||||
|
`/tmp/sleep-aux-native-retry-mutant-red.log`.
|
||||||
|
|
||||||
|
## Remaining gates and limits
|
||||||
|
|
||||||
|
This is local preparation. The dependency must merge, the normal merge onto its
|
||||||
|
actual landed master must preserve this tested patch and all master changes, and
|
||||||
|
fresh exact-head CI plus Greptile 5/5 must pass before protected merge. Prior
|
||||||
|
review scores and checks on older heads are not current passes. The previous
|
||||||
|
Snyk status was pending and is not called successful here. No shared OS resource
|
||||||
|
cleanup, schema change, whole-repository local matrix, live provider execution,
|
||||||
|
external alarm, physical suspension, or savings qualification is claimed.
|
||||||
@@ -108,6 +108,23 @@ qualification remains not run.
|
|||||||
|
|
||||||
## `instructions`
|
## `instructions`
|
||||||
|
|
||||||
|
The operator's Advanced YAML editor uses the existing configuration GET/PATCH
|
||||||
|
operations plus the owning instructions, skills, tools, channels, permissions,
|
||||||
|
secrets, API key, and revision APIs. It does not add aggregate write authority;
|
||||||
|
each request retains its own company and actor checks. Resource reads fail closed.
|
||||||
|
Observed inherited access/history is read-only. Multi-resource writes are
|
||||||
|
sequential, with preflight comparison and explicit partial-failure recovery;
|
||||||
|
Core configuration, managed files, and entry revisions enforce their existing
|
||||||
|
atomic revision/hash preconditions. External supporting files and other
|
||||||
|
unversioned resource writes retain a race window after preflight. Configuration reads include the complete redacted, revisioned fields
|
||||||
|
and `configurationRevision`; writes accept an optional
|
||||||
|
`expectedConfigurationRevision`, checked again under the write lock. A stale
|
||||||
|
write returns `409`. Revision-guarded writes preserve unchanged redacted values
|
||||||
|
at their original paths. Configuration read/configure authority, company scope,
|
||||||
|
instruction-path restrictions, environment/account checks, revision history, and
|
||||||
|
activity attribution still apply. YAML does not expose a permission-grant or
|
||||||
|
lifecycle mutation shortcut. See [the format](../../doc/agent-configuration-yaml.md).
|
||||||
|
|
||||||
Capability ID: `agents/instructions`.
|
Capability ID: `agents/instructions`.
|
||||||
|
|
||||||
Review: `baseline`.
|
Review: `baseline`.
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ resource allocation waits for approval.
|
|||||||
|
|
||||||
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
|
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
|
||||||
|
|
||||||
|
Open **Advanced** at `/agents/:agentId/advanced` for the complete agent
|
||||||
|
[YAML configuration](../../doc/agent-configuration-yaml.md). Edit a name, budget,
|
||||||
|
runtime setting, and adapter key; save, reload, and check the ordinary settings
|
||||||
|
and Revisions. Try malformed YAML, a missing/unknown field, a removed nested key,
|
||||||
|
and a concurrent settings edit. Errors must retain the draft; stale saves return
|
||||||
|
a conflict. Unchanged secret markers must preserve their stored values. Navigating
|
||||||
|
away with a draft must show the existing unsaved-change guard. **Reload saved**
|
||||||
|
discards the draft. Also edit instruction text, skill pins, direct tool installs,
|
||||||
|
channel settings/resources, permissions/trust/project boundaries, secret operations,
|
||||||
|
and API key operations. Verify each result in its owning tab. Revision restore is
|
||||||
|
an isolated save. Inherited access/history under `observed` is read-only. An
|
||||||
|
interrupted multi-resource save must report partial progress, preserve returned
|
||||||
|
one-time keys, and require reload before another save. Domain APIs retain their
|
||||||
|
existing authorization; a multi-resource save is not atomic.
|
||||||
|
|
||||||
For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator
|
For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator
|
||||||
with agent-management permission can select additional projects and explicitly
|
with agent-management permission can select additional projects and explicitly
|
||||||
save them. Existing projects and other containment restrictions remain intact.
|
save them. Existing projects and other containment restrictions remain intact.
|
||||||
|
|||||||
@@ -3499,10 +3499,6 @@ describe("executeNativeSession recovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stops and closes a timed-out consumer even when the caller requested a warm session", async () => {
|
it("stops and closes a timed-out consumer even when the caller requested a warm session", async () => {
|
||||||
let markAppendStarted = () => {};
|
|
||||||
const appendStarted = new Promise<void>((resolve) => {
|
|
||||||
markAppendStarted = resolve;
|
|
||||||
});
|
|
||||||
let releaseAppend = () => {};
|
let releaseAppend = () => {};
|
||||||
const appendReleased = new Promise<void>((resolve) => {
|
const appendReleased = new Promise<void>((resolve) => {
|
||||||
releaseAppend = resolve;
|
releaseAppend = resolve;
|
||||||
@@ -3515,7 +3511,6 @@ describe("executeNativeSession recovery", () => {
|
|||||||
let appendCommitted = false;
|
let appendCommitted = false;
|
||||||
const appendEvent = vi.fn(
|
const appendEvent = vi.fn(
|
||||||
async (_event: PrpEvent, options?: { signal: AbortSignal }) => {
|
async (_event: PrpEvent, options?: { signal: AbortSignal }) => {
|
||||||
markAppendStarted();
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
appendReleased,
|
appendReleased,
|
||||||
new Promise<never>((_resolve, reject) => {
|
new Promise<never>((_resolve, reject) => {
|
||||||
@@ -3612,28 +3607,39 @@ describe("executeNativeSession recovery", () => {
|
|||||||
async completeRun() {},
|
async completeRun() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const execution = executeNativeSession({
|
// Reach the pending append before expiring the consumer. A real 1 ms
|
||||||
input,
|
// deadline can expire during setup before the consumer reaches this case.
|
||||||
backend,
|
vi.useFakeTimers();
|
||||||
controlPlane: port,
|
try {
|
||||||
runnerInstanceId: "runner-recovery",
|
const execution = executeNativeSession({
|
||||||
controlPlaneInstanceId: "control-recovery",
|
input,
|
||||||
timeoutMs: 1,
|
backend,
|
||||||
keepSessionOpen: true,
|
controlPlane: port,
|
||||||
});
|
runnerInstanceId: "runner-recovery",
|
||||||
const rejection = expect(execution).rejects.toThrow(
|
controlPlaneInstanceId: "control-recovery",
|
||||||
"native session timed out",
|
timeoutMs: 1,
|
||||||
);
|
keepSessionOpen: true,
|
||||||
await appendStarted;
|
});
|
||||||
await vi.waitFor(() => expect(iteratorTeardown).toHaveBeenCalledOnce());
|
const rejection = expect(execution).rejects.toThrow(
|
||||||
await rejection;
|
"native session timed out",
|
||||||
expect(cancel).toHaveBeenCalledOnce();
|
);
|
||||||
expect(close).toHaveBeenCalled();
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
expect(appendEvent).toHaveBeenCalledOnce();
|
expect(appendEvent).toHaveBeenCalledOnce();
|
||||||
expect(appendCommitted).toBe(false);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
releaseAppend();
|
await rejection;
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
expect(iteratorTeardown).toHaveBeenCalledOnce();
|
||||||
expect(appendCommitted).toBe(false);
|
expect(cancel).toHaveBeenCalledOnce();
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
expect(appendEvent).toHaveBeenCalledOnce();
|
||||||
|
expect(appendCommitted).toBe(false);
|
||||||
|
releaseAppend();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(appendCommitted).toBe(false);
|
||||||
|
} finally {
|
||||||
|
releaseAppend();
|
||||||
|
releaseTeardown();
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes a failed session while retaining an uncancellable event read", async () => {
|
it("closes a failed session while retaining an uncancellable event read", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { updateAgentPermissionsSchema, createAgentKeySchema } from "./validators/agent.js";
|
||||||
|
import { agentDesiredSkillSelectionSchema, agentDesiredSkillEntrySchema } from "./validators/adapter-skills.js";
|
||||||
|
import { createToolProfileWithEntriesSchema, createToolProfileEntryForProfileSchema, createToolPolicySchema } from "./validators/tool-access.js";
|
||||||
|
import { chatProviderSchema, updateChatEndpointSchema, replaceChatEndpointResourcesSchema, createChatEndpointSchema, configureChatEndpointSchema } from "./validators/chat-channels.js";
|
||||||
|
import { createSecretSchema, rotateSecretSchema, updateSecretSchema } from "./validators/secret.js";
|
||||||
|
|
||||||
|
const id = z.string().min(1);
|
||||||
|
const filePath = z.string().min(1).max(512).refine(value => !value.startsWith("/") && !value.includes("\\") && !value.split("/").some(part => part === ".." || part === "." || !part), "Use a relative file path without traversal.");
|
||||||
|
|
||||||
|
export const agentConfigurationResourceShape = {
|
||||||
|
permissions: updateAgentPermissionsSchema.extend({
|
||||||
|
canCreateSkills: z.boolean(), trustPreset: updateAgentPermissionsSchema.shape.trustPreset.unwrap(),
|
||||||
|
}).strict().optional(),
|
||||||
|
instructions: z.object({
|
||||||
|
mode: z.enum(["managed", "external"]).nullable(),
|
||||||
|
rootPath: z.string().nullable(),
|
||||||
|
entryFile: filePath,
|
||||||
|
files: z.record(filePath, z.string().max(1024 * 1024)),
|
||||||
|
}).strict().optional(),
|
||||||
|
skills: z.array(z.union([agentDesiredSkillSelectionSchema.options[0], agentDesiredSkillEntrySchema.strict()])).optional(),
|
||||||
|
tools: z.object({
|
||||||
|
connections: z.array(z.object({ connectionId: id, installed: z.boolean() }).strict()),
|
||||||
|
profiles: z.array(z.object({
|
||||||
|
profileId: id.nullable(), updateSharedDefinition: z.boolean().optional(), priority: z.number().int().min(0).max(10000),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||||
|
configuration: createToolProfileWithEntriesSchema.extend({
|
||||||
|
entries: z.array(createToolProfileEntryForProfileSchema.strict()).max(250).optional(),
|
||||||
|
}).strict(),
|
||||||
|
}).strict()),
|
||||||
|
policies: z.array(z.object({ id: id.nullable(), configuration: createToolPolicySchema.strict() }).strict()),
|
||||||
|
}).strict().optional(),
|
||||||
|
channels: z.array(z.object({
|
||||||
|
id, provider: chatProviderSchema,
|
||||||
|
settings: updateChatEndpointSchema,
|
||||||
|
resources: z.array(replaceChatEndpointResourcesSchema.shape.resources.element.strict()),
|
||||||
|
}).strict()).optional(),
|
||||||
|
apiKeys: z.object({ create: z.array(createAgentKeySchema.strict()), revoke: z.array(id) }).strict().optional(),
|
||||||
|
revisions: z.object({ restore: id.nullable() }).strict().optional(),
|
||||||
|
secrets: z.object({
|
||||||
|
create: z.array(z.object({
|
||||||
|
secret: createSecretSchema.strict(),
|
||||||
|
bind: z.object({ env: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)), api: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)) }).strict(),
|
||||||
|
}).strict()),
|
||||||
|
rotate: z.array(z.object({ id, input: rotateSecretSchema.strict() }).strict()),
|
||||||
|
update: z.array(z.object({ id, input: updateSecretSchema.strict() }).strict()),
|
||||||
|
proposals: z.array(z.object({ id, decision: z.enum(["approve", "reject"]), reason: z.string().optional() }).strict()),
|
||||||
|
}).strict().optional(),
|
||||||
|
channelOperations: z.object({
|
||||||
|
create: z.array(z.object(createChatEndpointSchema.shape).omit({ assignedAgentId: true }).strict().refine(
|
||||||
|
value => (!value.slackApp && !value.slackSetupMethod) || value.provider === "slack",
|
||||||
|
{ message: "Slack app details only apply to Slack connections", path: ["slackApp"] },
|
||||||
|
)),
|
||||||
|
setup: z.array(z.object({ id, input: configureChatEndpointSchema }).strict()),
|
||||||
|
revokeIdentityLinks: z.array(z.object({ endpointId: id, principalId: id }).strict()),
|
||||||
|
}).strict().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentConfigurationResources = z.infer<z.ZodObject<typeof agentConfigurationResourceShape>>;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { resolveAgentAppearance } from "./agent-appearance.js";
|
||||||
|
import type { Agent } from "./types/agent.js";
|
||||||
|
import { createAgentSchema } from "./validators/agent.js";
|
||||||
|
import { objectWithoutDefaults } from "./validators/partial.js";
|
||||||
|
import { agentConfigurationResourceShape } from "./agent-configuration-resources.js";
|
||||||
|
|
||||||
|
export const agentConfigurationSpecSchema = objectWithoutDefaults(createAgentSchema.pick({
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
title: true,
|
||||||
|
icon: true,
|
||||||
|
appearance: true,
|
||||||
|
reportsTo: true,
|
||||||
|
capabilities: true,
|
||||||
|
adapterType: true,
|
||||||
|
adapterConfig: true,
|
||||||
|
runtimeConfig: true,
|
||||||
|
defaultEnvironmentId: true,
|
||||||
|
budgetMonthlyCents: true,
|
||||||
|
metadata: true,
|
||||||
|
})).required().strict();
|
||||||
|
|
||||||
|
export const agentConfigurationDocumentSchema = z.object({
|
||||||
|
apiVersion: z.literal("paperclip/v1"),
|
||||||
|
kind: z.literal("Agent"),
|
||||||
|
spec: agentConfigurationSpecSchema.extend(agentConfigurationResourceShape),
|
||||||
|
observed: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export type AgentConfigurationSpec = z.infer<typeof agentConfigurationSpecSchema>;
|
||||||
|
export type AgentConfigurationDocument = z.infer<typeof agentConfigurationDocumentSchema>;
|
||||||
|
export type AgentConfigurationSource = Omit<Pick<Agent, "id" | keyof AgentConfigurationSpec>, "role" | "adapterType"> & {
|
||||||
|
role: string;
|
||||||
|
adapterType: string;
|
||||||
|
};
|
||||||
|
export type AgentConfigurationResponse = AgentConfigurationSource & { configurationRevision: string };
|
||||||
|
|
||||||
|
export function agentConfigurationSpec(agent: AgentConfigurationSource) {
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
role: agent.role,
|
||||||
|
title: agent.title,
|
||||||
|
icon: agent.icon,
|
||||||
|
appearance: resolveAgentAppearance(agent.appearance, agent.id),
|
||||||
|
reportsTo: agent.reportsTo,
|
||||||
|
capabilities: agent.capabilities,
|
||||||
|
adapterType: agent.adapterType,
|
||||||
|
adapterConfig: agent.adapterConfig,
|
||||||
|
runtimeConfig: agent.runtimeConfig,
|
||||||
|
defaultEnvironmentId: agent.defaultEnvironmentId ?? null,
|
||||||
|
budgetMonthlyCents: agent.budgetMonthlyCents,
|
||||||
|
metadata: agent.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalAgentConfiguration(value: unknown): string {
|
||||||
|
return JSON.stringify(value, (_key, item: unknown) => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
|
||||||
|
return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2861,5 +2861,5 @@ export * from "./prompt-injection-prevention.js";
|
|||||||
|
|
||||||
export * from "./workspace-provider.js";
|
export * from "./workspace-provider.js";
|
||||||
export * from "./workflows.js";
|
export * from "./workflows.js";
|
||||||
|
|
||||||
export { updateWorkspacePreferenceSchema, type UpdateWorkspacePreference, type WorkspacePreference } from "./workspace-preferences.js";
|
export { updateWorkspacePreferenceSchema, type UpdateWorkspacePreference, type WorkspacePreference } from "./workspace-preferences.js";
|
||||||
|
export * from "./agent-configuration.js";
|
||||||
@@ -174,6 +174,7 @@ export const updateAgentSchema = objectWithoutDefaults(
|
|||||||
permissions: z.never().optional(),
|
permissions: z.never().optional(),
|
||||||
runtimeSettings: agentRuntimeSettingsPatchSchema.optional(),
|
runtimeSettings: agentRuntimeSettingsPatchSchema.optional(),
|
||||||
replaceAdapterConfig: z.boolean().optional(),
|
replaceAdapterConfig: z.boolean().optional(),
|
||||||
|
expectedConfigurationRevision: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
||||||
status: z.enum(["paused", "idle", "terminated"]).optional(),
|
status: z.enum(["paused", "idle", "terminated"]).optional(),
|
||||||
spentMonthlyCents: z.never().optional(),
|
spentMonthlyCents: z.never().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -334,6 +334,53 @@ describe("agent routes adapter validation", () => {
|
|||||||
await unregisterTestAdapter(missingAdapterType);
|
await unregisterTestAdapter(missingAdapterType);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips the protected YAML projection and forwards its revision to the write lock", async () => {
|
||||||
|
const existing = {
|
||||||
|
...(await mockAgentService.getById()),
|
||||||
|
adapterConfig: { model: "old", removeMe: true, env: { DEMO_VALUE: "private-fixture" } },
|
||||||
|
metadata: { token: "private-metadata-fixture" },
|
||||||
|
};
|
||||||
|
mockAgentService.getById.mockResolvedValue(existing);
|
||||||
|
const app = await createApp();
|
||||||
|
const loaded = await requestApp(app, base => request(base).get(`/api/agents/${existing.id}/configuration`));
|
||||||
|
expect(loaded.status).toBe(200);
|
||||||
|
expect(loaded.body.configurationRevision).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(JSON.stringify(loaded.body)).not.toContain("private-fixture");
|
||||||
|
expect(JSON.stringify(loaded.body)).not.toContain("private-metadata-fixture");
|
||||||
|
const adapterConfig = { ...loaded.body.adapterConfig, model: "new" };
|
||||||
|
delete adapterConfig.removeMe;
|
||||||
|
const saved = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
|
||||||
|
adapterConfig, replaceAdapterConfig: true, metadata: { ...loaded.body.metadata, label: "new" },
|
||||||
|
expectedConfigurationRevision: loaded.body.configurationRevision,
|
||||||
|
}));
|
||||||
|
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
||||||
|
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
|
||||||
|
adapterConfig: expect.objectContaining({ model: "new", env: existing.adapterConfig.env }),
|
||||||
|
metadata: { ...existing.metadata, label: "new" },
|
||||||
|
}), expect.objectContaining({ expectedConfigurationRevision: loaded.body.configurationRevision }));
|
||||||
|
expect(mockAgentService.update.mock.calls[0][1].adapterConfig).not.toHaveProperty("removeMe");
|
||||||
|
expect(saved.body.configurationRevision).not.toBe(loaded.body.configurationRevision);
|
||||||
|
expect(JSON.stringify(saved.body)).not.toContain("private-fixture");
|
||||||
|
expect(JSON.stringify(saved.body)).not.toContain("private-metadata-fixture");
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale and unauthorized YAML saves before calling the update service", async () => {
|
||||||
|
const existing = await mockAgentService.getById();
|
||||||
|
const app = await createApp();
|
||||||
|
const loaded = await requestApp(app, base => request(base).get(`/api/agents/${existing.id}/configuration`));
|
||||||
|
const stale = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
|
||||||
|
title: "Stale", expectedConfigurationRevision: "0".repeat(64),
|
||||||
|
}));
|
||||||
|
expect(stale.status).toBe(409);
|
||||||
|
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_missing_grant", explanation: "No configuration permission" });
|
||||||
|
const denied = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
|
||||||
|
title: "Denied", expectedConfigurationRevision: loaded.body.configurationRevision,
|
||||||
|
}));
|
||||||
|
expect(denied.status).toBe(403);
|
||||||
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("routes narrow roster edits through authorization, config merging and audit history", async () => {
|
it("routes narrow roster edits through authorization, config merging and audit history", async () => {
|
||||||
const existing = { ...(await mockAgentService.getById()), adapterConfig: { model: "old", cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 } } };
|
const existing = { ...(await mockAgentService.getById()), adapterConfig: { model: "old", cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 } } };
|
||||||
mockAgentService.getById.mockResolvedValue(existing);
|
mockAgentService.getById.mockResolvedValue(existing);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveAgentAppearance } from "@paperclipai/shared";
|
||||||
|
import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js";
|
||||||
|
import { redactAgentAdapterConfig } from "../redaction.js";
|
||||||
|
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
|
||||||
|
|
||||||
|
const agent = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111", name: "Codie", role: "engineer" as const, title: null,
|
||||||
|
icon: null, appearance: null, reportsTo: null, capabilities: null, adapterType: "codex_local" as const,
|
||||||
|
adapterConfig: { model: "example", env: { API_KEY: "fake-secret" }, apiKey: "fake-adapter-secret" },
|
||||||
|
runtimeConfig: {}, defaultEnvironmentId: null, budgetMonthlyCents: 0, metadata: null, permissions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("agent configuration revisions and redactions", () => {
|
||||||
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
it("keys revisions with the private instance secret while detecting hidden credential edits", () => {
|
||||||
|
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "a".repeat(32));
|
||||||
|
const original = agentConfigurationRevision(agent);
|
||||||
|
expect(agentConfigurationRevision({ ...agent, adapterConfig: { ...agent.adapterConfig, apiKey: "changed-hidden-secret" } }))
|
||||||
|
.not.toBe(original);
|
||||||
|
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "b".repeat(32));
|
||||||
|
expect(agentConfigurationRevision(agent)).not.toBe(original);
|
||||||
|
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "a".repeat(32));
|
||||||
|
expect(agentConfigurationRevision(agent)).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a signing key rotated in place without restarting the process", () => {
|
||||||
|
const home = mkdtempSync(path.join(os.tmpdir(), "agent-yaml-signing-"));
|
||||||
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||||
|
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "");
|
||||||
|
try {
|
||||||
|
const original = agentConfigurationRevision(agent);
|
||||||
|
const keyPath = path.join(path.dirname(resolveDefaultSecretsKeyFilePath()), "decision-signing.key");
|
||||||
|
const previous = readFileSync(keyPath, "utf8");
|
||||||
|
writeFileSync(keyPath, "rotated-private-key".repeat(4), { mode: 0o600 });
|
||||||
|
expect(agentConfigurationRevision(agent)).not.toBe(original);
|
||||||
|
writeFileSync(keyPath, previous, { mode: 0o600 });
|
||||||
|
expect(agentConfigurationRevision(agent)).toBe(original);
|
||||||
|
} finally { rmSync(home, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes raw and presented rows and ignores live bookkeeping", () => {
|
||||||
|
expect(agentConfigurationRevision({ ...agent, appearance: resolveAgentAppearance(null, agent.id), permissions: { canCreateAgents: false, canCreateSkills: true } }))
|
||||||
|
.toBe(agentConfigurationRevision(agent));
|
||||||
|
const runtimeChange = { ...agent, status: "running", spentMonthlyCents: 100, updatedAt: new Date() };
|
||||||
|
expect(agentConfigurationRevision(runtimeChange)).toBe(agentConfigurationRevision(agent));
|
||||||
|
for (const patch of [{ name: "Renamed" }, { budgetMonthlyCents: 2 }, { adapterConfig: { ...agent.adapterConfig, model: "new" } }]) {
|
||||||
|
expect(agentConfigurationRevision({ ...agent, ...patch })).not.toBe(agentConfigurationRevision(agent));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves redacted secrets while changing another key and permits explicit removal", () => {
|
||||||
|
const displayed = redactAgentAdapterConfig(agent.adapterConfig);
|
||||||
|
expect(restoreAgentConfigurationRedactions({ ...displayed, model: "new" }, agent.adapterConfig, displayed))
|
||||||
|
.toEqual({ ...agent.adapterConfig, model: "new" });
|
||||||
|
expect(restoreAgentConfigurationRedactions({ model: "new" }, agent.adapterConfig, displayed)).toEqual({ model: "new" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects moved and partially edited redactions", () => {
|
||||||
|
expect(() => restoreAgentConfigurationRedactions({ other: "***REDACTED***" }, { apiKey: "secret" }, { apiKey: "***REDACTED***" })).toThrow("redacted value");
|
||||||
|
expect(() => restoreAgentConfigurationRedactions("Bearer ***REDACTED*** edited", "Bearer secret", "Bearer ***REDACTED***")).toThrow("redacted value");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { agentConfigurationSpec, canonicalAgentConfiguration, type AgentConfigurationSource } from "@paperclipai/shared";
|
||||||
|
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||||
|
import { REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||||
|
import { unprocessable } from "../errors.js";
|
||||||
|
import { resolveDecisionSigningSecret } from "../services/decision-signing.js";
|
||||||
|
|
||||||
|
export function agentConfigurationRevision(agent: AgentConfigurationSource & { permissions: Record<string, unknown> }) {
|
||||||
|
return createHmac("sha256", resolveDecisionSigningSecret()).update("agent-configuration-v1:").update(canonicalAgentConfiguration({
|
||||||
|
id: agent.id, ...agentConfigurationSpec(agent), permissions: normalizeAgentPermissions(agent.permissions),
|
||||||
|
})).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreAgentConfigurationRedactions(requested: unknown, saved: unknown, displayed: unknown): unknown {
|
||||||
|
if (canonicalAgentConfiguration(requested) === canonicalAgentConfiguration(displayed)) return saved;
|
||||||
|
if (typeof requested === "string" && requested.includes(REDACTED_EVENT_VALUE)) {
|
||||||
|
throw unprocessable("A redacted value was changed or moved. Keep it unchanged, remove it, or replace the entire value.");
|
||||||
|
}
|
||||||
|
if (Array.isArray(requested)) {
|
||||||
|
return requested.map((value, index) => restoreAgentConfigurationRedactions(value,
|
||||||
|
Array.isArray(saved) ? saved[index] : undefined, Array.isArray(displayed) ? displayed[index] : undefined));
|
||||||
|
}
|
||||||
|
if (requested && typeof requested === "object") {
|
||||||
|
const previous = saved && typeof saved === "object" ? saved as Record<string, unknown> : {};
|
||||||
|
const redacted = displayed && typeof displayed === "object" ? displayed as Record<string, unknown> : {};
|
||||||
|
return Object.fromEntries(Object.entries(requested).map(([key, value]) => [key,
|
||||||
|
restoreAgentConfigurationRedactions(value, Object.hasOwn(previous, key) ? previous[key] : undefined,
|
||||||
|
Object.hasOwn(redacted, key) ? redacted[key] : undefined),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ export interface ClaudeLoginContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAgentOptions {
|
export interface UpdateAgentOptions {
|
||||||
|
expectedConfigurationRevision?: string;
|
||||||
expectedConfiguration?: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "defaultEnvironmentId">;
|
expectedConfiguration?: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "defaultEnvironmentId">;
|
||||||
recordRevision?: RevisionMetadata;
|
recordRevision?: RevisionMetadata;
|
||||||
allowBuiltInAgentMetadata?: boolean;
|
allowBuiltInAgentMetadata?: boolean;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { errorHandler } from "../../../middleware/error-handler.js";
|
|||||||
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
|
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db";
|
import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, applyPendingMigrations, closeRegisteredClients, createDb, type Db } from "@paperclipai/db";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
||||||
import { createAgentLifecycle, configureAgentLifecycle, startAgentLifecycle } from "../../../services/agent-lifecycle.js";
|
import { createAgentLifecycle, configureAgentLifecycle, startAgentLifecycle } from "../../../services/agent-lifecycle.js";
|
||||||
@@ -25,17 +25,27 @@ import { createLifecycleStore as createStore } from "../adapters/postgres.js";
|
|||||||
import { transition } from "../domain/policy.js";
|
import { transition } from "../domain/policy.js";
|
||||||
import type { LifecycleAgent } from "../application/ports.js";
|
import type { LifecycleAgent } from "../application/ports.js";
|
||||||
import { budgetService } from "../../../services/budgets.js";
|
import { budgetService } from "../../../services/budgets.js";
|
||||||
|
import { agentConfigurationRevision } from "../../../lib/agent-configuration-revision.js";
|
||||||
|
import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
|
||||||
|
|
||||||
const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
|
const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
|
||||||
|
|
||||||
const support = await getEmbeddedPostgresTestSupport();
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_AGENT_LIFECYCLE_TEST_DATABASE_URL");
|
||||||
|
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||||
(support.supported ? describe : describe.skip)("agent lifecycle commands", () => {
|
(support.supported ? describe : describe.skip)("agent lifecycle commands", () => {
|
||||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||||
let db: Db;
|
let db: Db;
|
||||||
let companyId: string;
|
let companyId: string;
|
||||||
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
|
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
|
||||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }, 90_000);
|
beforeAll(async () => {
|
||||||
afterAll(async () => { for (const worker of workers) await worker.stop(); await database?.cleanup(); });
|
if (external) { await applyPendingMigrations(external); db = createDb(external); }
|
||||||
|
else { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }
|
||||||
|
}, 90_000);
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const worker of workers) await worker.stop();
|
||||||
|
if (external) await closeRegisteredClients(external);
|
||||||
|
else await database?.cleanup();
|
||||||
|
});
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
for (const work of workers) await work.stop();
|
for (const work of workers) await work.stop();
|
||||||
companyId = randomUUID();
|
companyId = randomUUID();
|
||||||
@@ -75,6 +85,53 @@ const support = await getEmbeddedPostgresTestSupport();
|
|||||||
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
|
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fences YAML saves under the write lock without treating runtime bookkeeping as an edit", async () => {
|
||||||
|
const agent = await hire();
|
||||||
|
const revision = agentConfigurationRevision(await current(agent.id));
|
||||||
|
await db.update(agents).set({ lastHeartbeatAt: new Date(), updatedAt: new Date() }).where(eq(agents.id, agent.id));
|
||||||
|
await createAgentLifecycle(db).updateConfiguration(agent.id, { title: "New title" }, { expectedConfigurationRevision: revision });
|
||||||
|
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { name: "Stale overwrite" }, { expectedConfigurationRevision: revision }))
|
||||||
|
.rejects.toThrow("configuration changed while you were editing");
|
||||||
|
expect(await current(agent.id)).toMatchObject({ name: agent.name, title: "New title" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves YAML configuration through the normal route with redaction, revisions, and access checks", async () => {
|
||||||
|
const [agent] = await db.insert(agents).values({ companyId, name: "YAML test", adapterType: "process",
|
||||||
|
adapterConfig: { command: "echo", removeMe: true, env: { DEMO_VALUE: "private-fixture" }, apiKey: "private-api-fixture" },
|
||||||
|
runtimeConfig: { heartbeat: { enabled: false } }, metadata: { token: "private-metadata-fixture" },
|
||||||
|
}).returning();
|
||||||
|
function app(actor: Express.Request["actor"]) {
|
||||||
|
const app = express(); app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||||
|
app.use("/api", agentRoutes(db)); app.use(errorHandler); return app;
|
||||||
|
}
|
||||||
|
const board = app({ type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] });
|
||||||
|
const response = await request(board).get(`/api/agents/${agent.id}/configuration`).expect(200);
|
||||||
|
expect(response.body).toMatchObject({ capabilities: null, budgetMonthlyCents: 0, defaultEnvironmentId: null });
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain("private-fixture");
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain("private-api-fixture");
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain("private-metadata-fixture");
|
||||||
|
const adapterConfig = { ...response.body.adapterConfig, command: "printf" };
|
||||||
|
delete adapterConfig.removeMe;
|
||||||
|
const saved = await request(board).patch(`/api/agents/${agent.id}`).send({
|
||||||
|
adapterConfig, replaceAdapterConfig: true, title: "Updated using YAML",
|
||||||
|
expectedConfigurationRevision: response.body.configurationRevision,
|
||||||
|
});
|
||||||
|
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
||||||
|
expect(saved.body.configurationRevision).not.toBe(response.body.configurationRevision);
|
||||||
|
const persisted = await current(agent.id);
|
||||||
|
expect(persisted.adapterConfig).toMatchObject({ command: "printf", env: { DEMO_VALUE: { type: "plain", value: "private-fixture" } }, apiKey: "private-api-fixture" });
|
||||||
|
expect(persisted.adapterConfig).not.toHaveProperty("removeMe");
|
||||||
|
expect(persisted.runtimeConfig).toEqual(agent.runtimeConfig);
|
||||||
|
expect(persisted.metadata).toEqual(agent.metadata);
|
||||||
|
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(1);
|
||||||
|
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.updated")))).toHaveLength(1);
|
||||||
|
await request(board).patch(`/api/agents/${agent.id}`).send({ name: "Stale", expectedConfigurationRevision: response.body.configurationRevision }).expect(409);
|
||||||
|
const foreign = app({ type: "board", source: "session", userId: "outsider", companyIds: [], memberships: [] });
|
||||||
|
await request(foreign).get(`/api/agents/${agent.id}/configuration`).expect(403);
|
||||||
|
await request(foreign).patch(`/api/agents/${agent.id}`).send({ title: "Denied", expectedConfigurationRevision: saved.body.configurationRevision }).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps existing states without starting setup or clearing pause reasons", async () => {
|
it("maps existing states without starting setup or clearing pause reasons", async () => {
|
||||||
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
|
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
|
||||||
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
|
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isDeepStrictEqual } from "node:util";
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import { conflict } from "../../../errors.js";
|
import { conflict } from "../../../errors.js";
|
||||||
|
import { agentConfigurationRevision } from "../../../lib/agent-configuration-revision.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { agents, type Db } from "@paperclipai/db";
|
import { agents, type Db } from "@paperclipai/db";
|
||||||
@@ -13,6 +14,9 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
|
|||||||
assertConfigurationFields(data);
|
assertConfigurationFields(data);
|
||||||
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
|
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) {
|
||||||
|
throw conflict("Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again.");
|
||||||
|
}
|
||||||
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
|
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
|
||||||
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
|
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
|
||||||
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
|
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { latestFailedRunsByAgent } from "@paperclipai/shared";
|
import { latestFailedRunsByAgent } from "@paperclipai/shared";
|
||||||
|
import { agentConfigurationSpec } from "@paperclipai/shared";
|
||||||
|
import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js";
|
||||||
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
|
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
|
||||||
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
|
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
|
||||||
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
|
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
|
||||||
@@ -2943,6 +2945,9 @@ export function agentRoutes(
|
|||||||
function redactAgentConfiguration(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
function redactAgentConfiguration(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
||||||
if (!agent) return null;
|
if (!agent) return null;
|
||||||
return {
|
return {
|
||||||
|
...agentConfigurationSpec(agent),
|
||||||
|
configurationRevision: agentConfigurationRevision(agent),
|
||||||
|
metadata: redactEventPayload(agent.metadata),
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
@@ -5399,6 +5404,20 @@ export function agentRoutes(
|
|||||||
: { ...(req.body as Record<string, unknown>) };
|
: { ...(req.body as Record<string, unknown>) };
|
||||||
const runner = patchData.runner as AgentRunnerChoice | undefined;
|
const runner = patchData.runner as AgentRunnerChoice | undefined;
|
||||||
delete patchData.runner;
|
delete patchData.runner;
|
||||||
|
const expectedConfigurationRevision = patchData.expectedConfigurationRevision as string | undefined;
|
||||||
|
delete patchData.expectedConfigurationRevision;
|
||||||
|
if (expectedConfigurationRevision && expectedConfigurationRevision !== agentConfigurationRevision(existing)) {
|
||||||
|
throw conflict("Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again.");
|
||||||
|
}
|
||||||
|
if (expectedConfigurationRevision) {
|
||||||
|
await assertCanConfigureAgentSetup(req, existing);
|
||||||
|
const displayed = redactAgentConfiguration(existing)!;
|
||||||
|
for (const key of ["adapterConfig", "runtimeConfig", "metadata"] as const) {
|
||||||
|
if (hasOwn(patchData, key)) {
|
||||||
|
patchData[key] = restoreAgentConfigurationRedactions(patchData[key], existing[key], displayed[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const requestedStatus = patchData.status;
|
const requestedStatus = patchData.status;
|
||||||
delete patchData.status;
|
delete patchData.status;
|
||||||
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
|
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
|
||||||
@@ -5651,6 +5670,7 @@ export function agentRoutes(
|
|||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const updateOptions = {
|
const updateOptions = {
|
||||||
|
expectedConfigurationRevision,
|
||||||
...(runtimeSettings ? { expectedConfiguration: {
|
...(runtimeSettings ? { expectedConfiguration: {
|
||||||
adapterType: existing.adapterType, adapterConfig: existing.adapterConfig,
|
adapterType: existing.adapterType, adapterConfig: existing.adapterConfig,
|
||||||
runtimeConfig: existing.runtimeConfig, defaultEnvironmentId: existing.defaultEnvironmentId,
|
runtimeConfig: existing.runtimeConfig, defaultEnvironmentId: existing.defaultEnvironmentId,
|
||||||
@@ -5690,7 +5710,7 @@ export function agentRoutes(
|
|||||||
details: summarizeAgentUpdateDetails(patchData),
|
details: summarizeAgentUpdateDetails(patchData),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
|
res.json({ ...redactAgentRowForResponse(agent), ...redactAgentConfiguration(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/agents/:id/lifecycle/retry", async (req, res) => {
|
router.post("/agents/:id/lifecycle/retry", async (req, res) => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns } from "@paperclipai/db";
|
import { agents, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns } from "@paperclipai/db";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
|
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
|
||||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
||||||
|
import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
|
||||||
import { runningProcesses } from "../../../adapters/index.js";
|
import { runningProcesses } from "../../../adapters/index.js";
|
||||||
import { terminateLocalService } from "../../local-service-supervisor.js";
|
import { terminateLocalService } from "../../local-service-supervisor.js";
|
||||||
import { createHeartbeatRunState } from "../run-state.js";
|
import { createHeartbeatRunState } from "../run-state.js";
|
||||||
@@ -65,15 +66,17 @@ export function callbacks(db: Db): TestCallbacks {
|
|||||||
failedProcessRunCancellations: new Map<string, { settled: Promise<void>; failed: boolean; error?: unknown }>(),
|
failedProcessRunCancellations: new Map<string, { settled: Promise<void>; failed: boolean; error?: unknown }>(),
|
||||||
} satisfies HeartbeatRunControlDependencies;
|
} satisfies HeartbeatRunControlDependencies;
|
||||||
}
|
}
|
||||||
export const runControlPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_RUN_CONTROL_TEST_DATABASE_URL");
|
||||||
|
export const runControlPostgresSupport = externalTestDatabaseUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
||||||
if (!runControlPostgresSupport.supported) console.warn(`Skipping run-control database tests: ${runControlPostgresSupport.reason}`);
|
if (!runControlPostgresSupport.supported) console.warn(`Skipping run-control database tests: ${runControlPostgresSupport.reason}`);
|
||||||
|
|
||||||
export function setupRunControlDatabase(name: string, ready: (db: Db) => void) {
|
export function setupRunControlDatabase(name: string, ready: (db: Db) => void) {
|
||||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||||
let db: Db;
|
let db: Db;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
database = await startEmbeddedPostgresTestDatabase(name);
|
if (externalTestDatabaseUrl) await applyPendingMigrations(externalTestDatabaseUrl);
|
||||||
db = createDb(database.connectionString);
|
else database = await startEmbeddedPostgresTestDatabase(name);
|
||||||
|
db = createDb(externalTestDatabaseUrl ?? database!.connectionString);
|
||||||
ready(db);
|
ready(db);
|
||||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
|
import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { runningProcesses } from "../../adapters/index.js";
|
import { runningProcesses } from "../../adapters/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
|
|||||||
import { HttpError } from "../../errors.js";
|
import { HttpError } from "../../errors.js";
|
||||||
import { captureException } from "../../sentry.js";
|
import { captureException } from "../../sentry.js";
|
||||||
import { terminateLocalService } from "../local-service-supervisor.js";
|
import { terminateLocalService } from "../local-service-supervisor.js";
|
||||||
|
import { publishLiveEvent } from "../live-events.js";
|
||||||
import { createHeartbeatRunState } from "./run-state.js";
|
import { createHeartbeatRunState } from "./run-state.js";
|
||||||
import { createHeartbeatRunControl } from "./run-control.js";
|
import { createHeartbeatRunControl } from "./run-control.js";
|
||||||
import { preserveCancelledNativeWorkspaces } from "../native-runtime/native-failed-workspace-recovery.js";
|
import { preserveCancelledNativeWorkspaces } from "../native-runtime/native-failed-workspace-recovery.js";
|
||||||
@@ -173,6 +174,79 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
|
|||||||
)).toMatchObject({ status: "cancelled" });
|
)).toMatchObject({ status: "cancelled" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([["40P01", 3], ["XX000", 1]] as const)("bounds cancellation transaction attempts for PostgreSQL %s", async (code, attempts) => {
|
||||||
|
const { company, agent } = await fixture();
|
||||||
|
const run = await liveRun(company.id, agent.id);
|
||||||
|
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Cancellation failure", status: "in_progress",
|
||||||
|
assigneeAgentId: agent.id, executionRunId: run.id }).returning();
|
||||||
|
await db.update(heartbeatRuns).set({ issueId: issue.id, contextSnapshot: { issueId: issue.id } }).where(eq(heartbeatRuns.id, run.id));
|
||||||
|
await db.execute(sql`create sequence run_control_cancel_attempts`);
|
||||||
|
await db.execute(sql.raw(`create function run_control_cancel_fault() returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if new.result_json->'executionCancellation'->>'state' = 'requested' then
|
||||||
|
perform nextval('run_control_cancel_attempts');
|
||||||
|
raise exception using errcode = '${code}', message = 'Synthetic cancellation transaction failure';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end $$`));
|
||||||
|
await db.execute(sql`create trigger run_control_cancel_fault before update on heartbeat_runs
|
||||||
|
for each row execute function run_control_cancel_fault()`);
|
||||||
|
const deps = callbacks(db);
|
||||||
|
try {
|
||||||
|
await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Operator Stop", {
|
||||||
|
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
|
||||||
|
})).rejects.toMatchObject({ cause: { code } });
|
||||||
|
const [counter] = await db.execute<{ count: number }>(sql`select last_value::int as count from run_control_cancel_attempts`);
|
||||||
|
expect(counter.count).toBe(attempts);
|
||||||
|
expect(await deps.getRun(run.id)).toMatchObject({ status: "running", resultJson: null });
|
||||||
|
expect(terminateLocalService).not.toHaveBeenCalled();
|
||||||
|
expect(deps.appendRunEvent).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
await db.execute(sql`drop trigger run_control_cancel_fault on heartbeat_runs`);
|
||||||
|
await db.execute(sql`drop function run_control_cancel_fault()`);
|
||||||
|
await db.execute(sql`drop sequence run_control_cancel_attempts`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes operator Stop after a concurrent run writer causes a transaction deadlock", async () => {
|
||||||
|
const { company, agent } = await fixture();
|
||||||
|
const run = await liveRun(company.id, agent.id);
|
||||||
|
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Stop contention", status: "in_progress",
|
||||||
|
assigneeAgentId: agent.id, executionRunId: run.id, checkoutRunId: run.id }).returning();
|
||||||
|
await db.update(heartbeatRuns).set({ issueId: issue.id, contextSnapshot: { issueId: issue.id } }).where(eq(heartbeatRuns.id, run.id));
|
||||||
|
let enter!: () => void, release!: () => void;
|
||||||
|
const entered = new Promise<void>(resolve => { enter = resolve; });
|
||||||
|
const released = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
let blockerPid = 0;
|
||||||
|
const writer = db.transaction(async tx => {
|
||||||
|
// Make Stop's default detector see the cycle first, so this reproduces
|
||||||
|
// the API-side victim observed in CI rather than a random victim.
|
||||||
|
await tx.execute(sql`set local deadlock_timeout = '10s'`);
|
||||||
|
const [backend] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||||
|
blockerPid = backend.pid;
|
||||||
|
await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)).for("update");
|
||||||
|
enter(); await released;
|
||||||
|
await tx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, issue.id));
|
||||||
|
});
|
||||||
|
await entered;
|
||||||
|
const deps = callbacks(db);
|
||||||
|
const stopping = createHeartbeatRunControl(db, deps).cancelRunInternal(run.id, "Operator Stop", {
|
||||||
|
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
|
||||||
|
}).then(value => ({ value, error: undefined }), error => ({ value: undefined, error }));
|
||||||
|
try {
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const waiting = await db.execute(sql`select pid from pg_stat_activity
|
||||||
|
where ${blockerPid} = any(pg_blocking_pids(pid)) and wait_event_type = 'Lock'`);
|
||||||
|
expect(waiting.length).toBeGreaterThan(0);
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
} finally { release(); }
|
||||||
|
await writer;
|
||||||
|
const result = await stopping;
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.value).toMatchObject({ status: "cancelled" });
|
||||||
|
expect(await deps.getRun(run.id)).toMatchObject({ status: "cancelled" });
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["queued", "running"] as const)("preserves case intent when a guarded Stop loses to %s promotion", async status => {
|
it.each(["queued", "running"] as const)("preserves case intent when a guarded Stop loses to %s promotion", async status => {
|
||||||
const { company, agent } = await fixture();
|
const { company, agent } = await fixture();
|
||||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Promoted case work", status: "todo", assigneeAgentId: agent.id }).returning();
|
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Promoted case work", status: "todo", assigneeAgentId: agent.id }).returning();
|
||||||
@@ -393,6 +467,63 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
|
|||||||
.toMatchObject({ status: "in_progress", phase: "verification", completedAt: null });
|
.toMatchObject({ status: "in_progress", phase: "verification", completedAt: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([false, true])("discards rolled-back native Stop claims and activity before retry (authority revoked=%s)", async revokeAuthority => {
|
||||||
|
const f = await boardTerminalFixture("running", "native");
|
||||||
|
const requestId = randomUUID(), deps = callbacks(db);
|
||||||
|
vi.mocked(publishLiveEvent).mockClear();
|
||||||
|
const transact = db.transaction.bind(db);
|
||||||
|
let injected = false;
|
||||||
|
const transaction = vi.spyOn(db, "transaction").mockImplementationOnce(async (operation, config) => {
|
||||||
|
try {
|
||||||
|
return await transact(async tx => {
|
||||||
|
const result = await operation(tx);
|
||||||
|
const [claimed] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id));
|
||||||
|
expect(claimed.resultJson?.startupCancellation).toMatchObject({ cancellationRequestId: requestId });
|
||||||
|
expect(claimed.resultJson?.executionCancellation).toMatchObject({ state: "requested" });
|
||||||
|
expect((await tx.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0].state).toBe("cancelled");
|
||||||
|
expect((await tx.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)))
|
||||||
|
.filter(row => row.action === "case.handoff_cancelled")).toHaveLength(1);
|
||||||
|
expect(deps.getAgent).not.toHaveBeenCalled();
|
||||||
|
injected = true;
|
||||||
|
await tx.execute(sql`do $$ begin raise exception using errcode = '40P01', message = 'Deadlock after native Stop claim'; end $$`);
|
||||||
|
return result;
|
||||||
|
}, config);
|
||||||
|
} catch (error) {
|
||||||
|
if (injected && revokeAuthority) await db.update(nativeRunFinalizations)
|
||||||
|
.set({ phase: "terminal_failure", failureCode: "retry_authority_revoked", nextAttemptAt: null })
|
||||||
|
.where(eq(nativeRunFinalizations.runId, f.run.id));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const stopping = createHeartbeatRunControl(db, deps).cancelRunInternal(f.run.id, "Retry native Stop", {
|
||||||
|
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator" },
|
||||||
|
cancellationRequestId: requestId, cancellationRequestedByUserId: "operator",
|
||||||
|
});
|
||||||
|
if (revokeAuthority) {
|
||||||
|
await expect(stopping).rejects.toMatchObject({ status: 409 });
|
||||||
|
expect(await deps.getRun(f.run.id)).toMatchObject({ status: "failed", resultJson: null });
|
||||||
|
expect(deps.getAgent).not.toHaveBeenCalled();
|
||||||
|
expect(deps.options.closeWarmNativeSessionsForRun).not.toHaveBeenCalled();
|
||||||
|
expect(deps.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
await expect(stopping).resolves.toMatchObject({ status: "cancelled" });
|
||||||
|
expect((await deps.getRun(f.run.id))?.resultJson?.startupCancellation).toMatchObject({ cancellationRequestId: requestId });
|
||||||
|
}
|
||||||
|
expect(injected).toBe(true);
|
||||||
|
expect(transaction.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0].state)
|
||||||
|
.toBe(revokeAuthority ? "awaiting_completion" : "cancelled");
|
||||||
|
const expected = revokeAuthority ? 0 : 1;
|
||||||
|
expect((await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)))
|
||||||
|
.filter(row => row.action === "case.handoff_cancelled")).toHaveLength(expected);
|
||||||
|
expect(vi.mocked(publishLiveEvent).mock.calls.map(([event]) => event)
|
||||||
|
.filter(event => event.companyId === f.company.id && event.type === "activity.logged"
|
||||||
|
&& event.payload?.action === "case.handoff_cancelled")).toHaveLength(expected);
|
||||||
|
expect(terminateLocalService).not.toHaveBeenCalled();
|
||||||
|
} finally { transaction.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
it("stops current preparing execution despite a prior terminal checkout when cancelling board intent", async () => {
|
it("stops current preparing execution despite a prior terminal checkout when cancelling board intent", async () => {
|
||||||
// Queue admission binds the new execution before preparation adopts its
|
// Queue admission binds the new execution before preparation adopts its
|
||||||
// predecessor's checkout. The execution owner is authoritative in this gap.
|
// predecessor's checkout. The execution owner is authoritative in this gap.
|
||||||
|
|||||||
@@ -1134,7 +1134,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
|||||||
let caseHandoffRetryClaimed = false;
|
let caseHandoffRetryClaimed = false;
|
||||||
if (handoffIssueId && supersedesCaseHandoff && options.resultJson?.caseHandoffYieldStop !== true) {
|
if (handoffIssueId && supersedesCaseHandoff && options.resultJson?.caseHandoffYieldStop !== true) {
|
||||||
const publications: ActivityPublication[] = [];
|
const publications: ActivityPublication[] = [];
|
||||||
const supersessionApplied = await db.transaction(async tx => {
|
const applySupersession = () => db.transaction(async tx => {
|
||||||
const [issue] = await tx.select({ id: issues.id, executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId,
|
const [issue] = await tx.select({ id: issues.id, executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId,
|
||||||
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
|
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
|
||||||
.where(and(eq(issues.companyId, run!.companyId), eq(issues.id, handoffIssueId))).for("update");
|
.where(and(eq(issues.companyId, run!.companyId), eq(issues.id, handoffIssueId))).for("update");
|
||||||
@@ -1189,7 +1189,27 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
|||||||
await cancelCaseHandoffsForIssue(tx as unknown as Db, { companyId: run!.companyId, issueId: handoffIssueId,
|
await cancelCaseHandoffsForIssue(tx as unknown as Db, { companyId: run!.companyId, issueId: handoffIssueId,
|
||||||
sourceRunId: runId, includeUnboundTerminalIntent: Boolean(fenced && ownsTask), reason, publications });
|
sourceRunId: runId, includeUnboundTerminalIntent: Boolean(fenced && ownsTask), reason, publications });
|
||||||
return true;
|
return true;
|
||||||
}).catch(rethrowNativeCancellationLockConflict);
|
});
|
||||||
|
let supersessionApplied = false;
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const previousRun = run;
|
||||||
|
try {
|
||||||
|
supersessionApplied = await applySupersession();
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const value = parseObject(error);
|
||||||
|
const deadlock = value.code === "40P01" || parseObject(value.cause).code === "40P01";
|
||||||
|
if (!deadlock || attempt >= 2) rethrowNativeCancellationLockConflict(error);
|
||||||
|
// PostgreSQL aborted the entire transaction. Discard its local
|
||||||
|
// receipts before re-reading current authority under fresh locks.
|
||||||
|
// Provider Stop and publication happen only after this boundary.
|
||||||
|
run = previousRun;
|
||||||
|
cancellationClaimed = false;
|
||||||
|
caseHandoffRetryClaimed = false;
|
||||||
|
publications.length = 0;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
publications.forEach(publishActivity);
|
publications.forEach(publishActivity);
|
||||||
if (!supersessionApplied) return null;
|
if (!supersessionApplied) return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test as base, type APIRequestContext, type APIResponse, type Page } from "@playwright/test";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
import type { AgentConfigurationDocument } from "@paperclipai/shared";
|
||||||
|
import { startReviewProvider } from "../fixtures/connection-review-provider";
|
||||||
|
|
||||||
|
const test = base.extend<{ toolProvider: Awaited<ReturnType<typeof startReviewProvider>>; settings: void }>({
|
||||||
|
settings: [async ({ request }, use) => {
|
||||||
|
const previous = await json(await request.get("/api/instance/settings/experimental"));
|
||||||
|
try {
|
||||||
|
await json(await request.patch("/api/instance/settings/experimental", { data: { enableBetaSkills: true, enableChatConnectors: true } }));
|
||||||
|
await use();
|
||||||
|
} finally {
|
||||||
|
await json(await request.patch("/api/instance/settings/experimental", { data: {
|
||||||
|
enableBetaSkills: previous.enableBetaSkills, enableChatConnectors: previous.enableChatConnectors,
|
||||||
|
} }));
|
||||||
|
}
|
||||||
|
}, { auto: true }],
|
||||||
|
toolProvider: async ({}, use) => {
|
||||||
|
const provider = await startReviewProvider();
|
||||||
|
try { await use(provider); }
|
||||||
|
finally { await provider.close(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.use({ permissions: ["clipboard-read", "clipboard-write"] });
|
||||||
|
|
||||||
|
async function json(response: APIResponse) {
|
||||||
|
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(request: APIRequestContext) {
|
||||||
|
const company = await json(await request.post("/api/companies", { data: { name: `Agent YAML ${Date.now()}` } }));
|
||||||
|
const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
|
||||||
|
name: "YAML engineer", role: "engineer", adapterType: "process", adapterConfig: { command: "true", removable: "old value" },
|
||||||
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } },
|
||||||
|
} }));
|
||||||
|
return { company, agent, url: `/${company.issuePrefix}/agents/${agent.urlKey}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = (page: Page) => page.getByRole("textbox", { name: "Agent YAML configuration" });
|
||||||
|
async function document(page: Page): Promise<AgentConfigurationDocument> {
|
||||||
|
await expect(editor(page)).toBeVisible();
|
||||||
|
await expect(editor(page)).toHaveAttribute("contenteditable", "true");
|
||||||
|
await editor(page).press("ControlOrMeta+a");
|
||||||
|
await editor(page).press("ControlOrMeta+c");
|
||||||
|
const source = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(source).toContain("apiVersion: paperclip/v1");
|
||||||
|
return parse(source);
|
||||||
|
}
|
||||||
|
async function edit(page: Page, change: (value: AgentConfigurationDocument) => void) {
|
||||||
|
const value = await document(page);
|
||||||
|
change(value);
|
||||||
|
await page.evaluate(source => navigator.clipboard.writeText(source), stringify(value, { lineWidth: 0 }));
|
||||||
|
await editor(page).press("ControlOrMeta+v");
|
||||||
|
}
|
||||||
|
async function save(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||||
|
await expect(page.getByText("Unsaved changes", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
||||||
|
await expect(page.getByRole("button", { name: "Reload saved", exact: true })).toBeEnabled();
|
||||||
|
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Advanced YAML persists settings and commands through their real resource APIs", async ({ page, request, toolProvider }, info) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const { company, agent, url } = await setup(request);
|
||||||
|
await json(await request.patch(`/api/agents/${agent.id}/instructions-bundle`, { data: { mode: "managed" } }));
|
||||||
|
const skill = await json(await request.post(`/api/companies/${company.id}/skills`, { data: {
|
||||||
|
name: "YAML proof", slug: "yaml-proof", markdown: "---\nname: yaml-proof\ndescription: Verify saved configuration.\n---\nCheck the persisted state.\n",
|
||||||
|
} }));
|
||||||
|
const connection = await json(await request.post(`/api/companies/${company.id}/tools/connections`, { data: {
|
||||||
|
name: "YAML tools", transport: "mcp_remote", config: { url: toolProvider.url }, status: "draft", enabled: false,
|
||||||
|
} }));
|
||||||
|
await page.goto(`${url}/overview`);
|
||||||
|
await page.getByRole("link", { name: "Advanced", exact: true }).click();
|
||||||
|
await edit(page, value => {
|
||||||
|
value.spec.title = "Saved YAML engineer";
|
||||||
|
value.spec.budgetMonthlyCents = 12300;
|
||||||
|
delete value.spec.adapterConfig.removable;
|
||||||
|
value.spec.adapterConfig.env = { YAML_VARIABLE: { type: "plain", value: "saved variable" } };
|
||||||
|
value.spec.instructions!.files["workflow.md"] = "Saved through YAML.\n";
|
||||||
|
value.spec.skills = [{ key: skill.key, versionId: skill.currentVersionId }];
|
||||||
|
value.spec.tools!.connections.find(row => row.connectionId === connection.id)!.installed = true;
|
||||||
|
value.spec.tools!.profiles = [{ profileId: null, priority: 100, metadata: null, configuration: {
|
||||||
|
profileKey: "yaml-profile", name: "YAML profile", description: null, status: "active", defaultAction: "deny", metadata: null,
|
||||||
|
entries: [{ selectorType: "tool_name", toolName: "read_repository", effect: "include" }],
|
||||||
|
} }];
|
||||||
|
value.spec.tools!.policies = [{ id: null, configuration: { name: "YAML block", policyType: "block", priority: 100, enabled: true, selectors: { agentIds: [agent.id], toolNames: ["delete_repository"] } } }];
|
||||||
|
value.spec.permissions!.canCreateSkills = false;
|
||||||
|
value.spec.secrets!.create = [{ secret: { name: "YAML secret", value: "disposable-yaml-secret" }, bind: { env: ["YAML_SECRET"], api: [] } }];
|
||||||
|
value.spec.apiKeys!.create = [{ name: "YAML automation", scope: { kind: "standard" } }];
|
||||||
|
value.spec.channelOperations!.create = [{ provider: "telegram" }];
|
||||||
|
});
|
||||||
|
await save(page);
|
||||||
|
const token = page.getByRole("textbox", { name: "API key: YAML automation token" });
|
||||||
|
await expect(token).toBeVisible();
|
||||||
|
expect(await token.inputValue()).toMatch(/^pcp_/);
|
||||||
|
await page.reload();
|
||||||
|
const saved = await document(page);
|
||||||
|
expect(saved.spec).toMatchObject({ title: "Saved YAML engineer", budgetMonthlyCents: 12300, permissions: { canCreateSkills: false } });
|
||||||
|
expect(saved.spec.adapterConfig).not.toHaveProperty("removable");
|
||||||
|
expect(saved.spec.adapterConfig.env).toMatchObject({ YAML_VARIABLE: { type: "plain", value: "***REDACTED***" }, YAML_SECRET: { type: "secret_ref" } });
|
||||||
|
expect(stringify(saved)).not.toContain("disposable-yaml-secret");
|
||||||
|
expect(saved.spec.instructions!.files["workflow.md"]).toBe("Saved through YAML.\n");
|
||||||
|
expect(saved.spec.skills).toEqual([{ key: skill.key, versionId: skill.currentVersionId }]);
|
||||||
|
expect(saved.spec.tools!.profiles).toEqual(expect.arrayContaining([expect.objectContaining({ profileId: expect.any(String), configuration: expect.objectContaining({ name: "YAML profile", entries: [expect.objectContaining({ toolName: "read_repository" })] }) })]));
|
||||||
|
expect(saved.spec.tools!.policies).toEqual([expect.objectContaining({ id: expect.any(String), configuration: expect.objectContaining({ name: "YAML block" }) })]);
|
||||||
|
expect(saved.spec.channels).toEqual([expect.objectContaining({ provider: "telegram" })]);
|
||||||
|
expect(saved.spec.apiKeys!.create).toEqual([]);
|
||||||
|
expect(saved.spec.secrets!.create).toEqual([]);
|
||||||
|
expect(saved.spec.channelOperations!.create).toEqual([]);
|
||||||
|
expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).adapterConfig.env.YAML_SECRET.type).toBe("secret_ref");
|
||||||
|
expect((await json(await request.get(`/api/tool-connections/${connection.id}/installs`))).installs).toContainEqual(expect.objectContaining({ targetType: "agent", targetId: agent.id }));
|
||||||
|
const key = (await json(await request.get(`/api/agents/${agent.id}/keys`))).find((row: { name: string }) => row.name === "YAML automation");
|
||||||
|
await edit(page, value => {
|
||||||
|
value.spec.apiKeys!.revoke = [key.id];
|
||||||
|
value.spec.channels![0].settings.allowUnlinkedPeople = true;
|
||||||
|
delete value.spec.instructions!.files["workflow.md"];
|
||||||
|
value.spec.tools!.profiles = [];
|
||||||
|
value.spec.tools!.policies = [];
|
||||||
|
value.spec.tools!.connections.find(row => row.connectionId === connection.id)!.installed = false;
|
||||||
|
value.spec.skills = [];
|
||||||
|
});
|
||||||
|
await save(page);
|
||||||
|
await page.reload();
|
||||||
|
const cleared = await document(page);
|
||||||
|
expect(cleared.spec.instructions!.files).not.toHaveProperty("workflow.md");
|
||||||
|
expect(cleared.spec.skills).toEqual([]);
|
||||||
|
expect(cleared.spec.tools!.profiles).toEqual([]);
|
||||||
|
expect(cleared.spec.tools!.policies).toEqual([]);
|
||||||
|
expect(cleared.spec.channels![0].settings.allowUnlinkedPeople).toBe(true);
|
||||||
|
expect(cleared.spec.channels![0].settings).not.toHaveProperty("communicationInstructions");
|
||||||
|
expect((await json(await request.get(`/api/agents/${agent.id}/keys`))).find((row: { id: string }) => row.id === key.id).revokedAt).not.toBeNull();
|
||||||
|
await page.screenshot({ path: info.outputPath("saved-agent-yaml.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale drafts and invalid commands leave saved configuration unchanged", async ({ page, request }) => {
|
||||||
|
const { agent, url } = await setup(request);
|
||||||
|
await page.goto(`${url}/advanced`);
|
||||||
|
await edit(page, value => { value.spec.title = "Local draft"; });
|
||||||
|
await json(await request.patch(`/api/agents/${agent.id}`, { data: { title: "Other operator" } }));
|
||||||
|
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("changed while you were editing");
|
||||||
|
expect((await document(page)).spec.title).toBe("Local draft");
|
||||||
|
expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).title).toBe("Other operator");
|
||||||
|
await page.getByRole("button", { name: "Reload saved", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await document(page)).spec.title).toBe("Other operator");
|
||||||
|
await edit(page, value => { value.observed = {}; });
|
||||||
|
await expect(page.getByRole("alert")).toContainText("observed is read-only");
|
||||||
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renaming an agent keeps newly created key tokens visible through the route change", async ({ page, request }) => {
|
||||||
|
const { agent, url } = await setup(request);
|
||||||
|
await page.goto(`${url}/advanced`);
|
||||||
|
await edit(page, value => {
|
||||||
|
value.spec.name = "Renamed YAML engineer";
|
||||||
|
value.spec.apiKeys!.create = [{ name: "Rename key", scope: { kind: "standard" } }];
|
||||||
|
});
|
||||||
|
await save(page);
|
||||||
|
await expect(page).toHaveURL(/\/agents\/renamed-yaml-engineer\/advanced$/);
|
||||||
|
const token = page.getByRole("textbox", { name: "API key: Rename key token" });
|
||||||
|
await expect(token).toBeVisible();
|
||||||
|
const created = await token.inputValue();
|
||||||
|
expect(created).toMatch(/^pcp_/);
|
||||||
|
expect(stringify(await document(page))).not.toContain(created);
|
||||||
|
expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(1);
|
||||||
|
await edit(page, value => { value.spec.title = "Another saved edit"; });
|
||||||
|
await save(page);
|
||||||
|
await expect(token).toHaveValue(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lost creation response requires reload and cannot blindly replay commands", async ({ page, request }) => {
|
||||||
|
const { agent, url } = await setup(request);
|
||||||
|
let creates = 0;
|
||||||
|
await page.route(`**/api/agents/${agent.id}/keys?*`, async route => {
|
||||||
|
if (route.request().method() !== "POST") return route.continue();
|
||||||
|
creates++;
|
||||||
|
if (creates === 1) return route.continue();
|
||||||
|
await route.fetch();
|
||||||
|
return route.abort("failed");
|
||||||
|
});
|
||||||
|
await page.goto(`${url}/advanced`);
|
||||||
|
await edit(page, value => {
|
||||||
|
value.spec.name = "Partially renamed YAML engineer";
|
||||||
|
value.spec.title = "Saved before lost response";
|
||||||
|
value.spec.apiKeys!.create = ["First key", "Second key"].map(name => ({ name, scope: { kind: "standard" } }));
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Reload saved before saving again");
|
||||||
|
await expect(page.getByRole("textbox", { name: "API key: First key token" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
||||||
|
expect((await json(await request.get(`/api/agents/${agent.id}/configuration`))).title).toBe("Saved before lost response");
|
||||||
|
expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(2);
|
||||||
|
expect((await document(page)).spec.apiKeys!.create).toHaveLength(2);
|
||||||
|
await page.getByRole("button", { name: "Reload saved", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await document(page)).spec.apiKeys!.create).toEqual([]);
|
||||||
|
await expect(page).toHaveURL(/\/agents\/partially-renamed-yaml-engineer\/advanced$/);
|
||||||
|
await expect(page.getByRole("textbox", { name: "API key: First key token" })).toBeVisible();
|
||||||
|
await edit(page, value => { value.spec.title = "Recovered save"; });
|
||||||
|
await save(page);
|
||||||
|
expect(creates).toBe(2);
|
||||||
|
expect(await json(await request.get(`/api/agents/${agent.id}/keys`))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching instructions location loads existing destination files without overwriting them", async ({ page, request }) => {
|
||||||
|
const { agent, url } = await setup(request);
|
||||||
|
await json(await request.patch(`/api/agents/${agent.id}/instructions-bundle`, { data: { mode: "managed" } }));
|
||||||
|
const destination = await mkdtemp(path.join(os.tmpdir(), "agent-yaml-instructions-"));
|
||||||
|
await writeFile(path.join(destination, "AGENTS.md"), "Keep destination instructions.\n");
|
||||||
|
await page.goto(`${url}/advanced`);
|
||||||
|
await edit(page, value => { value.spec.instructions!.mode = "external"; value.spec.instructions!.rootPath = destination; });
|
||||||
|
await save(page);
|
||||||
|
expect((await document(page)).spec.instructions!.files["AGENTS.md"]).toBe("Keep destination instructions.\n");
|
||||||
|
expect(await readFile(path.join(destination, "AGENTS.md"), "utf8")).toBe("Keep destination instructions.\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revision restore is an explicit separate save and reloads the restored configuration", async ({ page, request }) => {
|
||||||
|
const { agent, url } = await setup(request);
|
||||||
|
await page.goto(`${url}/advanced`);
|
||||||
|
await edit(page, value => { value.spec.title = "Before restore"; });
|
||||||
|
await save(page);
|
||||||
|
const revisions = await json(await request.get(`/api/agents/${agent.id}/config-revisions`));
|
||||||
|
const revision = revisions.find((row: { changedKeys: string[] }) => row.changedKeys.includes("title"));
|
||||||
|
expect(revision).toBeTruthy();
|
||||||
|
await edit(page, value => { value.spec.title = "After restore target"; });
|
||||||
|
await save(page);
|
||||||
|
await edit(page, value => { value.spec.revisions!.restore = revision.id; value.spec.title = "Conflicting edit"; });
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Restore a revision in its own save");
|
||||||
|
await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
|
||||||
|
await edit(page, value => { value.spec.title = "After restore target"; });
|
||||||
|
await save(page);
|
||||||
|
await page.reload();
|
||||||
|
const restored = await document(page);
|
||||||
|
expect(restored.spec.title).toBe("Before restore");
|
||||||
|
expect(restored.spec.revisions!.restore).toBeNull();
|
||||||
|
});
|
||||||
@@ -31,10 +31,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/react": "0.15.22",
|
"@assistant-ui/react": "0.15.22",
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/state": "^6.7.2",
|
||||||
|
"@codemirror/view": "^6.43.11",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lexical/link": "0.48.0",
|
"@lexical/link": "0.48.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@mdxeditor/editor": "^4.2.3",
|
"@mdxeditor/editor": "^4.2.3",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
@@ -58,6 +63,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"i18next": "^26.4.2",
|
"i18next": "^26.4.2",
|
||||||
"lexical": "0.48.0",
|
"lexical": "0.48.0",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
@@ -72,6 +78,7 @@
|
|||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"yaml": "^2.8.1",
|
||||||
"yjs": "13.6.32"
|
"yjs": "13.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export const agentsApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConfiguration: (id: string, companyId?: string) =>
|
getConfiguration: (id: string, companyId?: string) =>
|
||||||
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
|
api.get<import("@paperclipai/shared").AgentConfigurationResponse>(agentPath(id, companyId, "/configuration")),
|
||||||
listConfigRevisions: (id: string, companyId?: string) =>
|
listConfigRevisions: (id: string, companyId?: string) =>
|
||||||
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
|
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
|
||||||
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
||||||
@@ -168,7 +168,7 @@ export const agentsApi = {
|
|||||||
updateRuntimeSettings: (id: string, runtimeSettings: import("@paperclipai/shared").AgentRuntimeSettingsPatch, companyId: string) =>
|
updateRuntimeSettings: (id: string, runtimeSettings: import("@paperclipai/shared").AgentRuntimeSettingsPatch, companyId: string) =>
|
||||||
api.patch<Agent>(agentPath(id, companyId), { runtimeSettings }),
|
api.patch<Agent>(agentPath(id, companyId), { runtimeSettings }),
|
||||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||||
api.patch<Agent>(agentPath(id, companyId), data),
|
api.patch<Agent & import("@paperclipai/shared").AgentConfigurationResponse>(agentPath(id, companyId), data),
|
||||||
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
||||||
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
||||||
instructionsBundle: (id: string, companyId?: string) =>
|
instructionsBundle: (id: string, companyId?: string) =>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ describe("AgentContextualSidebar", () => {
|
|||||||
expect(markup).toContain("border-r border-border bg-background");
|
expect(markup).toContain("border-r border-border bg-background");
|
||||||
expect(markup).toContain('data-slot="contextual-sidebar-nav"');
|
expect(markup).toContain('data-slot="contextual-sidebar-nav"');
|
||||||
expect(markup).toContain('href="/agents/codexcoder/overview"');
|
expect(markup).toContain('href="/agents/codexcoder/overview"');
|
||||||
|
expect(markup).toContain('href="/agents/codexcoder/advanced"');
|
||||||
expect(markup).toContain('href="/agents/codexcoder/permissions"');
|
expect(markup).toContain('href="/agents/codexcoder/permissions"');
|
||||||
expect(markup).toContain('href="/agents/codexcoder/api-keys"');
|
expect(markup).toContain('href="/agents/codexcoder/api-keys"');
|
||||||
expect(markup).toContain('href="/activity?mode=agents&agentId=agent-1"');
|
expect(markup).toContain('href="/activity?mode=agents&agentId=agent-1"');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
|
FileCode2,
|
||||||
History,
|
History,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Library,
|
Library,
|
||||||
@@ -39,6 +40,7 @@ const localIcons = {
|
|||||||
permissions: ShieldCheck,
|
permissions: ShieldCheck,
|
||||||
"api-keys": KeyRound,
|
"api-keys": KeyRound,
|
||||||
revisions: History,
|
revisions: History,
|
||||||
|
advanced: FileCode2,
|
||||||
} satisfies Record<AgentLocalDetailView, typeof Sparkles>;
|
} satisfies Record<AgentLocalDetailView, typeof Sparkles>;
|
||||||
|
|
||||||
const auditItems = [
|
const auditItems = [
|
||||||
|
|||||||
@@ -3295,6 +3295,26 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||||||
── end allowlist ── */
|
── end allowlist ── */
|
||||||
|
|
||||||
/* Agent settings composition, shared with Storybook. */
|
/* Agent settings composition, shared with Storybook. */
|
||||||
|
.agent-yaml-editor .cm-editor {
|
||||||
|
height: var(--sz-480px);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.agent-yaml-editor .cm-scroller { overflow: auto; }
|
||||||
|
.agent-yaml-editor .cm-gutters {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.agent-yaml-editor .cm-activeLine,
|
||||||
|
.agent-yaml-editor .cm-activeLineGutter { background: var(--accent); }
|
||||||
|
.agent-yaml-editor .cm-cursor { border-left-color: var(--foreground); }
|
||||||
|
.agent-yaml-editor .cm-selectionBackground,
|
||||||
|
.agent-yaml-editor .cm-focused .cm-selectionBackground { background: var(--accent); }
|
||||||
|
.agent-yaml-editor .cm-focused { outline-color: var(--ring); }
|
||||||
|
|
||||||
.agent-settings-form > .relative {
|
.agent-settings-form > .relative {
|
||||||
@apply flex flex-col gap-8;
|
@apply flex flex-col gap-8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appearanceForPalette, type AgentConfigurationSource } from "@paperclipai/shared";
|
||||||
|
import { agentConfigurationPatch, parseAgentConfigurationYaml, serializeAgentConfiguration } from "./agent-configuration-yaml";
|
||||||
|
|
||||||
|
export const configuration: AgentConfigurationSource = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111", name: "Codie", role: "engineer", title: null,
|
||||||
|
icon: null, appearance: appearanceForPalette("bubblegum-sky"), reportsTo: null,
|
||||||
|
capabilities: "Write code\nReview changes\n", adapterType: "codex_local",
|
||||||
|
adapterConfig: { model: "gpt-5", env: { API_KEY: { type: "plain", value: "***REDACTED***" } }, extra: { enabled: true } },
|
||||||
|
runtimeConfig: { heartbeat: { enabled: false, intervalSec: 3600 }, custom: { nested: [1, "on", false] } },
|
||||||
|
defaultEnvironmentId: null, budgetMonthlyCents: 12000, metadata: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("agent configuration YAML", () => {
|
||||||
|
it("round trips the full configuration, multiline text, redactions and adapter extensions", () => {
|
||||||
|
const text = serializeAgentConfiguration(configuration);
|
||||||
|
expect(text).toContain("apiVersion: paperclip/v1");
|
||||||
|
expect(text).toContain("capabilities: |");
|
||||||
|
expect(agentConfigurationPatch(parseAgentConfigurationYaml(text), configuration)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends only changed fields and replaces the adapter map when a key is removed", () => {
|
||||||
|
const doc = parseAgentConfigurationYaml(serializeAgentConfiguration(configuration));
|
||||||
|
delete doc.spec.adapterConfig.extra;
|
||||||
|
doc.spec.budgetMonthlyCents = 20000;
|
||||||
|
expect(agentConfigurationPatch(doc, configuration)).toEqual({
|
||||||
|
adapterConfig: { model: "gpt-5", env: configuration.adapterConfig.env },
|
||||||
|
replaceAdapterConfig: true, budgetMonthlyCents: 20000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats formatting, comments and reordered maps as a no-op", () => {
|
||||||
|
const text = serializeAgentConfiguration({ ...configuration, adapterConfig: { extra: { enabled: true }, env: configuration.adapterConfig.env, model: "gpt-5" } });
|
||||||
|
expect(agentConfigurationPatch(parseAgentConfigurationYaml(`# comment\n${text}`), configuration)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["unknown version", (text: string) => text.replace("paperclip/v1", "paperclip/v2")],
|
||||||
|
["unknown field", (text: string) => `${text} typo: true\n`],
|
||||||
|
["missing field", (text: string) => text.replace(" name: Codie\n", "")],
|
||||||
|
["invalid budget", (text: string) => text.replace("budgetMonthlyCents: 12000", "budgetMonthlyCents: -1")],
|
||||||
|
["invalid environment", (text: string) => text.replace("defaultEnvironmentId: null", "defaultEnvironmentId: unknown")],
|
||||||
|
["duplicate key", (text: string) => `${text} name: duplicate\n`],
|
||||||
|
["second document", (text: string) => `${text}---\nkind: Agent\n`],
|
||||||
|
["alias", (text: string) => text.replace("metadata: null", "metadata: {a: &a [1], b: *a}")],
|
||||||
|
["NaN", (text: string) => text.replace("metadata: null", "metadata: {value: .nan}")],
|
||||||
|
["merge key", (text: string) => text.replace("metadata: null", "metadata: {<<: {name: Other}}")],
|
||||||
|
["reserved key", (text: string) => text.replace("metadata: null", "metadata: {__proto__: {bad: true}}")],
|
||||||
|
["permission mutation", (text: string) => `${text} permissions: {canCreateAgents: true}\n`],
|
||||||
|
["status mutation", (text: string) => `${text} status: idle\n`],
|
||||||
|
])("rejects %s before saving", (_name, modify) => {
|
||||||
|
expect(() => parseAgentConfigurationYaml(modify(serializeAgentConfiguration(configuration)))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { isAlias, parseDocument, stringify, visit } from "yaml";
|
||||||
|
import {
|
||||||
|
agentConfigurationDocumentSchema,
|
||||||
|
agentConfigurationSpec,
|
||||||
|
canonicalAgentConfiguration,
|
||||||
|
type AgentConfigurationDocument,
|
||||||
|
type AgentConfigurationSource,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export function serializeAgentConfiguration(agent: AgentConfigurationSource): string {
|
||||||
|
return serializeAgentConfigurationDocument({ apiVersion: "paperclip/v1", kind: "Agent", spec: agentConfigurationSpec(agent) } as AgentConfigurationDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAgentConfigurationDocument(document: AgentConfigurationDocument): string {
|
||||||
|
return stringify(document, {
|
||||||
|
lineWidth: 0, aliasDuplicateObjects: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertJson(value: unknown, depth = 0): void {
|
||||||
|
if (depth > 50) throw new Error("Configuration nesting must not exceed 50 levels.");
|
||||||
|
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return;
|
||||||
|
if (Array.isArray(value)) { value.forEach(item => assertJson(item, depth + 1)); return; }
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (["__proto__", "constructor", "prototype", "<<"].includes(key)) {
|
||||||
|
throw new Error(`Unsupported configuration key: ${key}`);
|
||||||
|
}
|
||||||
|
assertJson(item, depth + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Configuration must contain only JSON-compatible values (no NaN or infinity).");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAgentConfigurationYaml(source: string): AgentConfigurationDocument {
|
||||||
|
if (source.length > 8 * 1024 * 1024 || new TextEncoder().encode(source).byteLength > 8 * 1024 * 1024) throw new Error("Configuration must be smaller than 8 MiB.");
|
||||||
|
const document = parseDocument(source, { version: "1.2", schema: "core", uniqueKeys: true, stringKeys: true });
|
||||||
|
const problem = document.errors[0] ?? document.warnings[0];
|
||||||
|
if (problem) throw new Error(problem.message);
|
||||||
|
if (document.directives?.yaml.version !== "1.2") throw new Error("Use YAML 1.2.");
|
||||||
|
visit(document, (_key, node) => {
|
||||||
|
if (isAlias(node)) throw new Error("YAML aliases are not supported. Write each value explicitly.");
|
||||||
|
if (node && typeof node === "object" && "anchor" in node && node.anchor) throw new Error("YAML anchors are not supported.");
|
||||||
|
if (node && typeof node === "object" && "tag" in node && node.tag) throw new Error("YAML tags are not supported.");
|
||||||
|
});
|
||||||
|
const value: unknown = document.toJS({ maxAliasCount: 0 });
|
||||||
|
assertJson(value);
|
||||||
|
const parsed = agentConfigurationDocumentSchema.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(parsed.error.issues.map(issue => `${issue.path.join(".") || "document"}: ${issue.message}`).join("\n"));
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentConfigurationPatch(document: AgentConfigurationDocument, saved: AgentConfigurationSource) {
|
||||||
|
const before = agentConfigurationSpec(saved);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(before) as Array<keyof typeof before>) {
|
||||||
|
const value = document.spec[key];
|
||||||
|
if (canonicalAgentConfiguration(value) !== canonicalAgentConfiguration(before[key as keyof typeof before])) {
|
||||||
|
patch[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(patch, "adapterConfig")) patch.replaceAdapterConfig = true;
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { AgentConfigurationDocument } from "@paperclipai/shared";
|
||||||
|
import { chatEndpointsApi } from "../../api/chatEndpoints";
|
||||||
|
import { equal, type AgentConfigurationSnapshot } from "./load";
|
||||||
|
import { requireItem, requireExplicitFields, unique, type AddConfigurationStep } from "./steps";
|
||||||
|
|
||||||
|
export function planChannelChanges(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep) {
|
||||||
|
const { id: agentId, companyId } = snapshot.agent;
|
||||||
|
const before = snapshot.document.spec.channels!;
|
||||||
|
const next = document.spec.channels;
|
||||||
|
if (next) {
|
||||||
|
unique(next.map(row => row.id), "channels");
|
||||||
|
for (const row of next) {
|
||||||
|
const previous = requireItem(before, channel => channel.id === row.id, `Channel ${row.id}`);
|
||||||
|
if (row.provider !== previous.provider) throw new Error("A channel provider cannot be changed. Create a new channel instead.");
|
||||||
|
unique(row.resources.map(resource => resource.id), `channels.${row.id}.resources`);
|
||||||
|
for (const resource of row.resources) {
|
||||||
|
const existing = requireItem(previous.resources, value => value.id === resource.id, `Channel resource ${resource.id}`);
|
||||||
|
requireExplicitFields(existing, resource, `channels.${row.id}.resources.${resource.id}`);
|
||||||
|
}
|
||||||
|
if (!equal(previous.settings, row.settings)) {
|
||||||
|
requireExplicitFields(previous.settings, row.settings, `channels.${row.id}.settings`);
|
||||||
|
const changes = Object.fromEntries(Object.entries(row.settings).filter(([key, value]) =>
|
||||||
|
!equal(value, previous.settings[key as keyof typeof previous.settings])));
|
||||||
|
add(`Channel settings: ${row.id}`, () => chatEndpointsApi.update(row.id, changes));
|
||||||
|
}
|
||||||
|
if (!equal(previous.resources, row.resources)) {
|
||||||
|
const resources = previous.resources.map(resource => row.resources.find(value => value.id === resource.id) ?? { ...resource, enabled: false });
|
||||||
|
add(`Channel resources: ${row.id}`, () => chatEndpointsApi.updateResources(row.id, resources));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const previous of before) if (!next.some(row => row.id === previous.id)) {
|
||||||
|
add(`Remove channel: ${previous.id}`, () => chatEndpointsApi.setup(previous.id, { action: "remove" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const operations = document.spec.channelOperations;
|
||||||
|
if (!operations) return;
|
||||||
|
unique(operations.setup.map(row => row.id), "channelOperations.setup");
|
||||||
|
for (const input of operations.create) add(`Create ${input.provider} channel`, () => chatEndpointsApi.create(companyId, { ...input, assignedAgentId: agentId }));
|
||||||
|
for (const operation of operations.setup) {
|
||||||
|
const previous = requireItem(before, channel => channel.id === operation.id, `Channel ${operation.id}`);
|
||||||
|
if (operation.input.action === "remove" && next && !equal(next.find(row => row.id === operation.id), previous)) throw new Error(`Channel ${operation.id} cannot be edited and removed in the same save.`);
|
||||||
|
if (next && !next.some(row => row.id === operation.id)) throw new Error(`Channel ${operation.id} is both removed and configured.`);
|
||||||
|
add(`Channel ${operation.input.action}: ${operation.id}`, () => chatEndpointsApi.setup(operation.id, operation.input));
|
||||||
|
}
|
||||||
|
unique(operations.revokeIdentityLinks.map(row => `${row.endpointId}/${row.principalId}`), "channelOperations.revokeIdentityLinks");
|
||||||
|
for (const operation of operations.revokeIdentityLinks) {
|
||||||
|
if ((next && !next.some(row => row.id === operation.endpointId)) || operations.setup.some(row => row.id === operation.endpointId && row.input.action === "remove")) throw new Error(`Channel ${operation.endpointId} cannot be removed and have its identities edited in the same save.`);
|
||||||
|
const channel = requireItem(snapshot.channels, row => row.detail.id === operation.endpointId, `Channel ${operation.endpointId}`);
|
||||||
|
requireItem(channel.principals, row => row.principalId === operation.principalId, `Identity ${operation.principalId}`);
|
||||||
|
add(`Revoke channel identity: ${operation.principalId}`, () => chatEndpointsApi.revokeLink(operation.endpointId, operation.principalId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
agentConfigurationSpec, canonicalAgentConfiguration,
|
||||||
|
type AgentConfigurationDocument, type AgentInstructionsFileDetail, type ToolProfileWithDetails, type ToolPolicy,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { agentsApi } from "../../api/agents";
|
||||||
|
import { toolsApi } from "../../api/tools";
|
||||||
|
import { chatEndpointsApi } from "../../api/chatEndpoints";
|
||||||
|
import { secretsApi } from "../../api/secrets";
|
||||||
|
|
||||||
|
export const ownedAdapterKeys = ["paperclipSkillSync", "instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"] as const;
|
||||||
|
export const equal = (a: unknown, b: unknown) => canonicalAgentConfiguration(a) === canonicalAgentConfiguration(b);
|
||||||
|
export const jsonValue = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
export async function mapBounded<T, R>(items: T[], read: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
await Promise.all(Array.from({ length: Math.min(items.length, 4) }, async () => {
|
||||||
|
while (next < items.length) { const index = next++; results[index] = await read(items[index]); }
|
||||||
|
}));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileConfiguration(profile: ToolProfileWithDetails) {
|
||||||
|
return {
|
||||||
|
profileKey: profile.profileKey, name: profile.name, description: profile.description,
|
||||||
|
status: profile.status, defaultAction: profile.defaultAction, metadata: profile.metadata,
|
||||||
|
entries: profile.entries.map(({ selectorType, effect, applicationId, connectionId, catalogEntryId, toolName, riskLevel, conditions }) =>
|
||||||
|
({ selectorType, effect, applicationId, connectionId, catalogEntryId, toolName, riskLevel, conditions })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function policyConfiguration(policy: ToolPolicy) {
|
||||||
|
const { name, description, policyType, priority, enabled, selectors, conditions, config } = policy;
|
||||||
|
return { name, description, policyType, priority, enabled, selectors, conditions, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function policyAgentIds(policy: Pick<ToolPolicy, "selectors">): string[] {
|
||||||
|
return [policy.selectors.agentId, policy.selectors.agentIds].flatMap(value =>
|
||||||
|
Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : typeof value === "string" ? [value] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAgentConfiguration(id: string, companyId: string) {
|
||||||
|
const core = await agentsApi.getConfiguration(id, companyId);
|
||||||
|
const [agent, bundle, skills, connections, profiles, policies, endpoints, keys, revisions, secrets, proposals] = await Promise.all([
|
||||||
|
agentsApi.get(id, companyId), agentsApi.instructionsBundle(id, companyId), agentsApi.skills(id, companyId),
|
||||||
|
toolsApi.listConnections(companyId), toolsApi.listProfiles(companyId), toolsApi.listPolicies(companyId),
|
||||||
|
chatEndpointsApi.list(companyId), agentsApi.listKeys(id, companyId), agentsApi.listConfigRevisions(id, companyId),
|
||||||
|
secretsApi.list(companyId), secretsApi.listProposals(companyId),
|
||||||
|
]);
|
||||||
|
const [files, channels] = await Promise.all([
|
||||||
|
mapBounded(bundle.files.filter(file => file.editable && !file.binary && !file.virtual), file => agentsApi.instructionsFile(id, file.path, companyId)),
|
||||||
|
mapBounded(endpoints.filter(endpoint => endpoint.assignedAgentId === id && endpoint.status !== "archived"), async endpoint => {
|
||||||
|
const [detail, resources, principals] = await Promise.all([
|
||||||
|
chatEndpointsApi.get(endpoint.id), chatEndpointsApi.listResources(endpoint.id), chatEndpointsApi.listPrincipals(endpoint.id),
|
||||||
|
]);
|
||||||
|
if (detail.assignedAgentId !== id || detail.companyId !== companyId) throw new Error("Channel ownership changed. Reload configuration.");
|
||||||
|
return { detail, resources, principals };
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const adapterConfig = { ...core.adapterConfig };
|
||||||
|
for (const key of ownedAdapterKeys) delete adapterConfig[key];
|
||||||
|
const directPolicies = policies.policies.filter(policy => {
|
||||||
|
const ids = policyAgentIds(policy); return ids.length > 0 && ids.every(value => value === id);
|
||||||
|
});
|
||||||
|
const directProfiles = profiles.profiles.flatMap(profile => {
|
||||||
|
const binding = profile.bindings.find(binding => binding.targetType === "agent" && binding.targetId === id);
|
||||||
|
return binding ? [{ profileId: profile.id, updateSharedDefinition: false, priority: binding.priority, metadata: binding.metadata, configuration: profileConfiguration(profile) }] : [];
|
||||||
|
});
|
||||||
|
const document = jsonValue({
|
||||||
|
apiVersion: "paperclip/v1", kind: "Agent",
|
||||||
|
spec: {
|
||||||
|
...agentConfigurationSpec(core), adapterConfig,
|
||||||
|
permissions: {
|
||||||
|
canCreateAgents: Boolean(agent.permissions.canCreateAgents), canCreateSkills: agent.permissions.canCreateSkills !== false,
|
||||||
|
canAssignTasks: Boolean(agent.access?.canAssignTasks), trustPreset: agent.permissions.trustPreset ?? "standard",
|
||||||
|
...(agent.permissions.authorizationPolicy ? { authorizationPolicy: agent.permissions.authorizationPolicy } : {}),
|
||||||
|
},
|
||||||
|
instructions: { mode: bundle.mode, rootPath: bundle.rootPath, entryFile: bundle.entryFile,
|
||||||
|
files: Object.fromEntries(files.map(file => [file.path, file.content])) },
|
||||||
|
skills: skills.desiredSkillEntries ?? skills.desiredSkills.map(key => ({ key, versionId: null })),
|
||||||
|
tools: {
|
||||||
|
connections: connections.connections.map(connection => ({ connectionId: connection.id,
|
||||||
|
installed: (connection.installs ?? []).some(install => install.targetType === "agent" && install.targetId === id) })),
|
||||||
|
profiles: directProfiles,
|
||||||
|
policies: directPolicies.map(policy => ({ id: policy.id, configuration: policyConfiguration(policy) })),
|
||||||
|
},
|
||||||
|
channels: channels.map(({ detail, resources }) => ({ id: detail.id, provider: detail.provider, settings: {
|
||||||
|
executionDefaults: detail.executionDefaults ?? null,
|
||||||
|
...(detail.provider === "slack" ? { communicationInstructions: detail.communicationInstructions ?? "" } : {}),
|
||||||
|
allowUnlinkedPeople: detail.allowUnlinkedPeople,
|
||||||
|
...(detail.allowDirectMessages !== undefined ? { allowDirectMessages: detail.allowDirectMessages } : {}),
|
||||||
|
...(detail.provider === "slack" && detail.requireAtMention !== undefined ? { requireAtMention: detail.requireAtMention } : {}),
|
||||||
|
...(detail.allowGroupChats !== undefined ? { allowGroupChats: detail.allowGroupChats } : {}),
|
||||||
|
...(detail.setup?.slackApp ? { slackApp: detail.setup.slackApp } : {}),
|
||||||
|
...(detail.setup?.slackSetupMethod ? { slackSetupMethod: detail.setup.slackSetupMethod } : {}),
|
||||||
|
}, resources: resources.map(resource => ({ id: resource.id, enabled: resource.enabled, executionDefaults: resource.executionDefaults ?? null })) })),
|
||||||
|
apiKeys: { create: [], revoke: [] }, revisions: { restore: null },
|
||||||
|
secrets: { create: [], rotate: [], update: [], proposals: [] },
|
||||||
|
channelOperations: { create: [], setup: [], revokeIdentityLinks: [] },
|
||||||
|
},
|
||||||
|
observed: {
|
||||||
|
apiKeys: keys, revisions: revisions.map(({ id, source, changedKeys, createdAt }) => ({ id, source, changedKeys, createdAt })),
|
||||||
|
instructionFiles: bundle.files.filter(file => !file.editable || file.binary || file.virtual), instructionWarnings: bundle.warnings,
|
||||||
|
skillWarnings: skills.warnings,
|
||||||
|
connections: connections.connections.map(connection => ({ id: connection.id, name: connection.name,
|
||||||
|
inherited: (connection.installs ?? []).some(install => install.targetType === "company" && install.targetId === companyId) })),
|
||||||
|
availableProfiles: profiles.profiles.map(profile => ({ id: profile.id, configuration: profileConfiguration(profile) })),
|
||||||
|
inheritedProfiles: profiles.profiles.filter(profile => profile.bindings.some(binding => binding.targetType === "company" && binding.targetId === companyId)).map(profile => ({ id: profile.id, configuration: profileConfiguration(profile) })),
|
||||||
|
inheritedPolicies: policies.policies.filter(policy => !directPolicies.includes(policy) && (policyAgentIds(policy).length === 0 || policyAgentIds(policy).includes(id))).map(policy => ({ id: policy.id, configuration: policyConfiguration(policy) })),
|
||||||
|
channels: channels.map(({ detail, resources, principals }) => ({ id: detail.id, status: detail.status, botLabel: detail.botLabel, resources, principals })),
|
||||||
|
secrets: secrets.map(({ id, name, key, provider, latestVersion }) => ({ id, name, key, provider, latestVersion })),
|
||||||
|
secretProposals: proposals.filter(proposal => (proposal.target?.id === id || proposal.proposedBy.id === id)),
|
||||||
|
},
|
||||||
|
}) as AgentConfigurationDocument;
|
||||||
|
return { agent, core, bundle, files: new Map<string, AgentInstructionsFileDetail>(files.map(file => [file.path, file])),
|
||||||
|
connections: connections.connections, profiles: profiles.profiles, policies: policies.policies, channels, keys, revisions, secrets, proposals, document };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentConfigurationSnapshot = Awaited<ReturnType<typeof loadAgentConfiguration>>;
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { appearanceForPalette, type AgentConfigurationDocument } from "@paperclipai/shared";
|
||||||
|
import { parseAgentConfigurationYaml, serializeAgentConfigurationDocument } from "../agent-configuration-yaml";
|
||||||
|
import { loadAgentConfiguration } from "./load";
|
||||||
|
import { ConfigurationSaveInterrupted, planAgentConfiguration, saveAgentConfiguration } from "./save";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
agents: Object.fromEntries(["getConfiguration", "get", "instructionsBundle", "instructionsFile", "skills", "listKeys", "listConfigRevisions", "update", "updateInstructionsBundle", "saveInstructionsFile", "deleteInstructionsFile", "syncSkills", "updatePermissions", "createKey", "revokeKey", "rollbackConfigRevision"].map(key => [key, vi.fn()])),
|
||||||
|
tools: Object.fromEntries(["listConnections", "listProfiles", "listPolicies", "getConnectionInstalls", "putConnectionInstalls", "createProfile", "updateProfile", "bindProfile", "unbindProfile", "createPolicy", "updatePolicy", "deletePolicy"].map(key => [key, vi.fn()])),
|
||||||
|
channels: Object.fromEntries(["list", "get", "listResources", "listPrincipals", "update", "updateResources", "create", "setup", "revokeLink"].map(key => [key, vi.fn()])),
|
||||||
|
secrets: Object.fromEntries(["list", "listProposals", "create", "rotate", "update", "approveProposal", "rejectProposal"].map(key => [key, vi.fn()])),
|
||||||
|
}));
|
||||||
|
vi.mock("../../api/agents", () => ({ agentsApi: api.agents }));
|
||||||
|
vi.mock("../../api/tools", () => ({ toolsApi: api.tools }));
|
||||||
|
vi.mock("../../api/chatEndpoints", () => ({ chatEndpointsApi: api.channels }));
|
||||||
|
vi.mock("../../api/secrets", () => ({ secretsApi: api.secrets }));
|
||||||
|
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const resourceId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const agent = {
|
||||||
|
id: agentId, companyId: "company", name: "Codie", role: "engineer", title: null, icon: null,
|
||||||
|
appearance: appearanceForPalette("bubblegum-sky"), reportsTo: null, capabilities: null,
|
||||||
|
adapterType: "process", adapterConfig: { command: "echo", instructionsBundleMode: "managed", paperclipSkillSync: { desiredSkills: ["paperclip"] } },
|
||||||
|
runtimeConfig: {}, defaultEnvironmentId: null, budgetMonthlyCents: 0, metadata: null,
|
||||||
|
permissions: { canCreateAgents: false, canCreateSkills: true }, access: { canAssignTasks: false },
|
||||||
|
configurationRevision: "a".repeat(64),
|
||||||
|
};
|
||||||
|
const file = { path: "AGENTS.md", content: "# Codie\n", contentHash: "hash", revision: { id: "revision-file" }, editable: true, binary: false };
|
||||||
|
const installs = [{ targetType: "agent", targetId: agentId }, { targetType: "agent", targetId: "another-agent" }];
|
||||||
|
const connection = { id: "connection", name: "GitHub", installs };
|
||||||
|
const channel = { id: "channel", companyId: "company", assignedAgentId: agentId, provider: "slack", status: "active", allowUnlinkedPeople: false };
|
||||||
|
const progress = vi.fn(), receipt = vi.fn();
|
||||||
|
const copy = <T>(value: T): T => structuredClone(value);
|
||||||
|
async function snapshot() { return loadAgentConfiguration(agentId, "company"); }
|
||||||
|
function roundTrip(document: AgentConfigurationDocument) { return parseAgentConfigurationYaml(serializeAgentConfigurationDocument(document)); }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
api.agents.getConfiguration.mockResolvedValue(copy(agent)); api.agents.get.mockResolvedValue(copy(agent));
|
||||||
|
api.agents.instructionsBundle.mockResolvedValue({ mode: "managed", rootPath: "/managed/codie", entryFile: "AGENTS.md", editable: true, files: [file], warnings: [] });
|
||||||
|
api.agents.instructionsFile.mockResolvedValue(file);
|
||||||
|
api.agents.skills.mockResolvedValue({ desiredSkills: ["paperclip"], desiredSkillEntries: [{ key: "paperclip", versionId: resourceId }], warnings: [] });
|
||||||
|
api.agents.listKeys.mockResolvedValue([{ id: "key", name: "Previous", scope: { kind: "standard" }, revokedAt: null }]);
|
||||||
|
api.agents.listConfigRevisions.mockResolvedValue([{ id: "revision", source: "board", changedKeys: ["name"] }]);
|
||||||
|
api.tools.listConnections.mockResolvedValue({ connections: [connection] });
|
||||||
|
api.tools.listProfiles.mockResolvedValue({ profiles: [] }); api.tools.listPolicies.mockResolvedValue({ policies: [] });
|
||||||
|
api.tools.getConnectionInstalls.mockResolvedValue({ installs });
|
||||||
|
api.tools.createProfile.mockResolvedValue({ id: "new-profile" });
|
||||||
|
api.channels.list.mockResolvedValue([channel]); api.channels.get.mockResolvedValue(channel);
|
||||||
|
api.channels.listResources.mockResolvedValue([{ id: resourceId, enabled: true }]); api.channels.listPrincipals.mockResolvedValue([{ principalId: "principal" }]);
|
||||||
|
api.secrets.list.mockResolvedValue([{ id: "secret", name: "Token", provider: "local_encrypted", latestVersion: 1 }]);
|
||||||
|
api.secrets.listProposals.mockResolvedValue([{ id: "proposal", target: { id: agentId }, proposedBy: { id: agentId } }]);
|
||||||
|
api.agents.createKey.mockResolvedValue({ id: "new-key", name: "Automation", token: "one-time-token" });
|
||||||
|
api.secrets.create.mockResolvedValue({ id: "new-secret", name: "Token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("complete agent configuration", () => {
|
||||||
|
it("loads every resource section and round trips to a no-op without credential values", async () => {
|
||||||
|
const saved = await snapshot();
|
||||||
|
const doc = roundTrip(saved.document);
|
||||||
|
expect(doc.spec.instructions?.files["AGENTS.md"]).toBe(file.content);
|
||||||
|
expect(doc.spec.skills).toEqual([{ key: "paperclip", versionId: resourceId }]);
|
||||||
|
expect(doc.spec.adapterConfig).toEqual({ command: "echo" });
|
||||||
|
expect(doc.observed?.secretProposals).toHaveLength(1);
|
||||||
|
expect(planAgentConfiguration(saved, doc)).toEqual([]);
|
||||||
|
await saveAgentConfiguration(saved, doc, receipt, progress);
|
||||||
|
expect(api.agents.getConfiguration).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips existing profiles and agent policies without rewriting shared definitions", async () => {
|
||||||
|
api.tools.listProfiles.mockResolvedValue({ profiles: [{ id: "profile", profileKey: "read", name: "Read", description: null,
|
||||||
|
status: "active", defaultAction: "deny", metadata: null,
|
||||||
|
bindings: [{ targetType: "agent", targetId: agentId, priority: 100, metadata: null }],
|
||||||
|
entries: [{ selectorType: "tool_name", effect: "include", applicationId: null, connectionId: null,
|
||||||
|
catalogEntryId: null, toolName: "get_repository", riskLevel: null, conditions: null }],
|
||||||
|
}] });
|
||||||
|
api.tools.listPolicies.mockResolvedValue({ policies: [{ id: "policy", name: "Read", description: null, policyType: "allow",
|
||||||
|
priority: 100, enabled: true, selectors: { agentIds: [agentId] }, conditions: null, config: {} }] });
|
||||||
|
const saved = await snapshot();
|
||||||
|
expect(planAgentConfiguration(saved, roundTrip(saved.document))).toEqual([]);
|
||||||
|
const doc = copy(saved.document); doc.spec.tools!.profiles = []; doc.spec.tools!.policies = [];
|
||||||
|
await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
|
||||||
|
expect(api.tools.unbindProfile).toHaveBeenCalledWith("company", "profile", { targetType: "agent", targetId: agentId });
|
||||||
|
expect(api.tools.deletePolicy).toHaveBeenCalledWith("company", "policy");
|
||||||
|
expect(api.tools.updateProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches instruction locations without overwriting files at the destination", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.instructions!.rootPath = "/workspace/new"; doc.spec.instructions!.mode = "external";
|
||||||
|
api.agents.updateInstructionsBundle.mockResolvedValue({ files: [file] });
|
||||||
|
api.agents.instructionsFile.mockResolvedValueOnce(file).mockResolvedValueOnce({ ...file, content: "Destination instructions" });
|
||||||
|
const result = await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
|
||||||
|
expect(api.agents.updateInstructionsBundle).toHaveBeenCalledWith(agentId, { mode: "external", rootPath: "/workspace/new", entryFile: "AGENTS.md" }, "company");
|
||||||
|
expect(api.agents.saveInstructionsFile).not.toHaveBeenCalled();
|
||||||
|
expect(api.agents.deleteInstructionsFile).not.toHaveBeenCalled();
|
||||||
|
expect(result.document.spec.instructions!.files["AGENTS.md"]).toBe("Destination instructions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a location change combined with file edits before applying an unrelated core edit", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.title = "Engineer"; doc.spec.instructions!.entryFile = "NEW.md";
|
||||||
|
doc.spec.instructions!.files["NEW.md"] = "New instructions";
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("location or entry file first");
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled();
|
||||||
|
expect(api.agents.updateInstructionsBundle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ignored managed-root changes and impossible task-assignment permissions", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.instructions!.rootPath = "/ignored/root";
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("assigned by Paperclip");
|
||||||
|
doc.spec.instructions = saved.document.spec.instructions;
|
||||||
|
doc.spec.permissions!.canCreateAgents = true;
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("task assignment authority");
|
||||||
|
expect(api.agents.updatePermissions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a secret and binds its returned ID without exporting the value", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.secrets!.create = [{ secret: { name: "Token", value: "write-only-value", provider: "local_encrypted" }, bind: { env: ["TOKEN"], api: ["SERVICE"] } }];
|
||||||
|
const result = await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
|
||||||
|
expect(api.secrets.create).toHaveBeenCalledWith("company", expect.objectContaining({ value: "write-only-value" }));
|
||||||
|
expect(api.agents.update).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
||||||
|
adapterConfig: expect.objectContaining({ env: { TOKEN: { type: "secret_ref", secretId: "new-secret", version: "latest" } },
|
||||||
|
"access.SERVICE": { type: "secret_ref", secretId: "new-secret", version: "latest" } }),
|
||||||
|
expectedConfigurationRevision: agent.configurationRevision,
|
||||||
|
}), "company");
|
||||||
|
expect(JSON.stringify(result.document)).not.toContain("write-only-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects nested schema typos and unsupported channel creation before writes", async () => {
|
||||||
|
const saved = await snapshot();
|
||||||
|
const doc = copy(saved.document);
|
||||||
|
Object.assign(doc.spec.skills![0] as object, { typo: true });
|
||||||
|
expect(() => roundTrip(doc)).toThrow("spec.skills.0");
|
||||||
|
doc.spec.skills = saved.document.spec.skills;
|
||||||
|
doc.spec.channelOperations!.create = [{ provider: "telegram", slackSetupMethod: "manual" }];
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("Slack app details");
|
||||||
|
expect(api.channels.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves each domain through its owning API and preserves other agents' installations", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.title = "Engineer";
|
||||||
|
doc.spec.instructions!.files["AGENTS.md"] = "# Revised\n";
|
||||||
|
doc.spec.skills = [];
|
||||||
|
doc.spec.permissions!.canCreateSkills = false;
|
||||||
|
doc.spec.tools!.connections[0].installed = false;
|
||||||
|
doc.spec.channels![0].settings.allowUnlinkedPeople = true;
|
||||||
|
doc.spec.channels![0].resources = [];
|
||||||
|
doc.spec.apiKeys = { create: [{ name: "Automation", scope: { kind: "standard" } }], revoke: ["key"] };
|
||||||
|
doc.spec.channelOperations!.revokeIdentityLinks = [{ endpointId: "channel", principalId: "principal" }];
|
||||||
|
doc.spec.secrets!.rotate = [{ id: "secret", input: { value: "replacement" } }];
|
||||||
|
doc.spec.secrets!.proposals = [{ id: "proposal", decision: "reject", reason: "Wrong account" }];
|
||||||
|
await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
|
||||||
|
expect(api.agents.update).toHaveBeenCalledExactlyOnceWith(agentId, { title: "Engineer", expectedConfigurationRevision: agent.configurationRevision }, "company");
|
||||||
|
expect(api.agents.saveInstructionsFile).toHaveBeenCalledWith(agentId, { path: "AGENTS.md", content: "# Revised\n", baseHash: "hash", baseRevisionId: "revision-file" }, "company");
|
||||||
|
expect(api.agents.syncSkills).toHaveBeenCalledWith(agentId, [], "replace", "company");
|
||||||
|
expect(api.tools.putConnectionInstalls).toHaveBeenCalledWith("connection", [{ targetType: "agent", targetId: "another-agent" }]);
|
||||||
|
expect(api.channels.update).toHaveBeenCalledExactlyOnceWith("channel", { allowUnlinkedPeople: true });
|
||||||
|
expect(api.channels.updateResources).toHaveBeenCalledWith("channel", [{ id: resourceId, enabled: false, executionDefaults: null }]);
|
||||||
|
expect(api.channels.revokeLink).toHaveBeenCalledWith("channel", "principal");
|
||||||
|
expect(api.agents.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ canCreateSkills: false }), "company");
|
||||||
|
expect(api.secrets.rotate).toHaveBeenCalledWith("secret", { value: "replacement" });
|
||||||
|
expect(api.secrets.rejectProposal).toHaveBeenCalledWith("company", "proposal", { reason: "Wrong account" });
|
||||||
|
expect(api.agents.revokeKey).toHaveBeenCalledWith(agentId, "key", "company");
|
||||||
|
expect(receipt).toHaveBeenCalledWith({ label: "API key: Automation", id: "new-key", token: "one-time-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires explicit clears for nested PATCH fields", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.title = "Engineer";
|
||||||
|
delete doc.spec.channels![0].resources[0].enabled;
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow(".enabled explicit");
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires explicit consent for shared profile changes and still supports binding without changing them", async () => {
|
||||||
|
const profile = { id: "profile", profileKey: "shared", name: "Shared", description: null,
|
||||||
|
status: "active", defaultAction: "deny", metadata: null, entries: [],
|
||||||
|
bindings: [{ targetType: "agent", targetId: "another-agent", priority: 100, metadata: null }] };
|
||||||
|
api.tools.listProfiles.mockResolvedValue({ profiles: [profile] });
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
const configuration = { profileKey: "shared", name: "Shared", description: null, status: "active" as const, defaultAction: "deny" as const, metadata: null, entries: [] };
|
||||||
|
doc.spec.tools!.profiles = [{ profileId: "profile", priority: 100, metadata: null, configuration }];
|
||||||
|
await saveAgentConfiguration(saved, doc, receipt, progress);
|
||||||
|
expect(api.tools.bindProfile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.tools.updateProfile).not.toHaveBeenCalled();
|
||||||
|
configuration.name = "Changed for everyone";
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("updateSharedDefinition: true");
|
||||||
|
doc.spec.tools!.profiles[0].updateSharedDefinition = true;
|
||||||
|
await saveAgentConfiguration(saved, doc, receipt, progress);
|
||||||
|
expect(api.tools.updateProfile).toHaveBeenCalledWith("profile", configuration);
|
||||||
|
delete (configuration as Partial<typeof configuration>).description;
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow(".description explicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a newly selected profile whose definition changed after the editor loaded", async () => {
|
||||||
|
const profile = { id: "profile", profileKey: "unbound", name: "Unbound", description: null,
|
||||||
|
status: "active", defaultAction: "deny", metadata: null, entries: [], bindings: [] };
|
||||||
|
api.tools.listProfiles.mockResolvedValueOnce({ profiles: [profile] });
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.tools!.profiles = [{ profileId: "profile", priority: 100, metadata: null, configuration:
|
||||||
|
{ profileKey: "unbound", name: "Unbound", description: null, status: "active", defaultAction: "deny", metadata: null, entries: [] } }];
|
||||||
|
api.tools.listProfiles.mockResolvedValue({ profiles: [{ ...profile, name: "Changed elsewhere" }] });
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("Tool profile profile changed");
|
||||||
|
expect(api.tools.updateProfile).not.toHaveBeenCalled();
|
||||||
|
expect(api.tools.bindProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects removing a channel while editing its settings or identity links", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.channels![0].settings.communicationInstructions = "New instructions";
|
||||||
|
doc.spec.channelOperations!.setup = [{ id: "channel", input: { action: "remove" } }];
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("edited and removed");
|
||||||
|
doc.spec.channels = []; doc.spec.channelOperations!.setup = [];
|
||||||
|
doc.spec.channelOperations!.revokeIdentityLinks = [{ endpointId: "channel", principalId: "principal" }];
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("removed and have its identities edited");
|
||||||
|
expect(api.channels.setup).not.toHaveBeenCalled();
|
||||||
|
expect(api.channels.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects overlapping secret bindings and preserves a concurrently edited binding", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
const create = { secret: { name: "Token", value: "new-value", provider: "local_encrypted" as const }, bind: { env: ["TOKEN"], api: [] } };
|
||||||
|
doc.spec.secrets!.create = [create, { ...create, secret: { ...create.secret, name: "Other" } }];
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("bind.env contains duplicate");
|
||||||
|
doc.spec.secrets!.create = [create]; doc.spec.adapterConfig.env = { TOKEN: "competing-value" };
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("not both");
|
||||||
|
delete doc.spec.adapterConfig.env;
|
||||||
|
api.agents.getConfiguration.mockResolvedValueOnce(agent).mockResolvedValueOnce({ ...agent, adapterConfig: { ...agent.adapterConfig, env: { TOKEN: "someone-else" } } });
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("TOKEN changed");
|
||||||
|
expect(api.secrets.create).not.toHaveBeenCalled();
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains normalization warnings returned by writes after refreshing the saved snapshot", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document); doc.spec.title = "Engineer";
|
||||||
|
const warning = { code: "runner_option_ignored", field: "chrome", message: "Chrome is ignored" };
|
||||||
|
api.agents.update.mockResolvedValue({ ...agent, configurationWarnings: [warning] });
|
||||||
|
const result = await saveAgentConfiguration(saved, doc, receipt, progress);
|
||||||
|
expect(result.agent.configurationWarnings).toEqual([warning]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the entire plan before making any writes", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.title = "Engineer"; doc.spec.apiKeys!.revoke = ["foreign-key"];
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("foreign-key");
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled(); expect(api.agents.getConfiguration).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale core, file content and edited channel settings before writes", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document); doc.spec.title = "Engineer";
|
||||||
|
api.agents.getConfiguration.mockResolvedValueOnce({ ...agent, configurationRevision: "b".repeat(64) });
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("configuration changed");
|
||||||
|
doc.spec.instructions!.files["AGENTS.md"] = "My change";
|
||||||
|
api.agents.instructionsFile.mockResolvedValueOnce({ ...file, content: "Someone else's change" });
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("instructions changed");
|
||||||
|
delete doc.spec.instructions;
|
||||||
|
doc.spec.channels![0].settings.communicationInstructions = "Be concise";
|
||||||
|
api.channels.get.mockResolvedValueOnce({ ...channel, allowUnlinkedPeople: true });
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("channels changed");
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not undo a concurrent change to an unedited resource section", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document); doc.spec.title = "Engineer";
|
||||||
|
api.channels.get.mockResolvedValue({ ...channel, allowUnlinkedPeople: true });
|
||||||
|
await saveAgentConfiguration(saved, doc, receipt, progress);
|
||||||
|
expect(api.agents.update).toHaveBeenCalledTimes(1); expect(api.channels.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports partial progress without retrying or losing a newly created key", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.title = "Engineer";
|
||||||
|
doc.spec.apiKeys!.create = [{ name: "Automation", scope: { kind: "standard" } }, { name: "Second", scope: { kind: "standard" } }];
|
||||||
|
api.agents.createKey.mockResolvedValueOnce({ id: "new-key", name: "Automation", token: "one-time-token" }).mockRejectedValueOnce(new Error("Network lost"));
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow(ConfigurationSaveInterrupted);
|
||||||
|
expect(receipt).toHaveBeenCalledWith(expect.objectContaining({ token: "one-time-token" }));
|
||||||
|
expect(api.agents.update).toHaveBeenCalledTimes(1); expect(api.agents.createKey).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires revision restore to be the only change and never replays history", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.revisions!.restore = "revision"; doc.spec.title = "Engineer";
|
||||||
|
await expect(saveAgentConfiguration(saved, doc, receipt, progress)).rejects.toThrow("own save");
|
||||||
|
doc.spec.title = null;
|
||||||
|
await saveAgentConfiguration(saved, doc, receipt, progress);
|
||||||
|
expect(api.agents.rollbackConfigRevision).toHaveBeenCalledWith(agentId, "revision", "company");
|
||||||
|
expect(api.agents.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects edits to observed context and changes to organization-wide installs", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.observed!.apiKeys = [];
|
||||||
|
expect(() => planAgentConfiguration(saved, doc)).toThrow("observed is read-only");
|
||||||
|
doc.observed = saved.document.observed;
|
||||||
|
saved.connections[0].installs = [{ targetType: "company", targetId: "company" }] as typeof saved.connections[0]["installs"];
|
||||||
|
doc.spec.tools!.connections[0].installed = false;
|
||||||
|
expect(() => planAgentConfiguration(saved, doc)).toThrow("organization-wide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates profiles, scoped policies and channel setup using native contracts", async () => {
|
||||||
|
const saved = await snapshot(); const doc = copy(saved.document);
|
||||||
|
doc.spec.tools!.profiles = [{ profileId: null, priority: 10, metadata: null, configuration: { name: "Read", profileKey: "read", status: "active", defaultAction: "deny", entries: [] } }];
|
||||||
|
doc.spec.tools!.policies = [{ id: null, configuration: { name: "Safe", policyType: "allow", priority: 10, enabled: true, selectors: {}, conditions: null, config: {} } }];
|
||||||
|
doc.spec.channelOperations!.setup = [{ id: "channel", input: { action: "pause" } }];
|
||||||
|
await saveAgentConfiguration(saved, roundTrip(doc), receipt, progress);
|
||||||
|
expect(api.tools.createProfile).toHaveBeenCalledWith("company", expect.objectContaining({ profileKey: "read" }));
|
||||||
|
expect(api.tools.bindProfile).toHaveBeenCalledWith("company", "new-profile", { targetType: "agent", targetId: agentId, priority: 10, metadata: null });
|
||||||
|
expect(api.tools.createPolicy).toHaveBeenCalledWith("company", expect.objectContaining({ selectors: { agentIds: [agentId] } }));
|
||||||
|
expect(api.channels.setup).toHaveBeenCalledWith("channel", { action: "pause" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { agentConfigurationDocumentSchema, type AgentConfigurationDocument, type AgentConfigurationWarning } from "@paperclipai/shared";
|
||||||
|
import { agentsApi } from "../../api/agents";
|
||||||
|
import { secretsApi } from "../../api/secrets";
|
||||||
|
import { agentConfigurationPatch } from "../agent-configuration-yaml";
|
||||||
|
import { equal, loadAgentConfiguration, ownedAdapterKeys, type AgentConfigurationSnapshot } from "./load";
|
||||||
|
import { planToolChanges } from "./tools";
|
||||||
|
import { planChannelChanges } from "./channels";
|
||||||
|
import { requireItem, unique, type ConfigurationStep, type AddConfigurationStep } from "./steps";
|
||||||
|
|
||||||
|
export interface ConfigurationReceipt { label: string; id: string; token?: string }
|
||||||
|
export class ConfigurationSaveInterrupted extends Error {
|
||||||
|
constructor(label: string, completed: string[], cause: unknown) {
|
||||||
|
super(`${completed.length ? `Saved: ${completed.join(", ")}. ` : ""}Could not complete ${label}: ${cause instanceof Error ? cause.message : "Request failed"}. Some changes may have been saved. Reload saved before saving again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function planInstructions(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep) {
|
||||||
|
const next = document.spec.instructions;
|
||||||
|
if (!next) return;
|
||||||
|
const previous = snapshot.document.spec.instructions!;
|
||||||
|
if (equal(previous, next)) return;
|
||||||
|
if (!snapshot.bundle.editable && !next.mode) throw new Error("Choose managed or external instructions mode before adding files.");
|
||||||
|
if (next.mode === null && previous.mode !== null) throw new Error("Choose managed or external instructions mode.");
|
||||||
|
if (next.mode === "external" && !next.rootPath?.startsWith("/")) throw new Error("External instructions require an absolute rootPath.");
|
||||||
|
if (Object.hasOwn(previous.files, next.entryFile) && !Object.hasOwn(next.files, next.entryFile)) throw new Error("The instructions entry file cannot be removed.");
|
||||||
|
for (const path of Object.keys(next.files)) {
|
||||||
|
const existing = snapshot.bundle.files.find(file => file.path === path);
|
||||||
|
if (existing && (!existing.editable || existing.binary || existing.virtual)) throw new Error(`Instruction file ${path} cannot be edited as text.`);
|
||||||
|
}
|
||||||
|
const bundleChanged = previous.mode !== next.mode || previous.rootPath !== next.rootPath || previous.entryFile !== next.entryFile;
|
||||||
|
if (bundleChanged) {
|
||||||
|
if (!equal(previous.files, next.files)) throw new Error("Save the instructions location or entry file first, then edit the files loaded from that location.");
|
||||||
|
if (next.mode === "managed" && previous.mode === "managed" && next.rootPath !== previous.rootPath) {
|
||||||
|
throw new Error("The managed instructions root is assigned by Paperclip. Choose external mode to use another folder.");
|
||||||
|
}
|
||||||
|
add("Instructions location (load the destination files)", () => agentsApi.updateInstructionsBundle(snapshot.agent.id, {
|
||||||
|
...(next.mode ? { mode: next.mode } : {}), rootPath: next.rootPath, entryFile: next.entryFile,
|
||||||
|
}, snapshot.agent.companyId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentFiles = snapshot.files;
|
||||||
|
for (const [path, content] of Object.entries(next.files)) {
|
||||||
|
if (previous.files[path] === content) continue;
|
||||||
|
add(`Instructions: ${path}`, () => {
|
||||||
|
const file = currentFiles.get(path);
|
||||||
|
if (file?.content === content) return Promise.resolve();
|
||||||
|
return agentsApi.saveInstructionsFile(snapshot.agent.id, {
|
||||||
|
path, content, baseHash: file?.contentHash ?? null, baseRevisionId: file?.revision?.id ?? null,
|
||||||
|
}, snapshot.agent.companyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const path of Object.keys(previous.files)) {
|
||||||
|
if (Object.hasOwn(next.files, path)) continue;
|
||||||
|
add(`Remove instruction file: ${path}`, async () => {
|
||||||
|
const file = currentFiles.get(path);
|
||||||
|
if (!file) return;
|
||||||
|
return agentsApi.deleteInstructionsFile(snapshot.agent.id, path, snapshot.agent.companyId, file.contentHash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function planSecrets(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep, receipt: (value: ConfigurationReceipt) => void) {
|
||||||
|
const next = document.spec.secrets;
|
||||||
|
if (!next) return;
|
||||||
|
const { id, companyId } = snapshot.agent;
|
||||||
|
unique(next.rotate.map(row => row.id), "secrets.rotate");
|
||||||
|
unique(next.update.map(row => row.id), "secrets.update");
|
||||||
|
unique(next.proposals.map(row => row.id), "secrets.proposals");
|
||||||
|
unique(next.create.map(row => row.secret.key ?? row.secret.name), "secrets.create");
|
||||||
|
for (const operation of [...next.rotate, ...next.update]) requireItem(snapshot.secrets, secret => secret.id === operation.id, `Secret ${operation.id}`);
|
||||||
|
unique(next.create.flatMap(operation => operation.bind.env), "secrets.create.bind.env");
|
||||||
|
unique(next.create.flatMap(operation => operation.bind.api), "secrets.create.bind.api");
|
||||||
|
const env = (config: Record<string, unknown>) => config.env && typeof config.env === "object" && !Array.isArray(config.env) ? config.env as Record<string, unknown> : {};
|
||||||
|
for (const operation of next.create) {
|
||||||
|
for (const key of operation.bind.env) {
|
||||||
|
if (!equal(env(document.spec.adapterConfig)[key], env(snapshot.document.spec.adapterConfig)[key])) throw new Error(`Set env.${key} either in adapterConfig or secrets.create.bind, not both in one save.`);
|
||||||
|
}
|
||||||
|
for (const alias of operation.bind.api) {
|
||||||
|
if (!equal(document.spec.adapterConfig[`access.${alias}`], snapshot.document.spec.adapterConfig[`access.${alias}`])) throw new Error(`Set access.${alias} either in adapterConfig or secrets.create.bind, not both in one save.`);
|
||||||
|
}
|
||||||
|
const assertBindingsCurrent = (config: Record<string, unknown>) => {
|
||||||
|
for (const key of operation.bind.env) if (!equal(env(config)[key], env(snapshot.document.spec.adapterConfig)[key])) throw new Error(`env.${key} changed before secret binding. Reload saved before saving.`);
|
||||||
|
for (const alias of operation.bind.api) if (!equal(config[`access.${alias}`], snapshot.document.spec.adapterConfig[`access.${alias}`])) throw new Error(`access.${alias} changed before secret binding. Reload saved before saving.`);
|
||||||
|
};
|
||||||
|
add(`Create secret: ${operation.secret.name}`, async () => {
|
||||||
|
if (operation.bind.env.length || operation.bind.api.length) assertBindingsCurrent((await agentsApi.getConfiguration(id, companyId)).adapterConfig);
|
||||||
|
const created = await secretsApi.create(companyId, operation.secret);
|
||||||
|
receipt({ label: `Secret: ${created.name}`, id: created.id });
|
||||||
|
if (!operation.bind.env.length && !operation.bind.api.length) return;
|
||||||
|
const current = await agentsApi.getConfiguration(id, companyId);
|
||||||
|
assertBindingsCurrent(current.adapterConfig);
|
||||||
|
const adapterConfig = { ...current.adapterConfig };
|
||||||
|
const binding = { type: "secret_ref", secretId: created.id, version: "latest" };
|
||||||
|
if (operation.bind.env.length) adapterConfig.env = { ...(adapterConfig.env as Record<string, unknown> ?? {}),
|
||||||
|
...Object.fromEntries(operation.bind.env.map(key => [key, binding])) };
|
||||||
|
for (const alias of operation.bind.api) adapterConfig[`access.${alias}`] = binding;
|
||||||
|
await agentsApi.update(id, { adapterConfig, replaceAdapterConfig: true, expectedConfigurationRevision: current.configurationRevision }, companyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const operation of next.rotate) add(`Rotate secret: ${operation.id}`, () => secretsApi.rotate(operation.id, operation.input));
|
||||||
|
for (const operation of next.update) add(`Update secret: ${operation.id}`, () => secretsApi.update(operation.id, operation.input));
|
||||||
|
for (const operation of next.proposals) {
|
||||||
|
requireItem(snapshot.proposals, proposal => proposal.id === operation.id && (proposal.target?.id === id || proposal.proposedBy.id === id), `Secret proposal ${operation.id}`);
|
||||||
|
if (operation.decision === "reject" && !operation.reason?.trim()) throw new Error("A rejected secret proposal requires a reason.");
|
||||||
|
add(`Secret proposal: ${operation.id}`, () => operation.decision === "approve"
|
||||||
|
? secretsApi.approveProposal(companyId, operation.id)
|
||||||
|
: secretsApi.rejectProposal(companyId, operation.id, { reason: operation.reason! }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planAgentConfiguration(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, receipt: (value: ConfigurationReceipt) => void = () => {}) {
|
||||||
|
const steps: ConfigurationStep[] = [];
|
||||||
|
const add: AddConfigurationStep = (label, apply) => { steps.push({ label, apply }); };
|
||||||
|
if (!equal(document.observed, snapshot.document.observed)) throw new Error("observed is read-only. Edit spec to change configuration.");
|
||||||
|
for (const key of ownedAdapterKeys) if (Object.hasOwn(document.spec.adapterConfig, key)) throw new Error(`Use the ${key === "paperclipSkillSync" ? "skills" : "instructions"} section instead of adapterConfig.${key}.`);
|
||||||
|
const adapterConfig = { ...document.spec.adapterConfig };
|
||||||
|
for (const key of ownedAdapterKeys) if (Object.hasOwn(snapshot.core.adapterConfig, key)) adapterConfig[key] = snapshot.core.adapterConfig[key];
|
||||||
|
const corePatch = agentConfigurationPatch({ ...document, spec: { ...document.spec, adapterConfig } }, snapshot.core);
|
||||||
|
if (Object.keys(corePatch).length) add("Agent configuration", () => agentsApi.update(snapshot.agent.id, {
|
||||||
|
...corePatch, expectedConfigurationRevision: snapshot.core.configurationRevision,
|
||||||
|
}, snapshot.agent.companyId));
|
||||||
|
if (document.spec.permissions && !document.spec.permissions.canAssignTasks && (document.spec.role === "ceo" || document.spec.permissions.canCreateAgents)) {
|
||||||
|
throw new Error("CEO agents and agents that can create agents also have task assignment authority. Set canAssignTasks to true.");
|
||||||
|
}
|
||||||
|
planInstructions(snapshot, document, add);
|
||||||
|
if (document.spec.skills && !equal(document.spec.skills, snapshot.document.spec.skills)) {
|
||||||
|
unique(document.spec.skills.map(skill => typeof skill === "string" ? skill : skill.key), "skills");
|
||||||
|
add("Skills and version pins", () => agentsApi.syncSkills(snapshot.agent.id, document.spec.skills!, "replace", snapshot.agent.companyId));
|
||||||
|
}
|
||||||
|
planToolChanges(snapshot, document, add);
|
||||||
|
planChannelChanges(snapshot, document, add);
|
||||||
|
planSecrets(snapshot, document, add, receipt);
|
||||||
|
if (document.spec.permissions && !equal(document.spec.permissions, snapshot.document.spec.permissions)) {
|
||||||
|
add("Permissions and trust", () => agentsApi.updatePermissions(snapshot.agent.id, { ...document.spec.permissions!,
|
||||||
|
authorizationPolicy: document.spec.permissions!.authorizationPolicy ?? {},
|
||||||
|
}, snapshot.agent.companyId));
|
||||||
|
}
|
||||||
|
const keys = document.spec.apiKeys;
|
||||||
|
if (keys) {
|
||||||
|
unique(keys.revoke, "apiKeys.revoke");
|
||||||
|
unique(keys.create.map(key => key.name), "apiKeys.create");
|
||||||
|
for (const id of keys.revoke) {
|
||||||
|
const key = requireItem(snapshot.keys, key => key.id === id, `API key ${id}`);
|
||||||
|
if (!key.revokedAt) add(`Revoke API key: ${key.name}`, () => agentsApi.revokeKey(snapshot.agent.id, id, snapshot.agent.companyId));
|
||||||
|
}
|
||||||
|
for (const input of keys.create) add(`Create API key: ${input.name}`, async () => {
|
||||||
|
const key = await agentsApi.createKey(snapshot.agent.id, input.name, snapshot.agent.companyId, input.scope);
|
||||||
|
receipt({ label: `API key: ${key.name}`, id: key.id, token: key.token });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.spec.revisions?.restore) {
|
||||||
|
const id = document.spec.revisions.restore;
|
||||||
|
requireItem(snapshot.revisions, revision => revision.id === id, `Revision ${id}`);
|
||||||
|
if (steps.length) throw new Error("Restore a revision in its own save, then edit the restored configuration.");
|
||||||
|
add("Restore configuration revision", () => agentsApi.rollbackConfigRevision(snapshot.agent.id, id, snapshot.agent.companyId));
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAgentConfiguration(snapshot: AgentConfigurationSnapshot, input: AgentConfigurationDocument,
|
||||||
|
onReceipt: (receipt: ConfigurationReceipt) => void, onProgress: (label: string) => void) {
|
||||||
|
const document = agentConfigurationDocumentSchema.parse(input);
|
||||||
|
const initialPlan = planAgentConfiguration(snapshot, document);
|
||||||
|
if (!initialPlan.length) return snapshot;
|
||||||
|
const current = await loadAgentConfiguration(snapshot.agent.id, snapshot.agent.companyId);
|
||||||
|
if (current.core.configurationRevision !== snapshot.core.configurationRevision) throw new Error("Agent configuration changed while you were editing. Reload saved before saving.");
|
||||||
|
for (const key of ["permissions", "instructions", "skills", "tools", "channels"] as const) {
|
||||||
|
if (document.spec[key] !== undefined && !equal(document.spec[key], snapshot.document.spec[key]) && !equal(current.document.spec[key], snapshot.document.spec[key])) {
|
||||||
|
throw new Error(`${key} changed while you were editing. Reload saved before saving.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spec = { ...document.spec };
|
||||||
|
for (const key of ["permissions", "instructions", "skills", "tools", "channels"] as const) {
|
||||||
|
if (equal(document.spec[key], snapshot.document.spec[key])) Object.assign(spec, { [key]: current.document.spec[key] });
|
||||||
|
}
|
||||||
|
for (const operation of [...(document.spec.secrets?.rotate ?? []), ...(document.spec.secrets?.update ?? [])]) {
|
||||||
|
const previous = snapshot.secrets.find(secret => secret.id === operation.id);
|
||||||
|
if (!equal(previous, current.secrets.find(secret => secret.id === operation.id))) throw new Error(`Secret ${operation.id} changed. Reload saved before saving.`);
|
||||||
|
}
|
||||||
|
for (const profile of document.spec.tools?.profiles ?? []) {
|
||||||
|
if (!profile.profileId) continue;
|
||||||
|
const originalRow = snapshot.document.spec.tools?.profiles.find(value => value.profileId === profile.profileId);
|
||||||
|
if (originalRow && equal(originalRow, profile)) continue;
|
||||||
|
const before = snapshot.profiles.find(value => value.id === profile.profileId);
|
||||||
|
const fresh = current.profiles.find(value => value.id === profile.profileId);
|
||||||
|
if (!equal(before, fresh)) throw new Error(`Tool profile ${profile.profileId} changed while you were editing. Reload saved before saving.`);
|
||||||
|
}
|
||||||
|
const steps = planAgentConfiguration(current, { ...document, spec, observed: current.document.observed }, onReceipt);
|
||||||
|
const completed: string[] = [];
|
||||||
|
const warnings: AgentConfigurationWarning[] = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
onProgress(step.label);
|
||||||
|
try {
|
||||||
|
const result = await step.apply();
|
||||||
|
if (result && typeof result === "object" && "configurationWarnings" in result && Array.isArray(result.configurationWarnings)) {
|
||||||
|
warnings.push(...result.configurationWarnings as AgentConfigurationWarning[]);
|
||||||
|
}
|
||||||
|
completed.push(step.label);
|
||||||
|
}
|
||||||
|
catch (cause) { throw new ConfigurationSaveInterrupted(step.label, completed, cause); }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const refreshed = await loadAgentConfiguration(snapshot.agent.id, snapshot.agent.companyId);
|
||||||
|
if (warnings.length) refreshed.agent = { ...refreshed.agent, configurationWarnings: warnings };
|
||||||
|
return refreshed;
|
||||||
|
}
|
||||||
|
catch (cause) { throw new ConfigurationSaveInterrupted("refreshing the saved configuration", completed, cause); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface ConfigurationStep { label: string; apply: () => Promise<unknown> }
|
||||||
|
export type AddConfigurationStep = (label: string, apply: ConfigurationStep["apply"]) => void;
|
||||||
|
|
||||||
|
export function unique(values: string[], field: string) {
|
||||||
|
if (new Set(values).size !== values.length) throw new Error(`${field} contains duplicate entries.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireItem<T>(items: readonly T[], matches: (item: T) => boolean, label: string): T {
|
||||||
|
const item = items.find(matches);
|
||||||
|
if (!item) throw new Error(`${label} is not in this agent’s saved configuration. Reload before editing it.`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireExplicitFields(before: object, after: object, path: string) {
|
||||||
|
for (const [key, value] of Object.entries(before)) {
|
||||||
|
if (value !== undefined && !Object.hasOwn(after, key)) throw new Error(`Keep ${path}.${key} explicit; use null, false, an empty string, or an empty list to clear it where supported.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { AgentConfigurationDocument } from "@paperclipai/shared";
|
||||||
|
import { toolsApi } from "../../api/tools";
|
||||||
|
import { equal, policyAgentIds, profileConfiguration, type AgentConfigurationSnapshot } from "./load";
|
||||||
|
import { requireItem, requireExplicitFields, unique, type AddConfigurationStep } from "./steps";
|
||||||
|
|
||||||
|
export function planToolChanges(snapshot: AgentConfigurationSnapshot, document: AgentConfigurationDocument, add: AddConfigurationStep) {
|
||||||
|
const next = document.spec.tools;
|
||||||
|
if (!next) return;
|
||||||
|
const before = snapshot.document.spec.tools!;
|
||||||
|
const { id: agentId, companyId } = snapshot.agent;
|
||||||
|
unique(next.connections.map(row => row.connectionId), "tools.connections");
|
||||||
|
unique(next.profiles.flatMap(row => row.profileId ? [row.profileId] : []), "tools.profiles");
|
||||||
|
unique(next.policies.flatMap(row => row.id ? [row.id] : []), "tools.policies");
|
||||||
|
for (const row of next.connections) requireItem(snapshot.connections, connection => connection.id === row.connectionId, `Connection ${row.connectionId}`);
|
||||||
|
for (const previous of before.connections) {
|
||||||
|
const installed = next.connections.find(row => row.connectionId === previous.connectionId)?.installed ?? false;
|
||||||
|
if (installed === previous.installed) continue;
|
||||||
|
const connection = snapshot.connections.find(row => row.id === previous.connectionId)!;
|
||||||
|
if (connection.installs?.some(row => row.targetType === "company")) throw new Error(`${connection.name} is installed organization-wide. Its inherited installation cannot be overridden for one agent.`);
|
||||||
|
add(`Tool installation: ${connection.name}`, async () => {
|
||||||
|
const current = await toolsApi.getConnectionInstalls(connection.id);
|
||||||
|
if (current.installs.some(row => row.targetType === "company") || current.installs.some(row => row.targetType === "agent" && row.targetId === agentId) !== previous.installed) {
|
||||||
|
throw new Error(`Installation for ${connection.name} changed. Reload before saving.`);
|
||||||
|
}
|
||||||
|
const installs = current.installs.filter(row => !(row.targetType === "agent" && row.targetId === agentId))
|
||||||
|
.map(({ targetType, targetId }) => ({ targetType, targetId }));
|
||||||
|
if (installed) installs.push({ targetType: "agent", targetId: agentId });
|
||||||
|
return toolsApi.putConnectionInstalls(connection.id, installs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of next.profiles) {
|
||||||
|
const existing = row.profileId ? requireItem(snapshot.profiles, profile => profile.id === row.profileId, `Profile ${row.profileId}`) : null;
|
||||||
|
const previous = before.profiles.find(profile => profile.profileId === row.profileId);
|
||||||
|
if (previous && equal({ ...previous, updateSharedDefinition: false }, { ...row, updateSharedDefinition: false })) continue;
|
||||||
|
if (existing) {
|
||||||
|
requireExplicitFields(profileConfiguration(existing), row.configuration, `tools.profiles.${row.profileId}.configuration`);
|
||||||
|
const shared = existing.bindings.some(binding => binding.targetType !== "agent" || binding.targetId !== agentId);
|
||||||
|
if (shared && !equal(profileConfiguration(existing), row.configuration) && !row.updateSharedDefinition) {
|
||||||
|
throw new Error(`Profile ${existing.name} is shared with other targets. Set updateSharedDefinition: true to change its shared definition, or create a separate profile with profileId: null.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(`Tool profile${row.updateSharedDefinition ? " (shared definition)" : ""}: ${row.configuration.name}`, async () => {
|
||||||
|
let profileId = row.profileId;
|
||||||
|
if (!profileId) profileId = (await toolsApi.createProfile(companyId, row.configuration)).id;
|
||||||
|
else if (existing && !equal(profileConfiguration(existing), row.configuration)) await toolsApi.updateProfile(profileId, row.configuration);
|
||||||
|
return toolsApi.bindProfile(companyId, profileId, { targetType: "agent", targetId: agentId, priority: row.priority, metadata: row.metadata });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const previous of before.profiles) {
|
||||||
|
if (!next.profiles.some(row => row.profileId === previous.profileId)) add(`Unbind tool profile: ${previous.configuration.name}`,
|
||||||
|
() => toolsApi.unbindProfile(companyId, previous.profileId!, { targetType: "agent", targetId: agentId }));
|
||||||
|
}
|
||||||
|
for (const row of next.policies) {
|
||||||
|
const previous = row.id ? requireItem(before.policies, policy => policy.id === row.id, `Agent policy ${row.id}`) : null;
|
||||||
|
if (previous) requireExplicitFields(previous.configuration, row.configuration, `tools.policies.${row.id}.configuration`);
|
||||||
|
const agents = policyAgentIds(row.configuration);
|
||||||
|
if (agents.some(id => id !== agentId)) throw new Error("tools.policies must be scoped only to this agent.");
|
||||||
|
const selectors: Record<string, unknown> = { ...row.configuration.selectors, agentIds: [agentId] };
|
||||||
|
delete selectors.agentId;
|
||||||
|
const configuration = { ...row.configuration, selectors };
|
||||||
|
if (previous && equal(previous.configuration, row.configuration)) continue;
|
||||||
|
add(`Tool policy: ${configuration.name}`, () => row.id
|
||||||
|
? toolsApi.updatePolicy(companyId, row.id, configuration)
|
||||||
|
: toolsApi.createPolicy(companyId, configuration));
|
||||||
|
}
|
||||||
|
for (const previous of before.policies) {
|
||||||
|
if (!next.policies.some(row => row.id === previous.id)) add(`Remove tool policy: ${previous.configuration.name}`,
|
||||||
|
() => toolsApi.deletePolicy(companyId, previous.id!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { appearanceForPalette, agentConfigurationSpec, type Agent } from "@paperclipai/shared";
|
||||||
|
import { ConfigurationSaveInterrupted } from "../lib/agent-configuration/save";
|
||||||
|
import { AgentAdvancedTab } from "./AgentAdvancedTab";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({ load: vi.fn(), save: vi.fn() }));
|
||||||
|
vi.mock("../lib/agent-configuration/load", async importOriginal => ({
|
||||||
|
...await importOriginal<typeof import("../lib/agent-configuration/load")>(), loadAgentConfiguration: api.load,
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/agent-configuration/save", async importOriginal => ({
|
||||||
|
...await importOriginal<typeof import("../lib/agent-configuration/save")>(), saveAgentConfiguration: api.save,
|
||||||
|
}));
|
||||||
|
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
|
||||||
|
|
||||||
|
const agent = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111", companyId: "company", urlKey: "codie", name: "Codie",
|
||||||
|
role: "engineer", title: null, icon: null, appearance: appearanceForPalette("bubblegum-sky"), reportsTo: null,
|
||||||
|
capabilities: null, adapterType: "process", adapterConfig: { command: "echo" }, runtimeConfig: {},
|
||||||
|
defaultEnvironmentId: null, budgetMonthlyCents: 0, metadata: null, configurationRevision: "a".repeat(64),
|
||||||
|
status: "idle", spentMonthlyCents: 0, pauseReason: null, pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: false }, lastHeartbeatAt: null, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
} satisfies Agent & { configurationRevision: string };
|
||||||
|
|
||||||
|
function snapshot(value = agent) {
|
||||||
|
return { agent: value, core: value, document: { apiVersion: "paperclip/v1", kind: "Agent", spec: agentConfigurationSpec(value) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Advanced agent configuration", () => {
|
||||||
|
let container: HTMLDivElement, root: Root, client: QueryClient;
|
||||||
|
const dirty = vi.fn(), onSaved = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||||
|
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} unobserve() {} });
|
||||||
|
api.load.mockResolvedValue(snapshot());
|
||||||
|
container = document.createElement("div"); document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||||
|
});
|
||||||
|
afterEach(async () => { await act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
|
||||||
|
async function settle(assertion: () => void) {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 5)); });
|
||||||
|
try { assertion(); return; } catch (error) { if (i === 29) throw error; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function render() {
|
||||||
|
await act(() => root.render(<QueryClientProvider client={client}>
|
||||||
|
<AgentAdvancedTab agent={agent} onDirtyChange={dirty} onSaved={onSaved} />
|
||||||
|
</QueryClientProvider>));
|
||||||
|
}
|
||||||
|
function editor() { return EditorView.findFromDOM(container.querySelector(".cm-content")!)!; }
|
||||||
|
async function edit(transform: (source: string) => string) {
|
||||||
|
await act(() => { const view = editor(); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: transform(view.state.doc.toString()) } }); });
|
||||||
|
}
|
||||||
|
function button(label: string) { return [...container.querySelectorAll("button")].find(button => button.textContent === label)!; }
|
||||||
|
|
||||||
|
it("retains an invalid or rejected draft and reloads the saved configuration explicitly", async () => {
|
||||||
|
api.save.mockRejectedValue(new Error("Agent configuration changed while you were editing."));
|
||||||
|
await render(); await settle(() => expect(editor()).toBeTruthy());
|
||||||
|
await edit(source => source.replace("budgetMonthlyCents: 0", "budgetMonthlyCents: -1"));
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain("spec.budgetMonthlyCents");
|
||||||
|
await edit(source => source.replace("budgetMonthlyCents: -1", "budgetMonthlyCents: 500"));
|
||||||
|
await act(() => button("Save changes").click());
|
||||||
|
await settle(() => expect(container.textContent).toContain("configuration changed while you were editing"));
|
||||||
|
expect(editor().state.doc.toString()).toContain("budgetMonthlyCents: 500");
|
||||||
|
expect(api.save.mock.calls[0][1].spec.budgetMonthlyCents).toBe(500);
|
||||||
|
await act(() => button("Reload saved").click());
|
||||||
|
await settle(() => expect(editor().state.doc.toString()).toContain("budgetMonthlyCents: 0"));
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the saved server result and uses its revision for the next edit", async () => {
|
||||||
|
api.save.mockResolvedValue(snapshot({ ...agent, name: "Codie Two", urlKey: "codie-two", configurationRevision: "b".repeat(64) }));
|
||||||
|
await render(); await settle(() => expect(editor()).toBeTruthy());
|
||||||
|
await edit(source => source.replace("name: Codie", "name: Codie Two"));
|
||||||
|
await act(() => button("Save changes").click());
|
||||||
|
await settle(() => expect(onSaved).toHaveBeenCalledTimes(1));
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
expect(editor().state.doc.toString()).toContain("name: Codie Two");
|
||||||
|
await edit(source => source.replace("title: null", "title: Engineer"));
|
||||||
|
await act(() => button("Save changes").click());
|
||||||
|
await settle(() => expect(api.save).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.save.mock.calls[1][0].core.configurationRevision).toBe("b".repeat(64));
|
||||||
|
expect(api.save.mock.calls[1][1].spec.title).toBe("Engineer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks retries after partial saves and keeps one-time credentials outside YAML", async () => {
|
||||||
|
api.save.mockImplementation(async (_snapshot, _document, onReceipt) => {
|
||||||
|
onReceipt({ id: "key", label: "Automation", token: "one-time-key" });
|
||||||
|
throw new ConfigurationSaveInterrupted("second key", ["first key"], new Error("Network lost"));
|
||||||
|
});
|
||||||
|
await render(); await settle(() => expect(editor()).toBeTruthy());
|
||||||
|
await edit(source => source.replace("title: null", "title: Engineer"));
|
||||||
|
await act(() => button("Save changes").click());
|
||||||
|
await settle(() => expect(container.textContent).toContain("Reload saved before saving again"));
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
expect((container.querySelector('input[aria-label="Automation token"]') as HTMLInputElement).value).toBe("one-time-key");
|
||||||
|
expect(editor().state.doc.toString()).not.toContain("one-time-key");
|
||||||
|
await edit(source => source.replace("title: Engineer", "title: Lead"));
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
await act(() => button("Reload saved").click());
|
||||||
|
await settle(() => expect(editor().state.doc.toString()).toContain("title: null"));
|
||||||
|
await edit(source => source.replace("title: null", "title: Lead"));
|
||||||
|
expect(button("Save changes").disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews changes and sends only one save for a double click", async () => {
|
||||||
|
let complete!: (value: ReturnType<typeof snapshot>) => void;
|
||||||
|
api.save.mockImplementation(() => new Promise(resolve => { complete = resolve; }));
|
||||||
|
await render(); await settle(() => expect(editor()).toBeTruthy());
|
||||||
|
await edit(source => source.replace("title: null", "title: Engineer"));
|
||||||
|
expect(container.querySelector("summary")?.textContent).toBe("1 change to apply");
|
||||||
|
expect(container.querySelector("details")?.textContent).toContain("Agent configuration");
|
||||||
|
const view = editor();
|
||||||
|
await act(() => { button("Save changes").click(); button("Save changes").click(); });
|
||||||
|
await settle(() => expect(api.save).toHaveBeenCalledTimes(1));
|
||||||
|
expect(button("Reload saved").disabled).toBe(true);
|
||||||
|
await act(async () => complete(snapshot()));
|
||||||
|
await settle(() => expect(button("Save changes").disabled).toBe(true));
|
||||||
|
expect(editor()).toBe(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates read-only sections before enabling Save", async () => {
|
||||||
|
api.load.mockResolvedValue({ ...snapshot(), document: { ...snapshot().document, observed: { apiKeys: [] } } });
|
||||||
|
await render(); await settle(() => expect(editor()).toBeTruthy());
|
||||||
|
await edit(source => source.replace("apiKeys: []", "apiKeys: [changed]"));
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
expect(container.textContent).toContain("observed is read-only");
|
||||||
|
expect(api.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Save disabled for formatting-only changes", async () => {
|
||||||
|
await render(); await settle(() => expect(editor()).toBeTruthy());
|
||||||
|
await edit(source => `${source}\n# A local note\n`);
|
||||||
|
expect(button("Save changes").disabled).toBe(true);
|
||||||
|
expect(container.textContent).toContain("No configuration changes");
|
||||||
|
expect(api.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a configuration read denial without rendering an empty editable document", async () => {
|
||||||
|
api.load.mockRejectedValue(new Error("Configuration access denied"));
|
||||||
|
await render(); await settle(() => expect(container.textContent).toContain("Configuration access denied"));
|
||||||
|
expect(container.querySelector(".cm-content")).toBeNull();
|
||||||
|
expect(api.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { basicSetup } from "codemirror";
|
||||||
|
import { Compartment, EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { yaml } from "@codemirror/lang-yaml";
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { useToastActions } from "../context/ToastContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { parseAgentConfigurationYaml, serializeAgentConfigurationDocument } from "../lib/agent-configuration-yaml";
|
||||||
|
import { loadAgentConfiguration, type AgentConfigurationSnapshot } from "../lib/agent-configuration/load";
|
||||||
|
import { ConfigurationSaveInterrupted, planAgentConfiguration, saveAgentConfiguration, type ConfigurationReceipt } from "../lib/agent-configuration/save";
|
||||||
|
|
||||||
|
const yamlHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.propertyName, class: "text-primary" },
|
||||||
|
{ tag: tags.string, class: "text-foreground" },
|
||||||
|
{ tag: [tags.bool, tags.number, tags.null], class: "text-muted-foreground" },
|
||||||
|
{ tag: tags.keyword, class: "text-primary" },
|
||||||
|
{ tag: tags.comment, class: "text-muted-foreground" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface AgentAdvancedTabProps {
|
||||||
|
agent: Agent;
|
||||||
|
onDirtyChange: (dirty: boolean) => void;
|
||||||
|
onSaved: (updated: Agent) => void;
|
||||||
|
onSavingChange?: (saving: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentAdvancedTab(props: AgentAdvancedTabProps) {
|
||||||
|
const [initial, setInitial] = useState<AgentConfigurationSnapshot | null>(null);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["agent-configuration-yaml", props.agent.companyId, props.agent.id],
|
||||||
|
queryFn: () => loadAgentConfiguration(props.agent.id, props.agent.companyId),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initial && query.data && !query.isFetching) setInitial(query.data);
|
||||||
|
}, [initial, query.data, query.isFetching]);
|
||||||
|
if (!initial) {
|
||||||
|
if (query.error) return <div role="alert" className="space-y-3 text-sm">
|
||||||
|
<p>{query.error.message}</p>
|
||||||
|
<Button variant="outline" onClick={() => query.refetch()}>Retry</Button>
|
||||||
|
</div>;
|
||||||
|
return <p role="status" className="text-sm text-muted-foreground">Loading configuration…</p>;
|
||||||
|
}
|
||||||
|
return <ConfigurationEditor key={props.agent.id} {...props} initial={initial} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigurationEditor({ agent, initial, onDirtyChange, onSaved, onSavingChange }: AgentAdvancedTabProps & { initial: AgentConfigurationSnapshot }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { pushToast } = useToastActions();
|
||||||
|
const [saved, setSaved] = useState(initial);
|
||||||
|
const [source, setSource] = useState(() => serializeAgentConfigurationDocument(initial.document));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reloadRequired, setReloadRequired] = useState(false);
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
const [receipts, setReceipts] = useState<ConfigurationReceipt[]>([]);
|
||||||
|
const operationPending = useRef(false);
|
||||||
|
const active = useRef(true);
|
||||||
|
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
|
||||||
|
const original = useMemo(() => serializeAgentConfigurationDocument(saved.document), [saved]);
|
||||||
|
const dirty = source !== original;
|
||||||
|
const validation = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const document = parseAgentConfigurationYaml(source);
|
||||||
|
return { document, changes: planAgentConfiguration(saved, document).map(step => step.label), error: null };
|
||||||
|
} catch (cause) { return { document: null, changes: [], error: cause instanceof Error ? cause.message : "Invalid YAML" }; }
|
||||||
|
}, [source, saved]);
|
||||||
|
useEffect(() => { onDirtyChange(dirty || reloadRequired); }, [dirty, reloadRequired, onDirtyChange]);
|
||||||
|
useEffect(() => () => onDirtyChange(false), [onDirtyChange]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!validation.document) throw new Error(validation.error ?? "Invalid YAML");
|
||||||
|
if (reloadRequired) throw new Error("Reload saved before saving again.");
|
||||||
|
return saveAgentConfiguration(saved, validation.document,
|
||||||
|
receipt => { if (active.current) setReceipts(current => [...current, receipt]); },
|
||||||
|
label => { if (active.current) setProgress(label); });
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
onSuccess: configuration => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
|
||||||
|
queryClient.setQueryData(["agent-configuration-yaml", agent.companyId, agent.id], configuration);
|
||||||
|
if (!active.current) return;
|
||||||
|
setSaved(configuration);
|
||||||
|
setSource(serializeAgentConfigurationDocument(configuration.document));
|
||||||
|
setError(null);
|
||||||
|
onDirtyChange(false);
|
||||||
|
setReloadRequired(false);
|
||||||
|
onSaved(configuration.agent);
|
||||||
|
const warnings = configuration.agent.configurationWarnings;
|
||||||
|
pushToast({ title: "Agent saved", tone: warnings?.length ? "warn" : "success", body: warnings?.map(warning => warning.message).join("\n") });
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
operationPending.current = false;
|
||||||
|
queryClient.invalidateQueries({ predicate: query => query.queryKey.some(part => part === agent.id || part === agent.companyId), refetchType: "none" });
|
||||||
|
},
|
||||||
|
onError: cause => {
|
||||||
|
if (!active.current) return;
|
||||||
|
setError(cause.message);
|
||||||
|
if (cause instanceof ConfigurationSaveInterrupted) setReloadRequired(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reload = useMutation({
|
||||||
|
mutationFn: () => loadAgentConfiguration(agent.id, agent.companyId),
|
||||||
|
onSuccess: updated => {
|
||||||
|
queryClient.setQueryData(["agent-configuration-yaml", agent.companyId, agent.id], updated);
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
|
||||||
|
if (active.current) { setSaved(updated); setSource(serializeAgentConfigurationDocument(updated.document)); setError(null); setReloadRequired(false); onSaved(updated.agent); }
|
||||||
|
},
|
||||||
|
onSettled: () => { operationPending.current = false; },
|
||||||
|
onError: cause => setError(cause.message),
|
||||||
|
});
|
||||||
|
const busy = save.isPending || reload.isPending;
|
||||||
|
useEffect(() => { onSavingChange?.(busy); }, [busy, onSavingChange]);
|
||||||
|
useEffect(() => () => onSavingChange?.(false), [onSavingChange]);
|
||||||
|
return <div className="space-y-4">
|
||||||
|
{receipts.length > 0 && <div className="space-y-3 rounded-md border border-border p-4" aria-label="Created credentials">
|
||||||
|
{receipts.map((receipt, index) => <div key={`${receipt.id}-${index}`} className="space-y-2 text-sm">
|
||||||
|
<p className="font-medium">{receipt.label}</p>
|
||||||
|
{receipt.token ? <>
|
||||||
|
<p className="text-muted-foreground">Copy this key now. It won’t be shown again after leaving this page.</p>
|
||||||
|
<input readOnly aria-label={`${receipt.label} token`} value={receipt.token} className="w-full rounded-md border border-border bg-muted p-2 font-mono text-sm" />
|
||||||
|
</> : <p className="font-mono text-muted-foreground">{receipt.id}</p>}
|
||||||
|
</div>)}
|
||||||
|
</div>}
|
||||||
|
<AgentYamlEditor value={source} onChange={value => { setSource(value); if (!reloadRequired) setError(null); }} readOnly={busy} />
|
||||||
|
{dirty && validation.changes.length > 0 && <details className="rounded-md border border-border p-3 text-sm">
|
||||||
|
<summary className="cursor-pointer font-medium">{validation.changes.length} {validation.changes.length === 1 ? "change" : "changes"} to apply</summary>
|
||||||
|
<ul className="mt-2 list-inside list-disc space-y-1">{validation.changes.map((label, index) => <li key={index}>{label}</li>)}</ul>
|
||||||
|
<p className="mt-2 text-muted-foreground">Changes save in this order. If one fails, earlier changes remain saved.</p>
|
||||||
|
</details>}
|
||||||
|
{(validation.error || error) && <p role="alert" className="whitespace-pre-wrap text-sm text-destructive">{validation.error || error}</p>}
|
||||||
|
<footer className="sticky bottom-0 z-10 flex items-center justify-between gap-3 border-t border-border bg-background py-4">
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={() => { if (!operationPending.current) { operationPending.current = true; reload.mutate(); } }}>Reload saved</Button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span role="status" className="text-xs text-muted-foreground">{save.isPending ? `Saving: ${progress || "configuration"}…` : reload.isPending ? "Loading configuration…" : dirty ? validation.document && !validation.changes.length ? "No configuration changes" : "Unsaved changes" : ""}</span>
|
||||||
|
<Button disabled={!dirty || busy || reloadRequired || !validation.document || !validation.changes.length} onClick={() => { if (!operationPending.current) { operationPending.current = true; save.mutate(); } }}>{save.isPending ? "Saving…" : "Save changes"}</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentYamlEditor({ value, onChange, readOnly }: { value: string; onChange: (value: string) => void; readOnly: boolean }) {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const editor = useRef<EditorView | null>(null);
|
||||||
|
const editability = useRef(new Compartment());
|
||||||
|
const change = useRef(onChange);
|
||||||
|
change.current = onChange;
|
||||||
|
const current = useRef(value);
|
||||||
|
current.current = value;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!container.current) return;
|
||||||
|
const view = new EditorView({
|
||||||
|
parent: container.current,
|
||||||
|
state: EditorState.create({ doc: current.current, extensions: [
|
||||||
|
basicSetup, yaml(),
|
||||||
|
syntaxHighlighting(yamlHighlightStyle),
|
||||||
|
EditorView.contentAttributes.of({ "aria-label": "Agent YAML configuration", spellcheck: "false" }),
|
||||||
|
editability.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
|
||||||
|
EditorView.updateListener.of(update => { if (update.docChanged) change.current(update.state.doc.toString()); }),
|
||||||
|
] }),
|
||||||
|
});
|
||||||
|
editor.current = view;
|
||||||
|
return () => { view.destroy(); editor.current = null; };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
editor.current?.dispatch({ effects: editability.current.reconfigure([
|
||||||
|
EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly),
|
||||||
|
]) });
|
||||||
|
}, [readOnly]);
|
||||||
|
useEffect(() => {
|
||||||
|
const view = editor.current;
|
||||||
|
if (view && view.state.doc.toString() !== value) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||||
|
}, [value]);
|
||||||
|
return <div ref={container} className="agent-yaml-editor overflow-hidden rounded-md border border-border" />;
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { AgentCharacter } from "../components/AgentCharacter";
|
|||||||
import { characterStateForAgent } from "@paperclipai/shared";
|
import { characterStateForAgent } from "@paperclipai/shared";
|
||||||
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
|
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
|
||||||
import { usePageVisibility } from "../lib/page-visibility";
|
import { usePageVisibility } from "../lib/page-visibility";
|
||||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
|
import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -155,6 +155,8 @@ import {
|
|||||||
type AgentDetailView,
|
type AgentDetailView,
|
||||||
} from "./agent-detail-navigation";
|
} from "./agent-detail-navigation";
|
||||||
|
|
||||||
|
const AgentAdvancedTab = lazy(() => import("./AgentAdvancedTab").then(module => ({ default: module.AgentAdvancedTab })));
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||||
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
||||||
@@ -821,8 +823,9 @@ export function AgentDetail() {
|
|||||||
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
|
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
|
||||||
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
|
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
|
||||||
const prepareAgentNavigation = useCallback(() => {
|
const prepareAgentNavigation = useCallback(() => {
|
||||||
|
if (activeView === "advanced" && configSaving) return false;
|
||||||
return confirmAgentConfigNavigation(configDirty);
|
return confirmAgentConfigNavigation(configDirty);
|
||||||
}, [configDirty]);
|
}, [activeView, configDirty, configSaving]);
|
||||||
const agentQuery = useQuery<AgentDetailRecord>({
|
const agentQuery = useQuery<AgentDetailRecord>({
|
||||||
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
|
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
|
||||||
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
|
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
|
||||||
@@ -979,6 +982,7 @@ export function AgentDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agent) return;
|
if (!agent) return;
|
||||||
|
if (activeView === "advanced" && (configSaving || configDirty)) return;
|
||||||
if (urlRunId) {
|
if (urlRunId) {
|
||||||
if (routeAgentRef !== canonicalAgentRef) {
|
if (routeAgentRef !== canonicalAgentRef) {
|
||||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||||
@@ -991,7 +995,7 @@ export function AgentDetail() {
|
|||||||
navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true });
|
navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, location.search]);
|
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, configSaving, configDirty, legacyAuditSection, navigate, location.search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||||||
@@ -1301,7 +1305,7 @@ export function AgentDetail() {
|
|||||||
companyId={resolvedCompanyId}
|
companyId={resolvedCompanyId}
|
||||||
assignLabel="Assign Task"
|
assignLabel="Assign Task"
|
||||||
showStatus={false}
|
showStatus={false}
|
||||||
actionsDisabled={agentAction.isPending}
|
actionsDisabled={agentAction.isPending || (activeView === "advanced" && configSaving)}
|
||||||
workActionsDisabled={hasInvalidOrgChain}
|
workActionsDisabled={hasInvalidOrgChain}
|
||||||
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
|
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
|
||||||
hasPendingNavigationChanges={configDirty}
|
hasPendingNavigationChanges={configDirty}
|
||||||
@@ -1508,6 +1512,20 @@ export function AgentDetail() {
|
|||||||
<AgentRevisionsTab agent={agent} companyId={resolvedCompanyId ?? undefined} />
|
<AgentRevisionsTab agent={agent} companyId={resolvedCompanyId ?? undefined} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeView === "advanced" && (
|
||||||
|
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading configuration…</p>}>
|
||||||
|
<AgentAdvancedTab key={agent.id} agent={agent} onDirtyChange={setConfigDirty} onSavingChange={setConfigSaving}
|
||||||
|
onSaved={updated => {
|
||||||
|
const nextRef = agentRouteRef(updated);
|
||||||
|
const currentKey = [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null];
|
||||||
|
void queryClient.cancelQueries({ queryKey: currentKey, exact: true });
|
||||||
|
queryClient.setQueryData(currentKey, { ...agent, ...updated });
|
||||||
|
queryClient.setQueryData([...queryKeys.agents.detail(nextRef), lookupCompanyId ?? null], { ...agent, ...updated });
|
||||||
|
if (nextRef !== routeAgentRef) navigate(`/agents/${nextRef}/advanced`, { replace: true });
|
||||||
|
}} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeView === "run-detail" && (
|
{activeView === "run-detail" && (
|
||||||
<RunsTab
|
<RunsTab
|
||||||
runs={heartbeats ?? []}
|
runs={heartbeats ?? []}
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ describe("agent detail navigation", () => {
|
|||||||
"permissions",
|
"permissions",
|
||||||
"api-keys",
|
"api-keys",
|
||||||
"revisions",
|
"revisions",
|
||||||
|
"advanced",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("canonicalizes legacy local paths", () => {
|
it("canonicalizes legacy local paths", () => {
|
||||||
|
expect(parseAgentDetailView("advanced")).toBe("advanced");
|
||||||
|
expect(agentDetailHref("codie", "advanced")).toBe("/agents/codie/advanced");
|
||||||
expect(parseAgentDetailView("dashboard")).toBe("overview");
|
expect(parseAgentDetailView("dashboard")).toBe("overview");
|
||||||
expect(parseAgentDetailView("configuration")).toBe("runtime");
|
expect(parseAgentDetailView("configuration")).toBe("runtime");
|
||||||
expect(parseAgentDetailView("prompts")).toBe("instructions");
|
expect(parseAgentDetailView("prompts")).toBe("instructions");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AgentDetailView =
|
|||||||
| "permissions"
|
| "permissions"
|
||||||
| "api-keys"
|
| "api-keys"
|
||||||
| "revisions"
|
| "revisions"
|
||||||
|
| "advanced"
|
||||||
| "run-detail";
|
| "run-detail";
|
||||||
|
|
||||||
export type AgentLocalDetailView = Exclude<AgentDetailView, "run-detail">;
|
export type AgentLocalDetailView = Exclude<AgentDetailView, "run-detail">;
|
||||||
@@ -42,6 +43,7 @@ export const AGENT_DETAIL_NAVIGATION: ReadonlyArray<{
|
|||||||
{ value: "permissions", label: "Permissions / Trust" },
|
{ value: "permissions", label: "Permissions / Trust" },
|
||||||
{ value: "api-keys", label: "API Keys" },
|
{ value: "api-keys", label: "API Keys" },
|
||||||
{ value: "revisions", label: "Revisions" },
|
{ value: "revisions", label: "Revisions" },
|
||||||
|
{ value: "advanced", label: "Advanced" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
@@ -56,6 +58,7 @@ export function parseAgentDetailView(value: string | null): AgentLocalDetailView
|
|||||||
if (value === "permissions" || value === "trust") return "permissions";
|
if (value === "permissions" || value === "trust") return "permissions";
|
||||||
if (value === "api-keys" || value === "keys") return "api-keys";
|
if (value === "api-keys" || value === "keys") return "api-keys";
|
||||||
if (value === "revisions" || value === "history") return "revisions";
|
if (value === "revisions" || value === "history") return "revisions";
|
||||||
|
if (value === "advanced") return "advanced";
|
||||||
return "overview";
|
return "overview";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
confirmAgentConfigNavigation,
|
confirmAgentConfigNavigation,
|
||||||
} from "@/pages/AgentDetail";
|
} from "@/pages/AgentDetail";
|
||||||
import { AgentSkillsTab } from "@/pages/agent-skills/AgentSkillsTab";
|
import { AgentSkillsTab } from "@/pages/agent-skills/AgentSkillsTab";
|
||||||
|
import { AgentAdvancedTab } from "@/pages/AgentAdvancedTab";
|
||||||
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
||||||
import { AgentContextualSidebar } from "@/components/AgentContextualSidebar";
|
import { AgentContextualSidebar } from "@/components/AgentContextualSidebar";
|
||||||
import { AgentActionButtons } from "@/components/AgentActionButtons";
|
import { AgentActionButtons } from "@/components/AgentActionButtons";
|
||||||
@@ -45,6 +46,7 @@ import { storybookIssues } from "../../fixtures/paperclipData";
|
|||||||
import "./settings.css";
|
import "./settings.css";
|
||||||
|
|
||||||
const descriptions: Record<AgentLocalDetailView, string> = {
|
const descriptions: Record<AgentLocalDetailView, string> = {
|
||||||
|
advanced: "Edit the agent’s configuration as YAML.",
|
||||||
overview: "A snapshot of Nova’s work, capabilities, and current setup.",
|
overview: "A snapshot of Nova’s work, capabilities, and current setup.",
|
||||||
instructions: "The files that guide how your agent thinks and works.",
|
instructions: "The files that guide how your agent thinks and works.",
|
||||||
skills: "Choose the skills your agent brings to each task.",
|
skills: "Choose the skills your agent brings to each task.",
|
||||||
@@ -275,7 +277,7 @@ function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
|
|||||||
if (
|
if (
|
||||||
dirty &&
|
dirty &&
|
||||||
(event.target as HTMLElement).closest("a") &&
|
(event.target as HTMLElement).closest("a") &&
|
||||||
!confirmAgentConfigNavigation(true)
|
(saving || !confirmAgentConfigNavigation(true))
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -471,6 +473,9 @@ function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
|
|||||||
{view === "revisions" && (
|
{view === "revisions" && (
|
||||||
<AgentRevisionsTab agent={agent} companyId={COMPANY} />
|
<AgentRevisionsTab agent={agent} companyId={COMPANY} />
|
||||||
)}
|
)}
|
||||||
|
{view === "advanced" && (
|
||||||
|
<AgentAdvancedTab key={agent.id} agent={agent} onDirtyChange={onDirty} onSavingChange={setSaving} onSaved={() => {}} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(saveError || permissions.error) && (
|
{(saveError || permissions.error) && (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export function createSettingsFixtures(
|
|||||||
outcome: TestOutcome = "pass",
|
outcome: TestOutcome = "pass",
|
||||||
saveFails = false,
|
saveFails = false,
|
||||||
) {
|
) {
|
||||||
|
let configurationVersion = 0;
|
||||||
|
const configurationRevision = () => configurationVersion.toString(16).padStart(64, "0");
|
||||||
let agent: AgentDetail = {
|
let agent: AgentDetail = {
|
||||||
...storybookHiredAgent,
|
...storybookHiredAgent,
|
||||||
id: ID,
|
id: ID,
|
||||||
@@ -117,6 +119,7 @@ export function createSettingsFixtures(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let desiredSkills = ["paperclip", "design-guide"];
|
let desiredSkills = ["paperclip", "design-guide"];
|
||||||
|
let desiredSkillEntries = desiredSkills.map(key => ({ key, versionId: null as string | null }));
|
||||||
const files: Record<string, string> = {
|
const files: Record<string, string> = {
|
||||||
"AGENTS.md":
|
"AGENTS.md":
|
||||||
"# Nova\n\nYou are a product engineer. Build interfaces that are clear, useful, and reliable.\n\n## Working style\n\n- Read the task and relevant code before making changes.\n- Keep changes focused and preserve existing behavior.\n- Test the experience in the browser before handing it back.\n\nRead [workflow.md](workflow.md) for your delivery checklist.\n",
|
"# Nova\n\nYou are a product engineer. Build interfaces that are clear, useful, and reliable.\n\n## Working style\n\n- Read the task and relevant code before making changes.\n- Keep changes focused and preserve existing behavior.\n- Test the experience in the browser before handing it back.\n\nRead [workflow.md](workflow.md) for your delivery checklist.\n",
|
||||||
@@ -155,6 +158,7 @@ export function createSettingsFixtures(
|
|||||||
supported: true,
|
supported: true,
|
||||||
mode: "ephemeral",
|
mode: "ephemeral",
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
|
desiredSkillEntries,
|
||||||
warnings: [],
|
warnings: [],
|
||||||
entries: desiredSkills.map((key) => ({
|
entries: desiredSkills.map((key) => ({
|
||||||
key,
|
key,
|
||||||
@@ -331,6 +335,12 @@ export function createSettingsFixtures(
|
|||||||
}
|
}
|
||||||
if (url.pathname === `/api/companies/${COMPANY}/tools/connections`)
|
if (url.pathname === `/api/companies/${COMPANY}/tools/connections`)
|
||||||
return json({ connections: [connection()] });
|
return json({ connections: [connection()] });
|
||||||
|
if (url.pathname === `/api/companies/${COMPANY}/tools/profiles`)
|
||||||
|
return json({ profiles: [] });
|
||||||
|
if (url.pathname === `/api/companies/${COMPANY}/chat-endpoints`)
|
||||||
|
return json([]);
|
||||||
|
if (url.pathname === `/api/companies/${COMPANY}/secret-proposals`)
|
||||||
|
return json([]);
|
||||||
if (url.pathname === `/api/companies/${COMPANY}/tools/policies`)
|
if (url.pathname === `/api/companies/${COMPANY}/tools/policies`)
|
||||||
return json({ policies: [] });
|
return json({ policies: [] });
|
||||||
if (url.pathname.includes("/tools/profiles/effective/agents/"))
|
if (url.pathname.includes("/tools/profiles/effective/agents/"))
|
||||||
@@ -377,6 +387,11 @@ export function createSettingsFixtures(
|
|||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
);
|
||||||
const patch = await payload();
|
const patch = await payload();
|
||||||
|
if (patch.expectedConfigurationRevision && patch.expectedConfigurationRevision !== configurationRevision()) {
|
||||||
|
return Response.json({ error: "Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again." }, { status: 409 });
|
||||||
|
}
|
||||||
|
delete patch.expectedConfigurationRevision;
|
||||||
|
delete patch.replaceAdapterConfig;
|
||||||
const before = agent;
|
const before = agent;
|
||||||
agent =
|
agent =
|
||||||
path === "/permissions"
|
path === "/permissions"
|
||||||
@@ -391,11 +406,14 @@ export function createSettingsFixtures(
|
|||||||
}
|
}
|
||||||
: { ...agent, ...patch };
|
: { ...agent, ...patch };
|
||||||
recordRevision(before, patch);
|
recordRevision(before, patch);
|
||||||
return json(agent);
|
configurationVersion++;
|
||||||
|
return json({ ...agent, configurationRevision: configurationRevision() });
|
||||||
}
|
}
|
||||||
if (!path) return json(agent);
|
if (!path) return json(agent);
|
||||||
|
if (path === "/configuration") return json({ ...agent, configurationRevision: configurationRevision() });
|
||||||
if (path === "/skills/sync") {
|
if (path === "/skills/sync") {
|
||||||
const data = await payload();
|
const data = await payload();
|
||||||
|
desiredSkillEntries = data.desiredSkills.map((skill: string | { key: string; versionId?: string }) => typeof skill === "string" ? { key: skill, versionId: null } : { ...skill, versionId: skill.versionId ?? null });
|
||||||
desiredSkills = data.desiredSkills.map(
|
desiredSkills = data.desiredSkills.map(
|
||||||
(s: string | { key: string }) =>
|
(s: string | { key: string }) =>
|
||||||
typeof s === "string" ? s : s.key,
|
typeof s === "string" ? s : s.key,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const meta = {
|
|||||||
"permissions",
|
"permissions",
|
||||||
"api-keys",
|
"api-keys",
|
||||||
"revisions",
|
"revisions",
|
||||||
|
"advanced",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
adapterType: {
|
adapterType: {
|
||||||
@@ -59,6 +60,13 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
export const Overview: Story = { args: { initialTab: "overview" } };
|
export const Overview: Story = { args: { initialTab: "overview" } };
|
||||||
export const Instructions: Story = { args: { initialTab: "instructions" } };
|
export const Instructions: Story = { args: { initialTab: "instructions" } };
|
||||||
|
export const Advanced: Story = {
|
||||||
|
args: { initialTab: "advanced" },
|
||||||
|
parameters: { paperclip: {
|
||||||
|
capabilities: ["agents/instructions"], status: "current", implementation: "mixed", boundary: "fixtures",
|
||||||
|
notes: "Production YAML editor inside the settings preview shell. Mutable fixtures simulate saves and revision conflicts; server authorization and secret restoration require integration verification.",
|
||||||
|
} },
|
||||||
|
};
|
||||||
export const Skills: Story = { args: { initialTab: "skills" } };
|
export const Skills: Story = { args: { initialTab: "skills" } };
|
||||||
export const Runtime: Story = {
|
export const Runtime: Story = {
|
||||||
name: "Harness / Runtime",
|
name: "Harness / Runtime",
|
||||||
|
|||||||
Reference in new issue
Block a user