mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
Share the external Runner lifecycle and add strict Muse contracts (#16444)
Add the external Runner foundation with distinct Dot/Grok V6, canonical home-mode V7 and Muse V8 contracts. Keep generated schemas and profiles synchronized. Bound default question-answer command IDs in a disjoint namespace while preserving existing IDs and durable replay. Preserve canonical migration history and add normally generated, replay-safe Muse schema milestones with literal preview-history fixtures. Refs #16444. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4de6335c3b
commit
2cc7992369
95 files changed
+12594
-1600
No files matched your search
+6
-1
@@ -37,13 +37,18 @@ packages/paperclip-runner/scripts/*-smoke.mjs
|
||||
# now guards this in PR CI.
|
||||
!packages/paperclip-runner/generated/**
|
||||
# ACPX generation verifies these immutable release attestations as build inputs.
|
||||
# Keep the two declarations while excluding other development fixtures.
|
||||
# Keep current declarations and historical identities while excluding other development fixtures.
|
||||
!packages/paperclip-runner/test
|
||||
packages/paperclip-runner/test/**
|
||||
!packages/paperclip-runner/test/fixtures
|
||||
packages/paperclip-runner/test/fixtures/**
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v17-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v18-muse-historical-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v19-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v19-muse-historical-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v20-identity.json
|
||||
!packages/paperclip-runner/test-fixtures
|
||||
packages/paperclip-runner/test-fixtures/**
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp
|
||||
|
||||
@@ -275,11 +275,18 @@ function assertNoPrWorkflowCredentials(source) {
|
||||
|
||||
test("PR credential guard permits filenames but rejects secret expressions", () => {
|
||||
assertNoPrWorkflowCredentials("run: node --test scripts/docker-compose-secrets.test.mjs");
|
||||
assertNoPrWorkflowCredentials("if: ${{ always() }}\nrun: node --test scripts/docker-compose-secrets.test.mjs");
|
||||
assertNoPrWorkflowCredentials("if: ${{ startsWith(github.ref, 'ref') }}\nrun: 'node --test scripts/docker-compose-secrets.test.mjs'");
|
||||
for (const source of ["env: ${{ secrets.EXAMPLE }}", "env: ${{ secrets['EXAMPLE'] }}", "env: ${{ toJSON(secrets) }}", "env: ${{ format('}}', secrets.EXAMPLE) }}",
|
||||
"# Expressions start with '${{'.\nTOKEN: ${{ secrets.PR_TOKEN }}",
|
||||
"name: Example # Expressions start with '${{'.\nTOKEN: ${{ secrets.PR_TOKEN }}"]) {
|
||||
assert.throws(() => assertNoPrWorkflowCredentials(source));
|
||||
}
|
||||
for (const source of [
|
||||
"${{secrets.EXAMPLE_TOKEN}}", '${{ secrets["EXAMPLE_TOKEN"] }}',
|
||||
"${{ failure() && secrets.EXAMPLE_TOKEN }}", "${{\n secrets.EXAMPLE_TOKEN\n}}",
|
||||
"${{ format('{0}', secrets.EXAMPLE_TOKEN) }}", "HONEYCOMB_CI_API_KEY",
|
||||
]) assert.throws(() => assertNoPrWorkflowCredentials(source));
|
||||
assert.throws(() => assertNoPrWorkflowCredentials("HONEYCOMB_API_KEY: example"));
|
||||
assert.throws(() => assertNoPrWorkflowCredentials("${{ format('secrets.EXAMPLE', 'literal') }}"));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current integration (2026-10-08): **Copilot profile v17 remains pending** with
|
||||
Current integration (2026-10-15): **Copilot profile v20 remains pending** with
|
||||
digest `sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b`.
|
||||
Current installation verification requires this generated v20 identity. This
|
||||
source identity declaration supplies no new paid qualification.
|
||||
|
||||
Both profile v19 identities remain historical: upstream credential-observer
|
||||
`sha256:38abf359adeef43f1f19230245c6e2051c55fdd79571882ef6f00b7cb2958d93` and Muse
|
||||
`sha256:2fec061f29f9cac4f6aa70f9329e1b27ce0c9cf4825030fdbb78979ced9bc201`.
|
||||
Neither is the current launch identity.
|
||||
|
||||
Historical integration (2026-10-10): **Copilot profile v18 remained pending** with
|
||||
digest `sha256:5e79c2b1a42b98a545bafe9e93f8c6b6c423bec1c18dc1f0a3cf48477b757389`.
|
||||
Muse's external-provider protocol adds shared generated schema and validator
|
||||
sources to the execution identity. Version 18 binds those two source hashes;
|
||||
the Copilot executable, policies and receipt implementation are unchanged.
|
||||
At that point, the v17 declaration was historical and its warm sessions were
|
||||
rejected by the candidate. This identity update supplied no new paid qualification.
|
||||
|
||||
Historical integration (2026-10-08): **Copilot profile v17 remained pending** with
|
||||
digest `sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f`.
|
||||
It binds master's shared protocol-validation sources without changing the
|
||||
executable or supplying new paid qualification. Historical checkpoints below
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Bring personal Muse onto the shared external Runner
|
||||
|
||||
Date: 2026-10-09. Status: implementation plan; the port and live qualification have not started.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Keep Muse's proven receiver transport, but move task execution onto the external-provider Runner path now used by OpenAI Dot. Reuse Dot's invitation experience, durable operation receipts, controller recovery, native completion, and Cloud execution support. Do not restore a second Muse-specific Runner lifecycle.
|
||||
|
||||
This connects the user's existing **personal Muse at muse.ai**, not a coding CLI or a model API. Muse keeps its own tools and permission system. Paperclip supplies company identity, task collaboration, execution authority, and durable delivery.
|
||||
|
||||
The work needs a selective port onto current master. The prior Muse implementation predates the merged MCP foundation, the new agent lifecycle, and several Dot improvements. A wholesale merge produces 82 conflicted files, including generated contracts and migration metadata.
|
||||
|
||||
## Baseline and evidence
|
||||
|
||||
| Source | Inspected state | What it establishes |
|
||||
| --- | --- | --- |
|
||||
| Paperclip | `origin/master` at `417db14e7dfce7109e254a6f2cf410b6103fd037` | Current implementation baseline; new worktree/branch `codex/muse-dot-refresh` starts here. |
|
||||
| Earlier Muse work | `codex/muse-runner` preserved at snapshot `1dfdc7b7dbae9debe8ca86bbcdb4492dc2b277d1` | Recoverable implementation, tests, synthetic task evidence, and qualification tooling. It has **not** been merged into this baseline. |
|
||||
| Paperclip Cloud | `origin/master` at `282f5b2f` | Current tenant routing and Dot protocol ingress; inspected without changing the existing Cloud checkout. |
|
||||
| Dot implementation | [#15402](https://github.com/paperclipai/paperclip/pull/15402), [#15414](https://github.com/paperclipai/paperclip/pull/15414), [#15581](https://github.com/paperclipai/paperclip/pull/15581), [#15691](https://github.com/paperclipai/paperclip/pull/15691), [#15722](https://github.com/paperclipai/paperclip/pull/15722), [#15734](https://github.com/paperclipai/paperclip/pull/15734) | Merged delivery, governed tools, invitations, managed Cloud execution, pairing fixes, and provider-native tool guidance. |
|
||||
| Dot live evidence | [Current Dot documentation](../openai-dot-runner.md) and the existing Dot integration chat | Previously recorded real Dot assignment, document, proactive intake, and Cloud walkthrough. This investigation did not rerun those live tests. |
|
||||
| Muse live evidence | Stopped personal-agent experiment | Four unattended replies with a 60-second hook. Connectivity proof only; no five-second, full-task, or 24-hour qualification. |
|
||||
|
||||
The old Muse worktree was removed by startup cleanup. Its Git snapshot was recovered and the branch moved forward to preserve it. Primary Paperclip and Cloud checkout edits were left untouched. Older Muse test results describe that snapshot only; they do not validate the proposed port.
|
||||
|
||||
## What Dot has that Muse should inherit
|
||||
|
||||
| Area | Current Dot implementation | Muse decision |
|
||||
| --- | --- | --- |
|
||||
| Invitation | New Agent → Invite an external agent; server-owned unfinished invitation, normal hire approvals, live verification | Add a Muse preset to the real shared flow. Keep Muse's name/role and its three distinct verification milestones. |
|
||||
| Identity | Dedicated agent grant/resource, authorizing user recorded, company and agent binding | Reuse the authorization boundaries and credential primitives. Keep Muse's ticket-to-private-client setup; do not require ChatGPT plugin/OAuth UI. |
|
||||
| Delivery | Durable assignment, ordered mailbox, stable operation IDs and payload digests | Share the lifecycle and receipt logic; adapt mailbox notification to Muse's opaque hook signal. |
|
||||
| Execution | Rust Runner owns acceptance, tools, checkpoint recovery, and canonical completion | Replace the old Muse HTTP-polling Runner backend with this external-provider bridge. |
|
||||
| Native authority | Run/session/turn, assignment revision, controller lease, task ownership, approvals, pause, and budgets checked | Use the current authority checks, including private task/project visibility. The old Muse ownership checks alone are insufficient. |
|
||||
| Follow-up | Comments reach the current assignment; delivery is consumed only after an exact task-history receipt | Reuse for ordinary comments; retain structured clarification answers through the existing interaction contracts. |
|
||||
| Proactive work | Idle capability discovery and `request_turn`, admitted through ordinary task/Runner scheduling | Add this admission path while retaining the original Muse requirement for permitted idle read/search/create/comment operations. |
|
||||
| Cloud | Managed Runner sandbox, artifact capability checks, authenticated reconnect, bounded tenant protocol ingress | Extend the same paths for Muse; no local-process fallback on a Cloud control-plane host. |
|
||||
| Tool guidance | Explicitly permits the external agent's own apps/tools; setup restrictions do not become permanent | Reuse generic external-agent guidance. Muse first release exposes only first-party Paperclip capabilities. |
|
||||
| Usage | Unknown provider usage/cost remain unavailable | Keep this behavior; Paperclip budget gates do not imply control over Muse's independent spending. |
|
||||
|
||||
Two Dot policies must **not** become Muse defaults:
|
||||
|
||||
- Dot now uses rotating, non-expiring refresh credentials. Muse retains 15-minute access and rotating refresh credentials with a 30-day inactivity expiry.
|
||||
- Dot can fence a terminal assignment and admit later work even when external stopping is unconfirmed. Muse must retain an overlap block after uncertain claimed work. A fence revokes authority; it does not prove the remote work stopped.
|
||||
|
||||
Dot's MCP Events webhook is its own transport capability. Nothing inspected proves personal Muse supports the same event subscription. Use the demonstrated managed-hook mechanism unless a separate capability test establishes an equivalent push path.
|
||||
|
||||
## Target architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Paperclip invitation and agent settings] --> C[Company and agent connection]
|
||||
S[Task scheduler and admission] --> B[Shared external-provider broker]
|
||||
C --> B
|
||||
B <--> R[Native Rust Runner in Paperclip infrastructure]
|
||||
R <--> T[Native semantic tools and finalization]
|
||||
B <--> M[Durable mailbox and operation receipts]
|
||||
M --> H[Muse opaque signal endpoint]
|
||||
H --> D[Muse managed detector]
|
||||
D --> W[Awakened Muse worker]
|
||||
W <--> A[Muse authenticated client endpoints]
|
||||
A <--> B
|
||||
P[Proactive Muse conversation] --> A
|
||||
P --> I[Permitted idle first-party task operations]
|
||||
I --> C
|
||||
```
|
||||
|
||||
A signal contains no task content or worker credential. An empty check never wakes the model. The authenticated worker claims one assignment, accepts it through the Runner, and submits operations with stable identities. Transport receipt, worker progress, accepted result, and native task finalization remain separate facts.
|
||||
|
||||
Runner infrastructure is allocated for admitted work, not for an idle connection. A managed Runner sandbox does not itself grant Muse workspace commands or third-party Paperclip tools.
|
||||
|
||||
### A small shared core, with provider-specific transport
|
||||
|
||||
1. Generalize the existing [external-provider contract](../../packages/paperclip-runner/src/contracts/external-provider.ts), [Dot Runner driver](../../packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.ts), and [Rust backend](../../packages/paperclip-runner/runner/crates/runner-core/src/dot_provider_backend.rs). Extract the common acceptance/tool/progress/finish/recovery state machine. Keep Dot's existing driver kind, command IDs, wire revision, and checkpoint reader compatible.
|
||||
2. Extract authority validation, durable operation admission/settlement, mailbox ordering, and follow-up handling from the [Dot broker](../../server/src/services/dot-runner-broker.ts) behind explicit provider binding/storage adapters. Do not build a general plugin framework or generic ORM. Shared code receives a validated agent principal; the transport remains responsible for authenticating its own credential type.
|
||||
3. Keep Dot's existing tables and deployed OAuth grants intact. Port Muse connection/token and delivery records additively, with the fields needed for binding generation, assignment revision, controller generation, semantic digest, source event, and consumed-input receipts. Both providers exercise the same broker invariants through their storage adapters. No bulk rename or migration of live Dot assignments is required for this release.
|
||||
4. Supply a Muse adapter for signal, pairing, refresh, authenticated claim, operations, input/control reads, and connection health. Retain the old detector/client assets where appropriate, but update them to the shared operation protocol. Remove the old separate Muse Runner transport credential and HTTP-polling backend where the existing PRP command lane replaces them.
|
||||
5. Register `provider: "muse"` in the current native contract generation, provider resolver, capability manifest, artifact selection, recovery, and accounting projections. Generate current contracts; do not copy the old V5 contract edits or old generated artifacts. Remote Runner artifacts must explicitly advertise Muse support before launch.
|
||||
|
||||
Provider policy is explicit and server-controlled: credential inactivity, run lease bounds, stop confirmation, and readiness differ. Muse credentials must not authenticate as Dot or through the human `/mcp/paperclip` connection. Dot credentials must not access a Muse binding.
|
||||
|
||||
### Authorization and lifecycle details
|
||||
|
||||
- **Pairing:** operator creates an agent/company-bound invitation through the agent lifecycle module. Preserve hire approval and the authorizing user. Use a ten-minute, single-use ticket; Muse exchanges it directly into private storage. No durable secret in clipboard instructions, browser persistence, model-visible output, hook code, or logs.
|
||||
- **Refresh:** rotate atomically; replay revokes the credential family. Serialize refresh in the client so concurrent Muse conversations do not accidentally reuse a rotated token. A public detector poll does not extend authenticated inactivity. After 30 inactive days, reconnect is required.
|
||||
- **Idle authority:** identify the connection; list/search/read authorized company work; create tasks and comment under normal agent permissions. Stable write receipts prevent duplicates. Task creation does not authorize execution. A proactive request to perform work uses normal admission and returns a visible task/run receipt.
|
||||
- **Active authority:** validate company, agent, binding generation, assignment revision, run/session/turn, current controller lease, checkout/review ownership, pause/approval/budget gates, and task/project ACLs on every execution operation. Do not let idle endpoints complete work, publish run deliverables, or change execution state.
|
||||
- **Claim versus accept:** claim reserves the sole assignment; Runner acceptance grants execution. If the acceptance response is lost, retry the same operation. Never treat a repeated wake as permission to repeat prior effects.
|
||||
- **Completion:** Muse invokes the native completion operation, observes the accepted canonical report, then submits that report through the external finish operation. A webhook/HTTP callback cannot directly complete the task. Pending semantic tools block completion.
|
||||
- **Stopping:** revoke run authority immediately and deliver a durable best-effort stop. Keep an uncertain assignment blocked across server restart, reconnect, and credential rotation. Clear it only after a run-specific worker stop confirmation with defined semantics, or a separately recorded operator attestation. Display those evidence types distinctly; neither claims all private Muse activity stopped. Merely seeing the stop message is not confirmation.
|
||||
- **Disconnect:** revoke access, refresh, and active authority immediately. The signal tells the detector to disable itself; cleanup remains unconfirmed until observed. Turning off the experimental flag must still permit bounded cleanup/status handling without admitting new work.
|
||||
- **Wake suppression:** new assignments, unanswered inputs, and external follow-ups may wake Muse. Its own progress, comments, acceptance, and completion acknowledgements do not create loops. Read/consumption receipts, not signal delivery, retire inputs.
|
||||
|
||||
## User journey and UI changes
|
||||
|
||||
Use [ExternalAgentInviteContent](../../ui/src/components/new-agent/ExternalAgentInviteContent.tsx), [ExternalAgentInviteDialog](../../ui/src/components/new-agent/ExternalAgentInviteDialog.tsx), and the existing [guided Storybook journeys](../../ui/storybook/stories/external-agent-invite/journeys.stories.tsx). Generalize their provider presentation and connection watcher; do not duplicate the dialog or revive the earlier standalone Muse wizard.
|
||||
|
||||
| Moment | Muse experience | Required change |
|
||||
| --- | --- | --- |
|
||||
| Choose | New Agent → Invite an external agent → **Muse — Personal agent** | Add a gated preset explaining that it connects the user's existing Muse and capabilities. Keep Dot/Hermes/Other behavior unchanged. |
|
||||
| Identify | Name and role, then Continue | A compact step using existing fields; company-scoped non-secret draft survives navigation/reload. No model, computer, repo, or API-key fields. |
|
||||
| Connect | Copy setup instructions; Open Muse; paste in the current conversation | Ticket is transient. Server owns the resumable invitation and normal approval state. An expired prompt can be renewed without revoking a concurrent successful pairing. |
|
||||
| Approve | Muse requests its normal hostname approval | Explain the standing approval covers the hostname. Paperclip cannot grant it or infer it from silence. |
|
||||
| Verify | **Muse paired → Receiver detected → Background reply verified** | Start one idempotent background challenge after receiver detection. Require a separate authenticated worker reply. Agent lifecycle readiness must also succeed. |
|
||||
| Work | Assign an ordinary task; **Waiting for Muse** until claimed | Show submitted progress, structured questions, documents, and native run outcome in the existing task thread. No invented private tool trace. |
|
||||
| Maintain | Connection status, Test connection, Repair setup, Pause, Disconnect | Show last signal contact, authenticated worker activity, last verified reply, installed version, and current assignment/stop status. |
|
||||
|
||||
Reuse the current server-owned invitation recovery pattern from [dot-invitations.ts](../../server/src/services/dot-invitations.ts), parameterized by provider and operator/company. Resume only that operator's unfinished invitation. Preserve the exact connection reference and configuration revision when rotating setup. Secrets must not enter query caches or draft persistence. The footer remains one row, with Back/Save & exit on the left and the primary action on the right.
|
||||
|
||||
For errors, distinguish a failed UI status request, unreachable Paperclip hostname, expired credentials, stale detector, and missing worker reply. Show **Open Muse** for a reported permission request. Otherwise show **No recent response**; do not label a silent worker as a permission failure. Explain stop-unconfirmed and cleanup-unconfirmed states with one clear repair action.
|
||||
|
||||
Reuse [external-agent-guidance.ts](../../packages/shared/src/external-agent-guidance.ts): Paperclip's catalog describes this connection, not everything Muse can do. Muse may use its own approved apps and tools. Installation must register discoverable client instructions in Muse's supported persistent environment; prove proactive use from another conversation rather than assuming installation establishes discoverability.
|
||||
|
||||
## Cloud and protocol routing
|
||||
|
||||
Extend Cloud's existing exact method/path tenant protocol allowlist, next to `isTenantDotProtocolRequest` in `paperclip-cloud/src/service/app.ts`. Route only the versioned receiver assets and bounded pairing/refresh/signal/claim/operation/control/agent-gateway endpoints that the final Muse protocol needs. Keep board management behind normal tenant browser authentication.
|
||||
|
||||
Host resolution selects the tenant; tenant-issued credentials select the company/agent. Strip spoofed Cloud identity headers. Do not add an arbitrary `/api/muse/*` proxy exemption, route through the human MCP identity, or require a logged-in Cloud browser for receiver calls. Test invalid methods, encoded path variants, cross-tenant credentials, revoked access, unavailable tenants, and reconnect after a Cloud rollout.
|
||||
|
||||
Use current managed remote execution and artifact verification from [native-session-executor.ts](../../server/src/services/native-runtime/native-session-executor.ts). Preserve target-owned provider checkpoints, process identity checks, and controller reconnect grace. Missing state after dispatch requires reconciliation, not a fresh remote run.
|
||||
|
||||
Five-second polling is 17,280 empty checks per day per continuously idle connection. Measure ingress load, database writes, health timestamp accuracy, and interaction with Cloud sleep/wake behavior. Bound and coalesce health persistence without hiding stale contact; empty checks must not allocate Runner sandboxes. A capacity or sleep conflict is a release decision, not a reason to silently substitute a slower hook.
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
### 1. Extract and protect the current Dot path
|
||||
|
||||
Scope: shared external-provider contracts, Rust state machine, TS driver, broker authority/receipts, and provider policy. Add Muse-compatible extension points while keeping Dot's public tools, OAuth policy, storage, and checkpoint compatibility unchanged.
|
||||
|
||||
Acceptance: existing Dot protocol/onboarding/invitation/driver tests plus Rust tests pass; a captured Dot checkpoint and in-flight operation recover across the extraction; no new duplicated execution state machine. Include a regression proving Dot retains its own refresh/stop policy.
|
||||
|
||||
### 2. Port the Muse vertical slice
|
||||
|
||||
Scope: selective source recovery from `codex/muse-runner`; new migrations generated against current master; Muse binding/token and transport adapter; updated receiver/client; native provider registration and feature gate. Port test intent and fixtures, not historical migration numbers/journals or generated snapshots.
|
||||
|
||||
Acceptance: simulated receiver pairs, passes an independent background challenge, claims a real task, publishes a document through native tools, and finalizes through the same Runner used by Dot. Verify both local and managed-launch fixtures. A submitted result without native completion must fail. Receiver polling alone must never make setup ready.
|
||||
|
||||
### 3. Complete collaboration, recovery, and stopping
|
||||
|
||||
Scope: permitted idle operations, proactive `request_turn`, structured clarification, current-assignment follow-ups, reconnect, credential rotation, cancellation, revocation, and uncertain-stop blocking. Reuse [Dot follow-up handling](../../server/src/services/dot-assignment-follow-up.ts) and native question response delivery.
|
||||
|
||||
Acceptance: two sequential task assignments; a question answered in Paperclip resumes the original assignment; comments consumed once; agent attribution and authorizing-user audit; a cancelled claimed assignment cannot be replaced while stopping is unconfirmed. Restart, controller takeover, and lost responses never duplicate uncertain side effects.
|
||||
|
||||
### 4. Finish invitation, settings, and Cloud integration
|
||||
|
||||
Scope: real shared invitation components, Muse connection settings, API/shared contract synchronization, lifecycle integration, guided Storybook, current Cloud allowlist, and managed Runner artifact support.
|
||||
|
||||
Acceptance: hire approval, name/role draft, reload/resume, concurrent setup windows, unreachable host, reported permission request, expired connection, stale receiver, and unconfirmed stopping all have tested states. Verify exact Cloud tenant ingress and sandbox cleanup. No old local-only launch fallback, secret persistence, or extra wizard footer.
|
||||
|
||||
### 5. Qualify personal Muse before enabling it
|
||||
|
||||
First prove a real research/document task, a clarification answered in Paperclip, a follow-up assignment, and proactive read/create/comment from a fresh Muse conversation. Then run a new bounded 24-hour qualification with at least 20 unattended samples and sustained idle periods. The previous experiment remains stopped; none of its replies count toward this run.
|
||||
|
||||
Use the recovered qualification tooling after adapting it to the new receipts and generation identities. Schedule deterministic sample slots; never enqueue over active or uncertain work. A simulated worker or direct chat intervention cannot make an unattended sample pass. Preserve intervention and outage classifications in secret-free evidence.
|
||||
|
||||
Record requested hook interval and actual signal-contact gaps separately. Predeclare the five-second qualification threshold (the earlier harness used at least 90% of steady-state gaps at seven seconds or less); retain a sustained idle window of at least two hours. Separate scheduler queue-to-offer, queue-to-authenticated-claim, claim-to-accepted-result, and queue-to-native-finalization latency. Report counts, tails, missing claims, pending results, and failures, not only averages.
|
||||
|
||||
At the deadline, revoke qualification authority and request detector cleanup even after a watcher error or restart. Persist the deadline and cleanup state; retry failed cleanup and show unconfirmed remote cleanup honestly. Failure to sustain faster polling blocks release pending an explicit product decision; there is no hidden 60-second fallback.
|
||||
|
||||
## Verification and release gates
|
||||
|
||||
| Boundary | Required proof |
|
||||
| --- | --- |
|
||||
| Identity | Cross-company/agent/provider/tenant denial; expired/replayed pairing; refresh rotation and replay; removed operator access; hire approvals; agent attribution; private task/project ACLs. |
|
||||
| Delivery | Duplicate signals; competing claims; one active assignment; reordered mailbox commits; multiple HTTP replicas; stable input/operation IDs; bounded payloads; no model wake on empty/error polls. |
|
||||
| Recovery | Server restart and controller takeover; delayed results; lost acceptance/tool response; missing checkpoint; disconnect/reconnect; no automatic replay of claimed uncertain work. |
|
||||
| Lifecycle | Reassignment, pause, company pause, budget stop, cancellation, disconnect, feature disable; authority immediately fenced; stopping blocks overlap until the required evidence exists. |
|
||||
| Completion | Native semantic receipt and canonical report required; no completion through idle API or bare callback; questions/documents/deliverables use existing contracts. |
|
||||
| UI | Real-component Storybook journeys, setup reload/resume, concurrent pairing, approval pending, receiver-only contact, expired credentials, missing reply, stop/cleanup unconfirmed. |
|
||||
| Cloud | Bounded anonymous protocol routes, no browser-session dependency or identity spoofing, correct tenant, verified Muse-capable Runner artifact, reconnect and sandbox cleanup. |
|
||||
| Personal Muse | Research/document, clarification, follow-up, fresh-conversation proactive use, then 24 hours / ≥20 unattended samples with measured faster cadence. |
|
||||
|
||||
Run targeted server protocol, receiver/client, native Runner/Rust, UI, and Cloud routing tests first. Before implementation handoff, run repository-required `pnpm -r typecheck`, `pnpm test:run`, `pnpm build`, token gates, and Storybook; run the companion Cloud checks for changed ingress. Record any failures and their scope. Re-run Dot's relevant tests alongside Muse throughout the extraction.
|
||||
|
||||
Keep the Muse provider experimental and off until the live gates pass. Do not include third-party Paperclip tool grants, workspace command access, a Muse cookie login, or a new public model API in this release. Dot's additional capabilities can be evaluated later without broadening this port.
|
||||
|
||||
## Next concrete step
|
||||
|
||||
Start with the shared external-provider extraction and Dot regression fixtures, then port the Muse vertical slice. This plan does not install a receiver, provision new personal Muse access, or restart a live test. Make the receiver assets, permissions, qualification schedule, and cleanup reviewable before the later live-installation step.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Personal Muse on the shared external Runner
|
||||
|
||||
Date: 2026-10-10. Implementation architecture; experimental and disabled by default.
|
||||
|
||||
This refines [the accepted product plan](2026-10-09-muse-on-shared-external-runner.md) against origin/master `19e9facc5bd459ab790d53373612cfde1a2c9ec3`. It connects the personal Meta Muse, not Muse Code. The old experiment remains stopped. Its four replies establish connectivity only.
|
||||
|
||||
## User outcome and caller usage
|
||||
|
||||
An operator chooses “Muse — Personal agent,” supplies name/role, and copies a short-lived setup instruction into their current Muse conversation. Muse approves the Paperclip hostname through its own UI, installs a small private client and managed detector, and exchanges the ticket itself. Paperclip shows paired, receiver detected, and independent background reply verified as separate evidence. Existing hiring approval and lifecycle readiness remain required. A normal assigned task uses the native Runner, existing questions/documents/deliverables and finalizer. Idle Muse can identify/read its permitted company work, create tasks and comment with named-agent attribution.
|
||||
|
||||
```ts
|
||||
// Transport owns parsing/authentication, not lifecycle decisions.
|
||||
const subject = await museIdentity.authenticate(request);
|
||||
return externalAgents.act(subject, decodeMuseCommand(request.body));
|
||||
|
||||
// The existing queue still owns issue -> wake -> run locking and claim policy.
|
||||
await withExistingQueueClaimLocks(tx, async () => {
|
||||
return withExternalAdmissionGuard(tx, companyId, agentId, async () => {
|
||||
await assertNoExternalOverlap(tx, companyId, agentId);
|
||||
return commitExistingQueueClaim(tx);
|
||||
});
|
||||
});
|
||||
|
||||
// The native question bridge retains its actual session signature.
|
||||
await session.resolveRuntimeRequest({ requestId, turnId, resolution });
|
||||
// requestId is the native runtime request ID, NOT question_<interactionId>.
|
||||
```
|
||||
|
||||
The installed client exposes safe, durable work/open, operation/receipt, ask/answer and completion commands. It journals stable request IDs before network calls and exact receipts before returning success. Completing work first obtains the native paperclip_finish/paperclip_block receipt, then submits exactly that canonical report to the external finish operation.
|
||||
|
||||
## Synthesis decision
|
||||
|
||||
Two independent whole-shape sketches compared a transactional domain broker with a pure reducer/effect-shell architecture. Root review preferred the broker 4.15/5 versus 4.075/5; a separate cross-judge preferred it 3.60/5 versus 3.50/5. These measure design preference, not implementation confidence. Both sketches used the same candidate model; only judgment used a different model. No sketch or synthetic fixture qualifies personal Muse.
|
||||
|
||||
Choose the deeper transactional broker boundary, with closed Dot/Muse storage adapters and a shared native external-provider engine. Graft separate worker uncertainty and native-effect uncertainty barriers from the alternative. Reject a universal reducer/effect DSL, relocation of ordinary scheduling into the broker, duplicated generic outboxes and a second question bridge. Private effect inventory cannot be certified by a client that does not observe Muse's own tools.
|
||||
|
||||
## Boundaries and ownership
|
||||
|
||||
- `server/src/modules/external-agents/`: public act/inspect/attach/stop domain boundary and closed provider storage policies. Dot's existing service export is a compatibility facade. Identity, receiver transport and ordinary scheduler remain distinct owners. Routes cannot import private storage helpers.
|
||||
- `packages/paperclip-runner/`: one TS driver and one Rust external execution engine. Thin provider wrappers/codecs retain deployed Dot identities. No URL, connection token, cookie, signal poll or task callback enters Runner.
|
||||
- `server/src/services/muse-identity.ts`, `muse-receiver.ts`, `muse-invitations.ts`, `routes/muse.ts`: ticket/access/refresh/cleanup identities, opaque receiver signals, existing hire/lifecycle setup and bounded transport.
|
||||
- `packages/shared/src/muse-protocol.ts`: finite versioned wire contract and exact public route manifest. `muse-invitations.ts`: shared nonsecret UI DTOs. Provider/tool profiles are closed positive lists.
|
||||
- Existing native question bridge/response outbox owns question cards and saved answers; existing queue owns task/run admission; native finalizer alone completes work.
|
||||
- Existing external invitation components/settings own UI. No separate Muse wizard or human MCP identity.
|
||||
- Cloud owns exact tenant protocol ingress only. It strips spoofable identity headers and never implements agent authorization or uses a local control-plane Runner fallback.
|
||||
|
||||
Use explicit named operations and types rather than generic CRUD/write-set escape hatches. Reuse current domain authority and semantic receipts. Where existing Dot operations already persist commands, those rows are the outbox; add no second journal of the same fact.
|
||||
|
||||
## Execution and compatibility contract
|
||||
|
||||
Preserve Dot V6, `dot-provider-state.json`, `paperclip.runner.dot-provider-state.v1`, `openai_dot_mcp`, `dot-mcp-v1`, existing command/event identities and OAuth behavior. Dot's nonexpiring refresh and fence-only remote stop policy must not change. Dot's runtime-question capability remains disabled.
|
||||
|
||||
Muse has an explicit V7 native execution input, `muse_external` driver, `muse-v1` bridge, and advertised `muse_external_v1` artifact capability. Validate capability before managed launch. Recovery requires the exact target-owned checkpoint/process/session; missing checkpoint after dispatch requires reconciliation. Never start a new target to repeat uncertain work. Usage and cost remain unavailable/null.
|
||||
|
||||
Extend external operations with request_user_input and consume_input. request_user_input persists a real pending runtime request and emits `runtime_request.created` with `paperclip.runtime_request.v2`, which the existing bridge projects. The saved answer travels through the existing `request.resolve` command. The existing local bridge, runtime resolution helper and session input carry an optional stable command ID; Muse uses it for its durable PRP lane while other providers retain their behavior. Command identity `question_<interactionId>` remains distinct from the original request ID. Rust checkpoints an exact input reference/digest and emits input_available; broker persists delivery before ACK. The client persists the canonical answer into the same run's continuation before consume_input. Pending/unconsumed input blocks finish. Fetch, transport ACK and prepared response do not prove ingestion. No different-turn injection.
|
||||
|
||||
The active first-party profile excludes connector and workspace command grants and includes native task/document/collaboration/completion operations and the question path. Muse may use its own approved tools. Replay rechecks current authority before returning data or dispatching effects.
|
||||
|
||||
## Authority and durability
|
||||
|
||||
Use a dedicated `AgentConnectionSubject` carrying provider, company, agent, binding/generation and authorizing user. Never cast it into a Dot OAuth grant or human MCP identity. Idle reads/mutations require current agent and authorizer visibility; active projections/tools also require the run responsible user's visibility when different. Lists/search use SQL ACL predicates, not after-the-fact page filtering. Persist authorizer in audit, actor as named agent.
|
||||
|
||||
The idle catalog is exactly identify, task list/search/read/history/document-read, task create and task comment. request_turn is ordinary visible intake/scheduling, not an execution grant. Task status/completion/deliverable/configuration/connector/transfer operations require proper native execution and are denied idle. Agent callers cannot write connection configuration through create/update/import/rollback paths.
|
||||
|
||||
Preserve one live assignment per connection and an agent-wide barrier across generations, reconnects and provider changes. Stable assignment/request/event IDs and digests are immutable. Concurrent claim has one winner. Native operation rows progress reserved -> dispatched/pending -> settled/rejected/unknown. Unknown never automatically redispatches; only authoritative reconciliation can attach its original result.
|
||||
|
||||
Retain distinct offered, authenticated claim, native acceptance, progress, accepted result, finalization and stop facts. A callback cannot complete a task. Self-authored progress cannot create wake loops. Follow-up comments become consumed only through the exact successful native history receipt, not mailbox fetch.
|
||||
|
||||
### Transaction and lock constraints
|
||||
|
||||
Do not relocate the scheduler. Add a narrow database transaction admission/hold guard to existing claim branches, including no-issue and review claims. The existing process-local agent-start mutex is insufficient across replicas. Preserve queue issue -> wake -> run ordering and existing checkout/review predicates. Avoid acquiring binding then issue, since follow-up can already hold issue then binding. The company/agent guard follows existing issue/wake/run locks in queue claims and serializes the claim against connection generation/stop/hold transitions. Existing-issue idle mutations pre-lock that issue (or parent) before guard/binding; generation/stop and native receipt transitions never subsequently mutate an issue in the same transaction. Mailbox-only operations need not acquire the admission guard. Do not call network services under transaction locks. Prove competing claim, follow-up/edit/cancel interleavings and crash after claim before offer before finalizing the exact lock implementation.
|
||||
|
||||
Mailbox readers and writers serialize on the same binding row before allocating/returning a cursor, preventing a later commit from hiding an earlier sequence. Reuse existing Dot journal and mailbox rows; new Muse rows and additive sidecars carry new facts without copying existing assignments.
|
||||
|
||||
## Credentials, client and receiver
|
||||
|
||||
Single-use pairing tickets expire in ten minutes. Access credentials expire in fifteen minutes. Rotating refresh families expire after thirty days of authenticated inactivity; anonymous detector polling does not extend them. Hash credentials at rest; bind to tenant/company/agent/generation. Concurrent refresh is serialized by the private client's OS lock; consumed-token replay revokes the family. Ambiguous lost rotation requires reconnect, never a guessed successful token. Reconnect does not remove uncertainty holds.
|
||||
|
||||
The private Python standard-library client uses 0700/0600 private storage, exclusive lock, atomic writes/fsync, bounded HTTPS without redirects, safe output and persistent stable request journals. A Bash detector uses Muse's demonstrated managed-hook interface. Setup pins versioned asset digests and installs discoverable instructions in Muse's supported persistent environment. The copied instruction contains only a transient ticket and public asset metadata; no durable worker credentials or task content. Detector scripts and wake messages contain only a narrow opaque signal capability/reference, never worker credentials or task content.
|
||||
|
||||
Live discovery on 2026-10-10: personal Muse's managed hooks cannot use secrets, including narrow signal tokens. `hooks.add` accepts only `script_path`, without arguments; five-second checks are supported. The authenticated detector design above is therefore unsupported by the current managed-hook runtime and has no live qualification. A public opaque wake-only revision remains proposed and unapplied. Automatic approval review requires explicit human authorization for that public boundary. No successful real setup or fresh-conversation execution is asserted.
|
||||
|
||||
Request five-second checks. Empty or failed checks never wake the model. Errors back off; HTML/redirects/202/503 are not successful protocol responses. Health uses coalesced per-replica contact buckets with explicitly reported persistence lag; polling is not worker readiness. Qualification retains bounded cadence evidence and fails incomplete evidence rather than concealing lost tails.
|
||||
|
||||
Disconnect immediately revokes normal credentials and leases, persists control and any claimed-work hold, and activates a bounded old-generation cleanup-only capability. That lane cannot read task content, claim, refresh or run tools and remains available when the feature is off. Detector removal acknowledgement is distinct from worker stopping and sandbox cleanup.
|
||||
|
||||
## Stop evidence
|
||||
|
||||
Stopping fences authority immediately and records exact assignment/run/generation/nonce/operation boundary. A claimed assignment remains an overlap barrier across terminal state, restart, reconnect and provider switch.
|
||||
|
||||
Worker reported quiescence, operator attestation and authoritative native effect outcomes are separate facts. An unobserved private-tool inventory, empty manifest, token revocation, process exit or model declaration cannot establish all independent Muse effects stopped. Default to cannot_confirm unless a tested provider mechanism covers the named assignment. An operator may attest the worker facet for that exact boundary; unknown native effects remain an independent block and never become replayable. UI states exactly what is observed; it does not claim all personal Muse conversations stopped.
|
||||
|
||||
## Fixed public transport and UI API
|
||||
|
||||
Protocol version 1 has the following exact Cloud-public method/path set. Paths are immutable; adjacent, encoded/trailing variants and other methods are rejected. Core registers these through the same manifest, Cloud consumes a checked-in generated copy with parity verification.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | /api/muse/v1/assets/1/manifest.json | Public immutable digests/version |
|
||||
| GET | /api/muse/v1/assets/1/client.py | Private worker client source |
|
||||
| GET | /api/muse/v1/assets/1/detector.sh | Managed detector source |
|
||||
| GET | /api/muse/v1/assets/1/instructions.md | Discoverable client instructions |
|
||||
| POST | /api/muse/v1/pair | Single-use ticket exchange |
|
||||
| POST | /api/muse/v1/refresh | Refresh rotation |
|
||||
| GET | /api/muse/v1/signal | Opaque signal; narrow bearer credential |
|
||||
| POST | /api/muse/v1/commands | Strict closed worker command union |
|
||||
| POST | /api/muse/v1/queries | Strict closed worker read union |
|
||||
| POST | /api/muse/v1/cleanup | Old-generation cleanup-only union |
|
||||
| POST | /api/muse/v1/detector-cleanup | Narrow detector-removal receipt |
|
||||
|
||||
Bodies and responses have explicit bounds; secrets are never URL parameters or logs. Cloud strips identity headers, selects the correct tenant, and passes through tenant authentication failures. Board endpoints remain ordinary operator-authorized API endpoints:
|
||||
|
||||
- POST `/companies/:companyId/muse-invitations` with name, role; returns resumable approved/pending hire.
|
||||
- GET/POST `/companies/:companyId/agents/:agentId/muse-binding` for health and compare-and-replace pairing.
|
||||
- POST sibling `/verify`, `/revoke`, `/attest-stop` with exact binding/execution/version identity as applicable.
|
||||
|
||||
Control owner publishes shared DTOs first and coordinates actual closed command/query schemas with Runtime before implementing both sides. Do not implement a parallel untyped protocol. A revised internal signature is permissible when current code requires it; changes to invariant or public route set require architecture update and parity tests.
|
||||
|
||||
## UI and release gates
|
||||
|
||||
Reuse ExternalAgentInviteDialog/Content and one-row step footer. Add name/role nonsecret draft scoped to company/operator; never persist ticket/prompt/token in browser storage or query cache. Server resume is operator/company scoped and stale-window ticket replacement uses binding/config revision CAS. Readiness requires all three evidence milestones plus lifecycle/approval. Explain hostname-wide standing permission; never infer permission failure from silence. Use “No recent response” and Open Muse when appropriate.
|
||||
|
||||
Settings show last persisted receiver contact, authenticated worker activity, last verified reply, version, live/uncertain work and cleanup. Actions test, repair, pause, disconnect and narrowly scoped stop attestation. Show unavailable usage and best-effort/unconfirmed remote stop. Use real-component Storybook journeys and token-only styles.
|
||||
|
||||
Conservatively inhibit Cloud sleep while a receiver can poll or unresolved assignment/input/operation/stop/cleanup remains, even with the feature disabled. Measure polling cost before release (17,280 checks/day at five seconds). No Cloud local execution fallback.
|
||||
|
||||
Validation order: existing Dot compatibility; synthetic Muse pair/background challenge/task/document/native finalization; authority/races/restart/unknown/stop/clarification/proactive tests; real components and Cloud ingress; required typecheck/test/build/token checks. Then actual personal Muse task, Paperclip clarification, follow-up and fresh-conversation proactive behavior. Finally a new bounded 24-hour qualification, at least twenty unattended samples, at least two hours sustained idle, requested five seconds and at least 90% steady gaps <=7 seconds. Report queue-to-claim and full response separately with missing evidence visible. Durable deadline cleanup must survive watcher/server restart. No silent sixty-second fallback. Keep default-off until all gates pass; elapsed time and successful simulations are not live qualification.
|
||||
|
||||
## Implementation partitions
|
||||
|
||||
Runtime owns Runner TS/Rust/contracts/generation, native runtime integration, heartbeat claim guard call sites, provider/tool/runtime/artifact registration. Control owns DB/migration, shared Muse DTO/protocol/feature settings, broker/identity/receiver/client/setup/routes/invitations/lifecycle and admission guard implementation. UI owns ui/**. Root owns Cloud ingress, integrated review/qualification tooling and cross-boundary fixes. Writers use isolated worktrees and exchange early contract commits; one DB migration owner and one Runner generated-contract owner.
|
||||
|
||||
If independent implementation repeatedly requires alternate lifecycles, hidden policy copies or type escape hatches, stop that construction, re-ground the failing boundary and subtract the duplication before continuing.
|
||||
|
||||
|
||||
Implementation qualification evidence uses the original persisted 24-hour window. Idle coverage is bounded by persisted receiver contacts and excludes native runs, live assignments and pending input. A receiver gap longer than the 30-second contact persistence interval breaks idle coverage; the separate cadence check still requires at least 90% of gaps at or below seven seconds. Incomplete buckets, lost ingress tails or reader overflow leave evidence incomplete.
|
||||
@@ -1,5 +1,5 @@
|
||||
export type PaperclipRunnerProvider =
|
||||
"codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot";
|
||||
"codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot" | "muse";
|
||||
|
||||
export type CodexPermissionMode = "never" | "on-request" | "untrusted";
|
||||
export type OpenCodePermissionMode = "allow" | "ask" | "deny";
|
||||
@@ -104,6 +104,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
|
||||
"AWS AgentCore runs non-interactively under its qualified harness profile and Paperclip policy.",
|
||||
},
|
||||
grok_bot: { configurable: false, defaultMode: "provider-managed", options: [], description: "Grok Bot uses first-party Paperclip tools and retains its native apps. Grok manages its model; spending, live steering, provider transcript streaming and remote interruption are unavailable." },
|
||||
muse: { configurable: false, defaultMode: "provider-managed", options: [], description: "Muse uses the admitted Paperclip tool catalog and its own approved tools. Provider usage is unavailable and remote stop is best effort." },
|
||||
openai_dot: { configurable: false, defaultMode: "provider-managed", options: [], description: "Dot uses the admitted Paperclip tool catalog. OpenAI manages its model and other tools; provider usage and global interruption are unavailable." },
|
||||
acpx: {
|
||||
configurable: true,
|
||||
@@ -149,7 +150,7 @@ export function isPaperclipRunnerProvider(
|
||||
value === "opencode" ||
|
||||
value === "claude_managed" ||
|
||||
value === "aws_agentcore" ||
|
||||
value === "acpx" || value === "openai_dot" || value === "grok_bot"
|
||||
value === "acpx" || value === "openai_dot" || value === "grok_bot" || value === "muse"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4269,7 +4269,7 @@ export function normalizePaperclipRunnerAdapterConfig(
|
||||
): Record<string, unknown> {
|
||||
if (adapterType !== "paperclip_runner") return config;
|
||||
config = normalizeLegacyRunnerProvider(config);
|
||||
if (config.provider === "openai_dot") {
|
||||
if (config.provider === "openai_dot" || config.provider === "muse") {
|
||||
return normalizePaperclipOperationalSkillPreference(adapterType, { lifecycleMode: "per_turn", ...config });
|
||||
}
|
||||
const next: Record<string, unknown> = {
|
||||
|
||||
@@ -99,6 +99,10 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
if (provider === "muse") {
|
||||
return { provider, lifecycleMode: "per_turn", allowUnmeteredProvider: schemaValues.allowUnmeteredProvider === true,
|
||||
...(typeof schemaValues.museBindingId === "string" ? { museBindingId: schemaValues.museBindingId } : {}) };
|
||||
}
|
||||
if (provider === "openai_dot") {
|
||||
return { provider, lifecycleMode: "per_turn", allowUnmeteredProvider: schemaValues.allowUnmeteredProvider === true, dotWorkspaceAccess: schemaValues.dotWorkspaceAccess === true, dotAttachmentAccess: schemaValues.dotAttachmentAccess === true,
|
||||
...(typeof schemaValues.dotBindingId === "string" ? { dotBindingId: schemaValues.dotBindingId } : {}) };
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
CREATE TABLE "external_agent_holds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"assignment_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"worker_unknown" boolean DEFAULT true NOT NULL,
|
||||
"native_effects_unknown" boolean DEFAULT false NOT NULL,
|
||||
"stop_boundary" jsonb,
|
||||
"worker_reported_at" timestamp with time zone,
|
||||
"operator_attested_at" timestamp with time zone,
|
||||
"released_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_agent_bindings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"operator_id" text NOT NULL,
|
||||
"generation" integer DEFAULT 1 NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"status" text DEFAULT 'pairing' NOT NULL,
|
||||
"ticket_hash" text,
|
||||
"ticket_expires_at" timestamp with time zone,
|
||||
"paired_at" timestamp with time zone,
|
||||
"challenge_hash" text,
|
||||
"challenge_expires_at" timestamp with time zone,
|
||||
"verified_reply_at" timestamp with time zone,
|
||||
"receiver_contact_at" timestamp with time zone,
|
||||
"worker_activity_at" timestamp with time zone,
|
||||
"client_version" text,
|
||||
"worker_cursor" integer DEFAULT 0 NOT NULL,
|
||||
"qualification_id" uuid,
|
||||
"qualification_expires_at" timestamp with time zone,
|
||||
"cleanup_expires_at" timestamp with time zone,
|
||||
"detector_removed_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_credentials" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_idle_receipts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"request_id" uuid NOT NULL,
|
||||
"digest" text NOT NULL,
|
||||
"outcome" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_input_deliveries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"assignment_id" uuid NOT NULL,
|
||||
"request_id" text NOT NULL,
|
||||
"turn_id" text NOT NULL,
|
||||
"input_digest" text NOT NULL,
|
||||
"response" jsonb NOT NULL,
|
||||
"source_event_id" text NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"continuation_receipt_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_mailbox_items" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"assignment_id" uuid,
|
||||
"kind" text NOT NULL,
|
||||
"source_event_id" text NOT NULL,
|
||||
"references" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_receiver_contact_buckets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"replica_id" uuid NOT NULL,
|
||||
"bucket_at" timestamp with time zone NOT NULL,
|
||||
"first_at" timestamp with time zone NOT NULL,
|
||||
"last_at" timestamp with time zone NOT NULL,
|
||||
"contacts" integer NOT NULL,
|
||||
"gaps_at_most_seven_seconds" integer NOT NULL,
|
||||
"max_gap_ms" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_runner_assignments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"normalized_session_id" text NOT NULL,
|
||||
"turn_id" text NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"controller_generation" integer NOT NULL,
|
||||
"catalog_digest" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"projection" jsonb NOT NULL,
|
||||
"accept_by" timestamp with time zone NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"offered_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"claimed_at" timestamp with time zone,
|
||||
"native_accepted_at" timestamp with time zone,
|
||||
"accepted_result_at" timestamp with time zone,
|
||||
"finalized_at" timestamp with time zone,
|
||||
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "muse_runner_operations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"assignment_id" uuid NOT NULL,
|
||||
"request_id" uuid NOT NULL,
|
||||
"digest" text NOT NULL,
|
||||
"command" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'reserved' NOT NULL,
|
||||
"outcome" jsonb,
|
||||
"source_event_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "external_agent_holds" ADD CONSTRAINT "external_agent_holds_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "external_agent_holds" ADD CONSTRAINT "external_agent_holds_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_agent_bindings" ADD CONSTRAINT "muse_agent_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_agent_bindings" ADD CONSTRAINT "muse_agent_bindings_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_credentials" ADD CONSTRAINT "muse_credentials_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_credentials" ADD CONSTRAINT "muse_credentials_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_idle_receipts" ADD CONSTRAINT "muse_idle_receipts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_idle_receipts" ADD CONSTRAINT "muse_idle_receipts_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_input_deliveries" ADD CONSTRAINT "muse_input_deliveries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_input_deliveries" ADD CONSTRAINT "muse_input_deliveries_assignment_id_muse_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."muse_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD CONSTRAINT "muse_mailbox_items_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD CONSTRAINT "muse_mailbox_items_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD CONSTRAINT "muse_mailbox_items_assignment_id_muse_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."muse_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD CONSTRAINT "muse_receiver_contact_buckets_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD CONSTRAINT "muse_receiver_contact_buckets_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_runner_operations" ADD CONSTRAINT "muse_runner_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "muse_runner_operations" ADD CONSTRAINT "muse_runner_operations_assignment_id_muse_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."muse_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "external_holds_assignment_uq" ON "external_agent_holds" USING btree ("provider","assignment_id");--> statement-breakpoint
|
||||
CREATE INDEX "external_holds_agent_idx" ON "external_agent_holds" USING btree ("company_id","agent_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_bindings_active_agent_uq" ON "muse_agent_bindings" USING btree ("company_id","agent_id") WHERE "muse_agent_bindings"."revoked_at" IS NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_bindings_ticket_uq" ON "muse_agent_bindings" USING btree ("ticket_hash");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_credentials_hash_uq" ON "muse_credentials" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE INDEX "muse_credentials_binding_idx" ON "muse_credentials" USING btree ("binding_id","binding_generation");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_idle_receipts_request_uq" ON "muse_idle_receipts" USING btree ("binding_id","binding_generation","request_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_input_request_uq" ON "muse_input_deliveries" USING btree ("assignment_id","request_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_input_source_uq" ON "muse_input_deliveries" USING btree ("assignment_id","source_event_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_mailbox_event_uq" ON "muse_mailbox_items" USING btree ("binding_id","binding_generation","source_event_id");--> statement-breakpoint
|
||||
CREATE INDEX "muse_mailbox_cursor_idx" ON "muse_mailbox_items" USING btree ("binding_id","binding_generation","id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_contact_bucket_uq" ON "muse_receiver_contact_buckets" USING btree ("binding_id","binding_generation","replica_id","bucket_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_assignments_turn_uq" ON "muse_runner_assignments" USING btree ("run_id","turn_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_assignments_live_binding_uq" ON "muse_runner_assignments" USING btree ("binding_id") WHERE "muse_runner_assignments"."status" IN ('offered','claimed','accepted');--> statement-breakpoint
|
||||
CREATE INDEX "muse_assignments_company_idx" ON "muse_runner_assignments" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "muse_operations_request_uq" ON "muse_runner_operations" USING btree ("assignment_id","request_id");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN "qualification_started_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN "deadline_enforced_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN "cadence_evidence_incomplete_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD COLUMN "timestamps" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD COLUMN "incomplete" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN "detector_removal_requested_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD COLUMN "signal_attempt" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD COLUMN "signal_notified_at" timestamp with time zone;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "muse_runner_operations" ADD COLUMN "continuation_receipt_id" uuid;
|
||||
@@ -0,0 +1,377 @@
|
||||
CREATE TABLE IF NOT EXISTS "external_agent_holds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"assignment_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"worker_unknown" boolean DEFAULT true NOT NULL,
|
||||
"native_effects_unknown" boolean DEFAULT false NOT NULL,
|
||||
"stop_boundary" jsonb,
|
||||
"worker_reported_at" timestamp with time zone,
|
||||
"operator_attested_at" timestamp with time zone,
|
||||
"released_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_agent_bindings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"operator_id" text NOT NULL,
|
||||
"generation" integer DEFAULT 1 NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"status" text DEFAULT 'pairing' NOT NULL,
|
||||
"ticket_hash" text,
|
||||
"ticket_expires_at" timestamp with time zone,
|
||||
"paired_at" timestamp with time zone,
|
||||
"challenge_hash" text,
|
||||
"challenge_expires_at" timestamp with time zone,
|
||||
"verified_reply_at" timestamp with time zone,
|
||||
"receiver_contact_at" timestamp with time zone,
|
||||
"worker_activity_at" timestamp with time zone,
|
||||
"client_version" text,
|
||||
"worker_cursor" integer DEFAULT 0 NOT NULL,
|
||||
"qualification_id" uuid,
|
||||
"qualification_expires_at" timestamp with time zone,
|
||||
"cleanup_expires_at" timestamp with time zone,
|
||||
"detector_removed_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_credentials" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_idle_receipts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"request_id" uuid NOT NULL,
|
||||
"digest" text NOT NULL,
|
||||
"outcome" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_input_deliveries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"assignment_id" uuid NOT NULL,
|
||||
"request_id" text NOT NULL,
|
||||
"turn_id" text NOT NULL,
|
||||
"input_digest" text NOT NULL,
|
||||
"response" jsonb NOT NULL,
|
||||
"source_event_id" text NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"continuation_receipt_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_mailbox_items" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"assignment_id" uuid,
|
||||
"kind" text NOT NULL,
|
||||
"source_event_id" text NOT NULL,
|
||||
"references" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_receiver_contact_buckets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"replica_id" uuid NOT NULL,
|
||||
"bucket_at" timestamp with time zone NOT NULL,
|
||||
"first_at" timestamp with time zone NOT NULL,
|
||||
"last_at" timestamp with time zone NOT NULL,
|
||||
"contacts" integer NOT NULL,
|
||||
"gaps_at_most_seven_seconds" integer NOT NULL,
|
||||
"max_gap_ms" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_runner_assignments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"normalized_session_id" text NOT NULL,
|
||||
"turn_id" text NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"controller_generation" integer NOT NULL,
|
||||
"catalog_digest" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"projection" jsonb NOT NULL,
|
||||
"accept_by" timestamp with time zone NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"offered_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"claimed_at" timestamp with time zone,
|
||||
"native_accepted_at" timestamp with time zone,
|
||||
"accepted_result_at" timestamp with time zone,
|
||||
"finalized_at" timestamp with time zone,
|
||||
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "muse_runner_operations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"assignment_id" uuid NOT NULL,
|
||||
"request_id" uuid NOT NULL,
|
||||
"digest" text NOT NULL,
|
||||
"command" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'reserved' NOT NULL,
|
||||
"outcome" jsonb,
|
||||
"source_event_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.external_agent_holds'::regclass
|
||||
AND conname = 'external_agent_holds_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "external_agent_holds" ADD CONSTRAINT "external_agent_holds_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.external_agent_holds'::regclass
|
||||
AND conname = 'external_agent_holds_agent_id_agents_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "external_agent_holds" ADD CONSTRAINT "external_agent_holds_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_agent_bindings'::regclass
|
||||
AND conname = 'muse_agent_bindings_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_agent_bindings" ADD CONSTRAINT "muse_agent_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_agent_bindings'::regclass
|
||||
AND conname = 'muse_agent_bindings_agent_id_agents_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_agent_bindings" ADD CONSTRAINT "muse_agent_bindings_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_credentials'::regclass
|
||||
AND conname = 'muse_credentials_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_credentials" ADD CONSTRAINT "muse_credentials_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_credentials'::regclass
|
||||
AND conname = 'muse_credentials_binding_id_muse_agent_bindings_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_credentials" ADD CONSTRAINT "muse_credentials_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_idle_receipts'::regclass
|
||||
AND conname = 'muse_idle_receipts_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_idle_receipts" ADD CONSTRAINT "muse_idle_receipts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_idle_receipts'::regclass
|
||||
AND conname = 'muse_idle_receipts_binding_id_muse_agent_bindings_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_idle_receipts" ADD CONSTRAINT "muse_idle_receipts_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_input_deliveries'::regclass
|
||||
AND conname = 'muse_input_deliveries_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_input_deliveries" ADD CONSTRAINT "muse_input_deliveries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_input_deliveries'::regclass
|
||||
AND conname = 'muse_input_deliveries_assignment_id_muse_runner_assignments_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_input_deliveries" ADD CONSTRAINT "muse_input_deliveries_assignment_id_muse_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."muse_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_mailbox_items'::regclass
|
||||
AND conname = 'muse_mailbox_items_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_mailbox_items" ADD CONSTRAINT "muse_mailbox_items_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_mailbox_items'::regclass
|
||||
AND conname = 'muse_mailbox_items_binding_id_muse_agent_bindings_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_mailbox_items" ADD CONSTRAINT "muse_mailbox_items_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_mailbox_items'::regclass
|
||||
AND conname = 'muse_mailbox_items_assignment_id_muse_runner_assignments_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_mailbox_items" ADD CONSTRAINT "muse_mailbox_items_assignment_id_muse_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."muse_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_receiver_contact_buckets'::regclass
|
||||
AND conname = 'muse_receiver_contact_buckets_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD CONSTRAINT "muse_receiver_contact_buckets_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_receiver_contact_buckets'::regclass
|
||||
AND conname = 'muse_receiver_contact_buckets_binding_id_muse_agent_bindings_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD CONSTRAINT "muse_receiver_contact_buckets_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_runner_assignments'::regclass
|
||||
AND conname = 'muse_runner_assignments_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_runner_assignments'::regclass
|
||||
AND conname = 'muse_runner_assignments_binding_id_muse_agent_bindings_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_binding_id_muse_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."muse_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_runner_assignments'::regclass
|
||||
AND conname = 'muse_runner_assignments_run_id_heartbeat_runs_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_runner_assignments'::regclass
|
||||
AND conname = 'muse_runner_assignments_agent_id_agents_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_runner_assignments" ADD CONSTRAINT "muse_runner_assignments_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_runner_operations'::regclass
|
||||
AND conname = 'muse_runner_operations_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_runner_operations" ADD CONSTRAINT "muse_runner_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'public.muse_runner_operations'::regclass
|
||||
AND conname = 'muse_runner_operations_assignment_id_muse_runner_assignments_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "muse_runner_operations" ADD CONSTRAINT "muse_runner_operations_assignment_id_muse_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."muse_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_holds_assignment_uq" ON "external_agent_holds" USING btree ("provider","assignment_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "external_holds_agent_idx" ON "external_agent_holds" USING btree ("company_id","agent_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_bindings_active_agent_uq" ON "muse_agent_bindings" USING btree ("company_id","agent_id") WHERE "muse_agent_bindings"."revoked_at" IS NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_bindings_ticket_uq" ON "muse_agent_bindings" USING btree ("ticket_hash");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_credentials_hash_uq" ON "muse_credentials" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "muse_credentials_binding_idx" ON "muse_credentials" USING btree ("binding_id","binding_generation");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_idle_receipts_request_uq" ON "muse_idle_receipts" USING btree ("binding_id","binding_generation","request_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_input_request_uq" ON "muse_input_deliveries" USING btree ("assignment_id","request_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_input_source_uq" ON "muse_input_deliveries" USING btree ("assignment_id","source_event_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_mailbox_event_uq" ON "muse_mailbox_items" USING btree ("binding_id","binding_generation","source_event_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "muse_mailbox_cursor_idx" ON "muse_mailbox_items" USING btree ("binding_id","binding_generation","id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_contact_bucket_uq" ON "muse_receiver_contact_buckets" USING btree ("binding_id","binding_generation","replica_id","bucket_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_assignments_turn_uq" ON "muse_runner_assignments" USING btree ("run_id","turn_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_assignments_live_binding_uq" ON "muse_runner_assignments" USING btree ("binding_id") WHERE "muse_runner_assignments"."status" IN ('offered','claimed','accepted');--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "muse_assignments_company_idx" ON "muse_runner_assignments" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "muse_operations_request_uq" ON "muse_runner_operations" USING btree ("assignment_id","request_id");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN IF NOT EXISTS "qualification_started_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN IF NOT EXISTS "deadline_enforced_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN IF NOT EXISTS "cadence_evidence_incomplete_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD COLUMN IF NOT EXISTS "timestamps" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "muse_receiver_contact_buckets" ADD COLUMN IF NOT EXISTS "incomplete" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "muse_agent_bindings" ADD COLUMN IF NOT EXISTS "detector_removal_requested_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD COLUMN IF NOT EXISTS "signal_attempt" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "muse_mailbox_items" ADD COLUMN IF NOT EXISTS "signal_notified_at" timestamp with time zone;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "muse_runner_operations" ADD COLUMN IF NOT EXISTS "continuation_receipt_id" uuid;
|
||||
+1528
-2
File diff suppressed because it is too large.
Load diff
+1562
-4
File diff suppressed because it is too large.
Load diff
+1602
-4
File diff suppressed because it is too large.
Load diff
+1656
-4
File diff suppressed because it is too large.
Load diff
+28
@@ -2430,6 +2430,34 @@
|
||||
"when": 1792101163248,
|
||||
"tag": "0348_agent_skill_runtime_hooks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 349,
|
||||
"version": "7",
|
||||
"when": 1792172178661,
|
||||
"tag": "0349_oval_dragon_lord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 350,
|
||||
"version": "7",
|
||||
"when": 1792172225258,
|
||||
"tag": "0350_friendly_rhodey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 351,
|
||||
"version": "7",
|
||||
"when": 1792172229036,
|
||||
"tag": "0351_hard_albert_cleary",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 352,
|
||||
"version": "7",
|
||||
"when": 1792172232859,
|
||||
"tag": "0352_special_warbound",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import {
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const museMigrations = [
|
||||
{ file: "0349_oval_dragon_lord.sql", preview: "0344_perpetual_eternity.sql", hash: "5c422698d40da1d6cdc587134553e371f7c807ce9617e07d7ff77eb05810ae49" },
|
||||
{ file: "0350_friendly_rhodey.sql", preview: "0345_lumpy_celestials.sql", hash: "a9373b52e02fa985bf10c15da91f6681c49d8726d94d5d68fd77e5802fd47a68" },
|
||||
{ file: "0351_hard_albert_cleary.sql", preview: "0346_powerful_epoch.sql", hash: "65f4f08c6a8fed7396db491e0691e693636f48c4ca4fa63cd2057af38039861a" },
|
||||
{ file: "0352_special_warbound.sql", preview: "0347_big_meggan.sql", hash: "1d2d5834fd499b60a43778d6d14b2520e3e2980258b8c0997d80cb95465f4abf" },
|
||||
] as const;
|
||||
const museTables = [
|
||||
"external_agent_holds", "muse_agent_bindings", "muse_credentials", "muse_idle_receipts",
|
||||
"muse_input_deliveries", "muse_mailbox_items", "muse_receiver_contact_buckets",
|
||||
"muse_runner_assignments", "muse_runner_operations",
|
||||
] as const;
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); });
|
||||
|
||||
async function database() {
|
||||
const db = await startEmbeddedPostgresTestDatabase("paperclip-muse-migration-replay-");
|
||||
cleanups.push(db.cleanup);
|
||||
const sql = postgres(db.connectionString, { max: 1, onnotice: () => {} });
|
||||
cleanups.push(async () => { await sql.end(); });
|
||||
return { sql, url: db.connectionString };
|
||||
}
|
||||
async function content(file: string) {
|
||||
return readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8");
|
||||
}
|
||||
function statements(sql: string) { return sql.split("--> statement-breakpoint").map(s => s.trim()).filter(Boolean); }
|
||||
async function rawReplay(sql: ReturnType<typeof postgres>) {
|
||||
for (const migration of museMigrations) for (const statement of statements(await content(migration.file))) await sql.unsafe(statement);
|
||||
}
|
||||
async function history(sql: ReturnType<typeof postgres>) {
|
||||
return Array.from(await sql`SELECT id, hash, created_at::text AS created_at FROM drizzle.__drizzle_migrations ORDER BY id`);
|
||||
}
|
||||
async function shape(sql: ReturnType<typeof postgres>) {
|
||||
return {
|
||||
columns: Array.from(await sql`SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
|
||||
FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, column_name`),
|
||||
constraints: Array.from(await sql`SELECT t.relname, c.conname, c.convalidated, pg_get_constraintdef(c.oid, true) AS definition
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public' ORDER BY t.relname, c.conname`),
|
||||
indexes: Array.from(await sql`SELECT t.relname, i.indisvalid, i.indisready, pg_get_indexdef(i.indexrelid) AS definition
|
||||
FROM pg_index i JOIN pg_class t ON t.oid = i.indrelid JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public' ORDER BY t.relname, definition`),
|
||||
};
|
||||
}
|
||||
async function removeMuseSchemaAndReceipts(sql: ReturnType<typeof postgres>) {
|
||||
// A disposable, fully migrated DB supplies the canonical prefix. Remove only
|
||||
// this feature's tables/receipts to model interrupted unjournaled DDL.
|
||||
await sql.unsafe(`DROP TABLE ${museTables.map(table => `"${table}"`).join(", ")} CASCADE`);
|
||||
for (const migration of museMigrations) {
|
||||
const hash = createHash("sha256").update(await content(migration.file)).digest("hex");
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
|
||||
}
|
||||
}
|
||||
async function seedRows(sql: ReturnType<typeof postgres>, complete: boolean) {
|
||||
const [company] = await sql`INSERT INTO companies (name, issue_prefix) VALUES ('Migration replay', 'RPL') RETURNING id`;
|
||||
const [agent] = await sql`INSERT INTO agents (company_id, name, adapter_type) VALUES (${company.id}, 'Retained agent', 'paperclip_runner') RETURNING id`;
|
||||
const [task] = await sql`INSERT INTO issues (company_id, title, assignee_agent_id) VALUES (${company.id}, 'Retained task', ${agent.id}) RETURNING id`;
|
||||
const [run] = await sql`INSERT INTO heartbeat_runs (company_id, agent_id, invocation_source, trigger_detail, status)
|
||||
VALUES (${company.id}, ${agent.id}, 'assignment', 'system', 'failed') RETURNING id`;
|
||||
const [binding] = await sql`INSERT INTO muse_agent_bindings (company_id, agent_id, operator_id, generation, revision, status, worker_cursor, detector_removed_at, revoked_at)
|
||||
VALUES (${company.id}, ${agent.id}, 'synthetic-operator', 3, 9, 'revoked', 13, '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z') RETURNING id`;
|
||||
const [assignment] = await sql`INSERT INTO muse_runner_assignments (company_id, binding_id, binding_generation, run_id, agent_id,
|
||||
normalized_session_id, turn_id, controller_generation, catalog_digest, status, projection, accept_by, expires_at)
|
||||
VALUES (${company.id}, ${binding.id}, 3, ${run.id}, ${agent.id}, 'retained-session', 'retained-turn', 4, 'retained-digest', 'fenced',
|
||||
'{"retained":true}'::jsonb, '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z') RETURNING id`;
|
||||
await sql`INSERT INTO external_agent_holds (company_id, agent_id, provider, assignment_id, binding_id, binding_generation, run_id, worker_unknown, native_effects_unknown, stop_boundary)
|
||||
VALUES (${company.id}, ${agent.id}, 'muse', ${assignment.id}, ${binding.id}, 3, ${run.id}, true, true, '{"retained":true}'::jsonb)`;
|
||||
await sql`INSERT INTO muse_credentials (company_id, binding_id, binding_generation, kind, family_id, token_hash, expires_at, revoked_at)
|
||||
VALUES (${company.id}, ${binding.id}, 3, 'cleanup', ${randomUUID()}, ${randomUUID()}, '2026-01-02T00:00:00Z', '2026-01-01T00:00:00Z')`;
|
||||
await sql`INSERT INTO muse_idle_receipts (company_id, binding_id, binding_generation, request_id, digest, outcome)
|
||||
VALUES (${company.id}, ${binding.id}, 3, ${randomUUID()}, 'retained-idle', '{"retained":true}'::jsonb)`;
|
||||
await sql`INSERT INTO muse_input_deliveries (company_id, assignment_id, request_id, turn_id, input_digest, response, source_event_id, consumed_at, continuation_receipt_id)
|
||||
VALUES (${company.id}, ${assignment.id}, 'retained-input', 'retained-turn', 'retained-digest', '{"retained":true}'::jsonb, 'retained-input-event', '2026-01-01T00:00:00Z', ${randomUUID()})`;
|
||||
await sql`INSERT INTO muse_mailbox_items (company_id, binding_id, binding_generation, assignment_id, kind, source_event_id, "references")
|
||||
VALUES (${company.id}, ${binding.id}, 3, ${assignment.id}, 'assignment', 'retained-mailbox', '{"retained":true}'::jsonb)`;
|
||||
await sql`INSERT INTO muse_receiver_contact_buckets (company_id, binding_id, binding_generation, replica_id, bucket_at, first_at, last_at, contacts, gaps_at_most_seven_seconds, max_gap_ms)
|
||||
VALUES (${company.id}, ${binding.id}, 3, ${randomUUID()}, '2026-01-01T00:00:00Z', '2026-01-01T00:00:01Z', '2026-01-01T00:00:03Z', 2, 1, 2000)`;
|
||||
await sql`INSERT INTO muse_runner_operations (company_id, assignment_id, request_id, digest, command, status, outcome)
|
||||
VALUES (${company.id}, ${assignment.id}, ${randomUUID()}, 'retained-operation', '{"retained":true}'::jsonb, 'unknown', '{"retained":true}'::jsonb)`;
|
||||
if (complete) {
|
||||
await sql`UPDATE muse_agent_bindings SET qualification_started_at = '2026-01-01T00:00:00Z', deadline_enforced_at = '2026-01-02T00:00:00Z',
|
||||
cadence_evidence_incomplete_at = '2026-01-03T00:00:00Z', detector_removal_requested_at = '2026-01-04T00:00:00Z' WHERE id = ${binding.id}`;
|
||||
await sql`UPDATE muse_receiver_contact_buckets SET timestamps = '["2026-01-01T00:00:01Z"]'::jsonb, incomplete = true WHERE binding_id = ${binding.id}`;
|
||||
await sql`UPDATE muse_mailbox_items SET signal_attempt = 9, signal_notified_at = '2026-01-05T00:00:00Z' WHERE binding_id = ${binding.id}`;
|
||||
await sql`UPDATE muse_runner_operations SET continuation_receipt_id = ${randomUUID()} WHERE assignment_id = ${assignment.id}`;
|
||||
}
|
||||
return async () => {
|
||||
const rows: Record<string, unknown[]> = {};
|
||||
for (const table of [...museTables, "companies", "issues", "heartbeat_runs"] as const) {
|
||||
rows[table] = Array.from(await sql.unsafe(`SELECT to_jsonb(t) AS row FROM "${table}" t WHERE ${table === "companies" ? "id" : "company_id"} = $1 ORDER BY id`, [company.id])).map(row => row.row);
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
}
|
||||
|
||||
describePostgres("Muse migration SQL idempotence", () => {
|
||||
it("replays every raw SQL file twice without changing schema, receipts or runtime evidence", async () => {
|
||||
const { sql, url } = await database();
|
||||
const rows = await seedRows(sql, true);
|
||||
const before = { shape: await shape(sql), history: await history(sql), rows: await rows() };
|
||||
await rawReplay(sql);
|
||||
await rawReplay(sql);
|
||||
expect(await shape(sql)).toEqual(before.shape);
|
||||
expect(await history(sql)).toEqual(before.history);
|
||||
expect(await rows()).toEqual(before.rows);
|
||||
await applyPendingMigrations(url);
|
||||
expect((await inspectMigrations(url)).status).toBe("upToDate");
|
||||
expect(await history(sql)).toEqual(before.history);
|
||||
expect(await rows()).toEqual(before.rows);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
it("completes mixed partial unjournaled DDL and preserves existing rows and journal identities", async () => {
|
||||
const { sql, url } = await database();
|
||||
const expectedShape = await shape(sql);
|
||||
await removeMuseSchemaAndReceipts(sql);
|
||||
const prefixHistory = await history(sql);
|
||||
const baseStatements = statements(await content(museMigrations[0].file));
|
||||
for (const statement of baseStatements.slice(0, 10)) await sql.unsafe(statement);
|
||||
await sql.unsafe(baseStatements.find(statement => /^CREATE (?:UNIQUE )?INDEX/.test(statement))!);
|
||||
for (const migration of museMigrations.slice(1)) await sql.unsafe(statements(await content(migration.file))[0]);
|
||||
// A same-named FK elsewhere must not hide the missing public-table FK.
|
||||
await sql`CREATE SCHEMA migration_decoy`;
|
||||
await sql`CREATE TABLE migration_decoy.constraint_owner (binding_id uuid,
|
||||
CONSTRAINT muse_credentials_binding_id_muse_agent_bindings_id_fk FOREIGN KEY (binding_id) REFERENCES public.companies(id))`;
|
||||
const rows = await seedRows(sql, false);
|
||||
const beforeRows = await rows();
|
||||
await applyPendingMigrations(url);
|
||||
expect((await inspectMigrations(url)).status).toBe("upToDate");
|
||||
expect(await shape(sql)).toEqual(expectedShape);
|
||||
expect(await rows()).toMatchObject(beforeRows);
|
||||
const afterHistory = await history(sql);
|
||||
expect(afterHistory.slice(0, prefixHistory.length)).toEqual(prefixHistory);
|
||||
expect(afterHistory).toHaveLength(prefixHistory.length + museMigrations.length);
|
||||
expect(await sql`SELECT timestamps, incomplete FROM muse_receiver_contact_buckets`).toMatchObject([{ timestamps: [], incomplete: false }]);
|
||||
expect(await sql`SELECT signal_attempt, signal_notified_at FROM muse_mailbox_items`).toMatchObject([{ signal_attempt: 0, signal_notified_at: null }]);
|
||||
const finalRows = await rows();
|
||||
await rawReplay(sql);
|
||||
await applyPendingMigrations(url);
|
||||
expect(await history(sql)).toEqual(afterHistory);
|
||||
expect(await shape(sql)).toEqual(expectedShape);
|
||||
expect(await rows()).toEqual(finalRows);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
it("retains exact preview SQL hashes and receipts while journaling guarded successors", async () => {
|
||||
const { sql, url } = await database();
|
||||
const expectedShape = await shape(sql);
|
||||
await removeMuseSchemaAndReceipts(sql);
|
||||
for (const [index, migration] of museMigrations.entries()) {
|
||||
const preview = await readFile(new URL(`./__fixtures__/muse-preview-migrations/${migration.preview}`, import.meta.url), "utf8");
|
||||
expect(createHash("sha256").update(preview).digest("hex")).toBe(migration.hash);
|
||||
expect(createHash("sha256").update(await content(migration.file)).digest("hex")).not.toBe(migration.hash);
|
||||
for (const statement of statements(preview)) await sql.unsafe(statement);
|
||||
await sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${1791904391794 + index})`;
|
||||
}
|
||||
const rows = await seedRows(sql, true), beforeRows = await rows(), beforeHistory = await history(sql);
|
||||
await applyPendingMigrations(url);
|
||||
expect((await inspectMigrations(url)).status).toBe("upToDate");
|
||||
expect(await shape(sql)).toEqual(expectedShape);
|
||||
expect(await rows()).toEqual(beforeRows);
|
||||
const afterHistory = await history(sql);
|
||||
expect(afterHistory.slice(0, beforeHistory.length)).toEqual(beforeHistory);
|
||||
expect(afterHistory).toHaveLength(beforeHistory.length + museMigrations.length);
|
||||
for (const migration of museMigrations) expect(afterHistory.filter(row => row.hash === migration.hash)).toHaveLength(1);
|
||||
await applyPendingMigrations(url);
|
||||
expect(await history(sql)).toEqual(afterHistory);
|
||||
expect(await rows()).toEqual(beforeRows);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
});
|
||||
@@ -247,6 +247,7 @@ export { userCompanyPreferences } from "./user_company_preferences.js";
|
||||
|
||||
export { chatSlackManagerGrants } from "./chat_slack_manager_grants.js";
|
||||
export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js";
|
||||
export * from "./muse_runner.js";
|
||||
|
||||
export { executionWorkspaceRepositories } from "./execution_workspace_repositories.js";
|
||||
export { executionWorkspaceRepositoryRoots } from "./execution_workspace_repository_roots.js";
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { bigserial, boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import type { MuseStopBoundary } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
const time = (name: string) => timestamp(name, { withTimezone: true });
|
||||
|
||||
export const museAgentBindings = pgTable("muse_agent_bindings", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), operatorId: text("operator_id").notNull(),
|
||||
generation: integer("generation").notNull().default(1), revision: integer("revision").notNull().default(1),
|
||||
status: text("status").$type<"pairing" | "connected" | "ready" | "revoked">().notNull().default("pairing"),
|
||||
ticketHash: text("ticket_hash"), ticketExpiresAt: time("ticket_expires_at"), pairedAt: time("paired_at"),
|
||||
challengeHash: text("challenge_hash"), challengeExpiresAt: time("challenge_expires_at"), verifiedReplyAt: time("verified_reply_at"),
|
||||
receiverContactAt: time("receiver_contact_at"), workerActivityAt: time("worker_activity_at"), clientVersion: text("client_version"),
|
||||
workerCursor: integer("worker_cursor").notNull().default(0),
|
||||
qualificationId: uuid("qualification_id"), qualificationStartedAt: time("qualification_started_at"), qualificationExpiresAt: time("qualification_expires_at"), deadlineEnforcedAt: time("deadline_enforced_at"), cadenceEvidenceIncompleteAt: time("cadence_evidence_incomplete_at"),
|
||||
cleanupExpiresAt: time("cleanup_expires_at"), detectorRemovalRequestedAt: time("detector_removal_requested_at"), detectorRemovedAt: time("detector_removed_at"), revokedAt: time("revoked_at"),
|
||||
createdAt: time("created_at").notNull().defaultNow(), updatedAt: time("updated_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_bindings_active_agent_uq").on(t.companyId, t.agentId).where(sql`${t.revokedAt} IS NULL`), uniqueIndex("muse_bindings_ticket_uq").on(t.ticketHash)]);
|
||||
|
||||
export const museCredentials = pgTable("muse_credentials", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => museAgentBindings.id, { onDelete: "cascade" }), bindingGeneration: integer("binding_generation").notNull(),
|
||||
kind: text("kind").$type<"access" | "refresh" | "signal" | "cleanup" | "detector_cleanup">().notNull(), familyId: uuid("family_id").notNull(),
|
||||
tokenHash: text("token_hash").notNull(), expiresAt: time("expires_at").notNull(), consumedAt: time("consumed_at"), revokedAt: time("revoked_at"),
|
||||
createdAt: time("created_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_credentials_hash_uq").on(t.tokenHash), index("muse_credentials_binding_idx").on(t.bindingId, t.bindingGeneration)]);
|
||||
|
||||
export const museRunnerAssignments = pgTable("muse_runner_assignments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => museAgentBindings.id, { onDelete: "cascade" }), bindingGeneration: integer("binding_generation").notNull(),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }), agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
normalizedSessionId: text("normalized_session_id").notNull(), turnId: text("turn_id").notNull(), revision: integer("revision").notNull().default(1),
|
||||
controllerGeneration: integer("controller_generation").notNull(), catalogDigest: text("catalog_digest").notNull(),
|
||||
status: text("status").$type<"offered" | "claimed" | "accepted" | "settled" | "fenced">().notNull(),
|
||||
projection: jsonb("projection").$type<Record<string, unknown>>().notNull(),
|
||||
acceptBy: time("accept_by").notNull(), expiresAt: time("expires_at").notNull(), offeredAt: time("offered_at").notNull().defaultNow(),
|
||||
claimedAt: time("claimed_at"), nativeAcceptedAt: time("native_accepted_at"), acceptedResultAt: time("accepted_result_at"), finalizedAt: time("finalized_at"),
|
||||
lastActivityAt: time("last_activity_at").notNull().defaultNow(), createdAt: time("created_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_assignments_turn_uq").on(t.runId, t.turnId), uniqueIndex("muse_assignments_live_binding_uq").on(t.bindingId).where(sql`${t.status} IN ('offered','claimed','accepted')`), index("muse_assignments_company_idx").on(t.companyId)]);
|
||||
|
||||
export const museRunnerOperations = pgTable("muse_runner_operations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
assignmentId: uuid("assignment_id").notNull().references(() => museRunnerAssignments.id, { onDelete: "cascade" }), requestId: uuid("request_id").notNull(),
|
||||
continuationReceiptId: uuid("continuation_receipt_id"), digest: text("digest").notNull(), command: jsonb("command").$type<Record<string, unknown>>().notNull(),
|
||||
status: text("status").$type<"reserved" | "dispatched" | "pending" | "settled" | "rejected" | "unknown">().notNull().default("reserved"),
|
||||
outcome: jsonb("outcome").$type<Record<string, unknown>>(), sourceEventId: text("source_event_id"),
|
||||
createdAt: time("created_at").notNull().defaultNow(), updatedAt: time("updated_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_operations_request_uq").on(t.assignmentId, t.requestId)]);
|
||||
|
||||
export const museMailboxItems = pgTable("muse_mailbox_items", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => museAgentBindings.id, { onDelete: "cascade" }), bindingGeneration: integer("binding_generation").notNull(),
|
||||
assignmentId: uuid("assignment_id").references(() => museRunnerAssignments.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<"assignment" | "operation_result" | "authority_revoked" | "readiness_challenge" | "follow_up" | "input_available">().notNull(),
|
||||
signalAttempt: integer("signal_attempt").notNull().default(0), signalNotifiedAt: time("signal_notified_at"),
|
||||
sourceEventId: text("source_event_id").notNull(), references: jsonb("references").$type<Record<string, unknown>>().notNull(), createdAt: time("created_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_mailbox_event_uq").on(t.bindingId, t.bindingGeneration, t.sourceEventId), index("muse_mailbox_cursor_idx").on(t.bindingId, t.bindingGeneration, t.id)]);
|
||||
|
||||
/** Native input is not consumed by transport delivery; only an exact same-turn consume receipt can do that. */
|
||||
export const museInputDeliveries = pgTable("muse_input_deliveries", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
assignmentId: uuid("assignment_id").notNull().references(() => museRunnerAssignments.id, { onDelete: "cascade" }),
|
||||
requestId: text("request_id").notNull(), turnId: text("turn_id").notNull(), inputDigest: text("input_digest").notNull(),
|
||||
response: jsonb("response").$type<Record<string, unknown>>().notNull(), sourceEventId: text("source_event_id").notNull(),
|
||||
consumedAt: time("consumed_at"), continuationReceiptId: uuid("continuation_receipt_id"), createdAt: time("created_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_input_request_uq").on(t.assignmentId, t.requestId), uniqueIndex("muse_input_source_uq").on(t.assignmentId, t.sourceEventId)]);
|
||||
|
||||
/** Retained independently of run/assignment deletion. Worker and native-effect uncertainty are separate barriers. */
|
||||
export const externalAgentHolds = pgTable("external_agent_holds", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), provider: text("provider").$type<"muse" | "openai_dot">().notNull(),
|
||||
assignmentId: uuid("assignment_id").notNull(), bindingId: uuid("binding_id").notNull(), bindingGeneration: integer("binding_generation").notNull(),
|
||||
runId: uuid("run_id").notNull(), workerUnknown: boolean("worker_unknown").notNull().default(true), nativeEffectsUnknown: boolean("native_effects_unknown").notNull().default(false),
|
||||
stopBoundary: jsonb("stop_boundary").$type<MuseStopBoundary>(), workerReportedAt: time("worker_reported_at"), operatorAttestedAt: time("operator_attested_at"),
|
||||
releasedAt: time("released_at"), createdAt: time("created_at").notNull().defaultNow(), updatedAt: time("updated_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("external_holds_assignment_uq").on(t.provider, t.assignmentId), index("external_holds_agent_idx").on(t.companyId, t.agentId)]);
|
||||
|
||||
export const museIdleReceipts = pgTable("muse_idle_receipts", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => museAgentBindings.id, { onDelete: "cascade" }), bindingGeneration: integer("binding_generation").notNull(),
|
||||
requestId: uuid("request_id").notNull(), digest: text("digest").notNull(), outcome: jsonb("outcome").$type<Record<string, unknown>>().notNull(), createdAt: time("created_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_idle_receipts_request_uq").on(t.bindingId,t.bindingGeneration,t.requestId)]);
|
||||
|
||||
export const museReceiverContactBuckets = pgTable("muse_receiver_contact_buckets", {
|
||||
id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => museAgentBindings.id, { onDelete: "cascade" }), bindingGeneration: integer("binding_generation").notNull(),
|
||||
replicaId: uuid("replica_id").notNull(), bucketAt: time("bucket_at").notNull(), firstAt: time("first_at").notNull(), lastAt: time("last_at").notNull(),
|
||||
timestamps: jsonb("timestamps").$type<string[]>().notNull().default([]), incomplete: boolean("incomplete").notNull().default(false),
|
||||
contacts: integer("contacts").notNull(), gapsAtMostSevenSeconds: integer("gaps_at_most_seven_seconds").notNull(), maxGapMs: integer("max_gap_ms").notNull(),
|
||||
createdAt: time("created_at").notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("muse_contact_bucket_uq").on(t.bindingId,t.bindingGeneration,t.replicaId,t.bucketAt)]);
|
||||
@@ -32,12 +32,12 @@
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 19,
|
||||
"agentProfileVersion": 20,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:38abf359adeef43f1f19230245c6e2051c55fdd79571882ef6f00b7cb2958d93",
|
||||
"commandDigest": "sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
"requiresProviderPolicy": true,
|
||||
"qualificationStatus": "pending"
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
|
||||
"test": "pnpm run test:typescript && pnpm run test:rust",
|
||||
"test:typescript": "pnpm run test:typescript:prep && vitest run",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs",
|
||||
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
|
||||
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{
|
||||
"path": "schemas/command-v3.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v3/command.schema.json",
|
||||
"sha256": "0aedad6ae238c532f31411a4551c54819e3a3698e70c6f5d653ae395811c60b2"
|
||||
"sha256": "6d910a693fba2a7ee77a0aaf0cbfdf4d20998f0b157e64da699671b296ff0769"
|
||||
},
|
||||
{
|
||||
"path": "schemas/command.schema.json",
|
||||
@@ -60,7 +60,7 @@
|
||||
{
|
||||
"path": "schemas/event-v3.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v3/event.schema.json",
|
||||
"sha256": "a2d1969a7636108a1d4759cd5af4d554a155f3e147099d67c955ee0da5948ddf"
|
||||
"sha256": "1b167f2f5736ebdc9dfe0b3e8c0812337c002141412de8a80901ca3e1b58332e"
|
||||
},
|
||||
{
|
||||
"path": "schemas/event.schema.json",
|
||||
@@ -85,7 +85,7 @@
|
||||
{
|
||||
"path": "schemas/provider-descriptor.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
|
||||
"sha256": "5ce1b2ad05777b33c71d1a3c05b29496b661421f9b232a551d8d584c5dd07ee3"
|
||||
"sha256": "8383e8e73b3f7a9bd2e863eab9bd524591d10f4f92c8014e63e5c2d25e2dc597"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
|
||||
@@ -206,7 +206,10 @@
|
||||
"accept",
|
||||
"tool",
|
||||
"progress",
|
||||
"finish"
|
||||
"finish",
|
||||
"renew",
|
||||
"request_user_input",
|
||||
"consume_input"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
@@ -380,6 +383,140 @@
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "external_provider.operation"
|
||||
},
|
||||
"payload": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "renew"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAtUnixMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiresAtUnixMs"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "external_provider.operation"
|
||||
},
|
||||
"payload": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "request_user_input"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"questionSet": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId",
|
||||
"questionSet"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "external_provider.operation"
|
||||
},
|
||||
"payload": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "consume_input"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"inputDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId",
|
||||
"inputDigest"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": true
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"run.terminal",
|
||||
"external_provider.dispatch_requested",
|
||||
"external_provider.operation_settled",
|
||||
"external_provider.input_available",
|
||||
"auth.credentials.updated"
|
||||
]
|
||||
},
|
||||
@@ -568,6 +569,105 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"const": "external_provider.input_available"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"binding": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"companyId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"agentId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"bindingId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"runId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"normalizedSessionId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"bindingGeneration": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"assignmentRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"companyId",
|
||||
"agentId",
|
||||
"bindingId",
|
||||
"runId",
|
||||
"normalizedSessionId",
|
||||
"turnId",
|
||||
"bindingGeneration",
|
||||
"assignmentRevision"
|
||||
]
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"inputDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"response": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"binding",
|
||||
"requestId",
|
||||
"turnId",
|
||||
"inputDigest",
|
||||
"response"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"aws_agentcore",
|
||||
"acpx",
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
"grok_bot",
|
||||
"muse"
|
||||
]
|
||||
},
|
||||
"driver": {
|
||||
@@ -30,7 +31,8 @@
|
||||
"aws_agentcore_harness_api",
|
||||
"acpx_runtime",
|
||||
"openai_dot_mcp",
|
||||
"grok_bot_mcp"
|
||||
"grok_bot_mcp",
|
||||
"muse_external"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
@@ -56,7 +58,8 @@
|
||||
"anthropic_managed_agents",
|
||||
"aws_bedrock_agentcore_harness",
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
"grok_bot",
|
||||
"muse"
|
||||
]
|
||||
},
|
||||
"providerSessionId": {
|
||||
@@ -271,6 +274,19 @@
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "muse"
|
||||
},
|
||||
"driver": {
|
||||
"const": "muse_external"
|
||||
},
|
||||
"executionKind": {
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -441,6 +457,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "muse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"service",
|
||||
"providerSessionId"
|
||||
],
|
||||
"properties": {
|
||||
"service": {
|
||||
"const": "muse"
|
||||
},
|
||||
"model": {
|
||||
"const": null
|
||||
},
|
||||
"providerSessionId": {
|
||||
"const": null
|
||||
},
|
||||
"agentProcessId": {
|
||||
"const": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
|
||||
@@ -120,7 +120,7 @@ fn build_metadata() -> serde_json::Value {
|
||||
"packageVersion": env!("CARGO_PKG_VERSION"),
|
||||
"binaryContractVersion": 2,
|
||||
"durableSessionCapabilities": ["unlimited_runtime", "connection_lease_renewal"],
|
||||
"externalProviderCapabilities": ["openai_dot_mcp", "grok_bot_mcp"],
|
||||
"externalProviderCapabilities": ["openai_dot_mcp", "grok_bot_mcp", "muse_external_v1"],
|
||||
"nativeExecutionVersion": 1,
|
||||
"harnessDriverVersion": 1,
|
||||
"prp": {
|
||||
@@ -454,7 +454,7 @@ mod tests {
|
||||
assert_eq!(metadata["binaryContractVersion"], 2);
|
||||
assert_eq!(
|
||||
metadata["externalProviderCapabilities"],
|
||||
json!(["openai_dot_mcp", "grok_bot_mcp"])
|
||||
json!(["openai_dot_mcp", "grok_bot_mcp", "muse_external_v1"])
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["durableSessionCapabilities"],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1959
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:38abf359adeef43f1f19230245c6e2051c55fdd79571882ef6f00b7cb2958d93",
|
||||
"sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod configured_environment;
|
||||
mod credential_observer;
|
||||
pub mod dot_provider_backend;
|
||||
pub mod durable;
|
||||
pub mod external_provider_backend;
|
||||
pub mod fake_harness;
|
||||
mod generated_acpx_profiles;
|
||||
pub mod generated_acpx_sidecar_contract;
|
||||
|
||||
@@ -4,11 +4,14 @@ use serde_json::Value;
|
||||
|
||||
use crate::acpx_provider_backend::{AcpxCommandExecutor, ACPX_PROVIDER_STATE_FILE};
|
||||
use crate::credential_observer::CredentialObserver;
|
||||
use crate::dot_provider_backend::{DotCommandExecutor, DOT_PROVIDER_STATE_FILE};
|
||||
use crate::durable::{
|
||||
Command, CommandExecution, CommandExecutor, DurableRunnerConfig, DurableRunnerError,
|
||||
PolledEvent, TerminalDeliveryReconciliation,
|
||||
};
|
||||
use crate::external_provider_backend::{
|
||||
ExternalCommandExecutor, ExternalProviderCodec, DOT_PROVIDER_STATE_FILE,
|
||||
MUSE_PROVIDER_STATE_FILE,
|
||||
};
|
||||
use crate::managed_provider_backend::{
|
||||
ManagedProviderCommandExecutor, MANAGED_PROVIDER_STATE_FILE,
|
||||
};
|
||||
@@ -18,7 +21,7 @@ enum SelectedExecutor {
|
||||
LocalFacade(CodexCommandExecutor),
|
||||
Acpx(AcpxCommandExecutor),
|
||||
Managed(ManagedProviderCommandExecutor),
|
||||
Dot(DotCommandExecutor),
|
||||
Dot(ExternalCommandExecutor),
|
||||
}
|
||||
|
||||
impl CommandExecutor for SelectedExecutor {
|
||||
@@ -139,7 +142,8 @@ impl NativeProviderCommandExecutor {
|
||||
let acpx = self.state_dir.join(ACPX_PROVIDER_STATE_FILE).exists();
|
||||
let managed = self.state_dir.join(MANAGED_PROVIDER_STATE_FILE).exists();
|
||||
let dot = self.state_dir.join(DOT_PROVIDER_STATE_FILE).exists();
|
||||
if [codex, acpx, managed, dot]
|
||||
let muse = self.state_dir.join(MUSE_PROVIDER_STATE_FILE).exists();
|
||||
if [codex, acpx, managed, dot, muse]
|
||||
.into_iter()
|
||||
.filter(|present| *present)
|
||||
.count()
|
||||
@@ -149,9 +153,15 @@ impl NativeProviderCommandExecutor {
|
||||
"runner state contains conflicting provider authorities",
|
||||
));
|
||||
}
|
||||
self.selected = if dot {
|
||||
self.selected = if muse {
|
||||
Some(SelectedExecutor::Dot(ExternalCommandExecutor::with_codec(
|
||||
&self.state_dir,
|
||||
&self.config,
|
||||
ExternalProviderCodec::Muse,
|
||||
)))
|
||||
} else if dot {
|
||||
Some(SelectedExecutor::Dot(
|
||||
DotCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
ExternalCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
))
|
||||
} else if managed {
|
||||
Some(SelectedExecutor::Managed(
|
||||
@@ -181,9 +191,20 @@ impl NativeProviderCommandExecutor {
|
||||
)
|
||||
})?;
|
||||
self.selected = Some(match kind {
|
||||
"openai_dot" | "grok_bot" => SelectedExecutor::Dot(
|
||||
DotCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
),
|
||||
"muse" => SelectedExecutor::Dot(ExternalCommandExecutor::with_codec(
|
||||
&self.state_dir,
|
||||
&self.config,
|
||||
ExternalProviderCodec::Muse,
|
||||
)),
|
||||
"openai_dot" => SelectedExecutor::Dot(ExternalCommandExecutor::with_runner_config(
|
||||
&self.state_dir,
|
||||
&self.config,
|
||||
)),
|
||||
"grok_bot" => SelectedExecutor::Dot(ExternalCommandExecutor::with_codec(
|
||||
&self.state_dir,
|
||||
&self.config,
|
||||
ExternalProviderCodec::Grok,
|
||||
)),
|
||||
"codex" | "opencode" => SelectedExecutor::LocalFacade(
|
||||
CodexCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
),
|
||||
|
||||
@@ -546,6 +546,102 @@ fn saturate_provider_tool_receipts(directory: &Path) {
|
||||
.expect("write saturated provider state");
|
||||
}
|
||||
|
||||
fn read_receipt_limit_state(directory: &Path) -> Value {
|
||||
serde_json::from_slice(
|
||||
&fs::read(directory.join("codex-provider-state.json")).expect("read receipt-limit state"),
|
||||
)
|
||||
.expect("parse receipt-limit state")
|
||||
}
|
||||
|
||||
fn seed_pending_receipt_limit_retry(directory: &Path) {
|
||||
let original = read_receipt_limit_state(directory);
|
||||
assert!(!original["activeProviderTurnId"].is_null());
|
||||
assert_eq!(original["receiptLimitDiagnosticEmitted"], false);
|
||||
assert_eq!(original["receiptLimitInterruptPending"], false);
|
||||
assert_eq!(original["receiptLimitInterruptAccepted"], false);
|
||||
assert_eq!(original["receiptLimitInterruptAttempts"], 0);
|
||||
assert!(original["receiptLimitInterruptDeadlineUnixMs"].is_null());
|
||||
let mut seeded = original.clone();
|
||||
seeded["receiptLimitDiagnosticEmitted"] = json!(true);
|
||||
seeded["receiptLimitInterruptPending"] = json!(true);
|
||||
seeded["receiptLimitInterruptDeadlineUnixMs"] = json!(u64::MAX);
|
||||
let mut reverse = seeded.clone();
|
||||
for field in [
|
||||
"receiptLimitDiagnosticEmitted",
|
||||
"receiptLimitInterruptPending",
|
||||
"receiptLimitInterruptDeadlineUnixMs",
|
||||
] {
|
||||
reverse[field] = original[field].clone();
|
||||
}
|
||||
assert!(
|
||||
reverse == original,
|
||||
"only the explicit pending-stop fixture fields may change"
|
||||
);
|
||||
fs::write(
|
||||
directory.join("codex-provider-state.json"),
|
||||
serde_json::to_vec_pretty(&seeded).unwrap(),
|
||||
)
|
||||
.expect("write synthetic pending-stop retry fixture");
|
||||
}
|
||||
|
||||
fn assert_at_capacity_receipts_preserved_by_stop(actual: &Value, saturated: &Value) {
|
||||
let completed = saturated["completed"]
|
||||
.as_object()
|
||||
.expect("fixture completed receipts");
|
||||
assert_eq!(completed.len(), 4096);
|
||||
assert!(saturated["pending"].as_object().unwrap().is_empty());
|
||||
assert!(saturated["settledResults"].as_object().unwrap().is_empty());
|
||||
assert!(saturated["settledCallIds"].as_array().unwrap().is_empty());
|
||||
let mut expected_ids = completed.keys().cloned().collect::<Vec<_>>();
|
||||
expected_ids.sort();
|
||||
assert!(actual["completed"].as_object().unwrap().is_empty());
|
||||
assert_eq!(actual["settledResults"].as_object().unwrap().len(), 4096);
|
||||
assert_eq!(actual["settledCallIds"].as_array().unwrap().len(), 4096);
|
||||
assert!(
|
||||
actual["settledResults"] == saturated["completed"],
|
||||
"all4096 retained receipt IDs, inputs and result bodies must remain exact"
|
||||
);
|
||||
assert!(
|
||||
actual["settledCallIds"] == json!(expected_ids),
|
||||
"all4096 exact receipt identities must remain in settlement order"
|
||||
);
|
||||
assert!(actual["pending"].as_object().unwrap().is_empty());
|
||||
for field in ["authorized", "catalogOperations", "catalogDigest"] {
|
||||
assert!(
|
||||
actual[field] == saturated[field],
|
||||
"tool authority must remain exact"
|
||||
);
|
||||
}
|
||||
let mut expected = saturated.clone();
|
||||
expected["completed"] = json!({});
|
||||
expected["settledResults"] = saturated["completed"].clone();
|
||||
expected["settledCallIds"] = json!(expected_ids);
|
||||
expected["turnClosed"] = json!(true);
|
||||
assert!(
|
||||
actual == &expected,
|
||||
"the full bridge must match only the documented pre-RPC settlement transition"
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_for_executor_event_batch(
|
||||
executor: &mut CodexCommandExecutor,
|
||||
expected_event_type: &str,
|
||||
) -> Vec<PolledEvent> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let mut retained = Vec::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
retained.extend(poll_and_ack(executor).expect("poll and retain the entire event batch"));
|
||||
if retained
|
||||
.iter()
|
||||
.any(|event| event.event_type == expected_event_type)
|
||||
{
|
||||
return retained;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
panic!("did not observe Codex {expected_event_type} event before the deadline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_transport_buffers_notifications_while_waiting_for_responses() {
|
||||
let directory = temporary_directory("buffering");
|
||||
@@ -5006,7 +5102,7 @@ fn provider_exit_preserves_and_reconciles_the_active_turn() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_limit_rejects_the_call_and_keeps_polling_when_interrupt_fails() {
|
||||
fn receipt_limit_retries_at_capacity_pending_stop_through_authoritative_terminal() {
|
||||
let _receipt_limit_test = lock_receipt_limit_test();
|
||||
let directory = temporary_directory("receipt-limit-interrupt-failure");
|
||||
let config = provider_config(
|
||||
@@ -5040,29 +5136,84 @@ fn receipt_limit_rejects_the_call_and_keeps_polling_when_interrupt_fails() {
|
||||
"turn",
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text": "Hold this saturated turn for recovery."}),
|
||||
json!({"text": "Advance an at-capacity pending receipt-limit interruption."}),
|
||||
))
|
||||
.expect("start held provider turn");
|
||||
drop(first);
|
||||
|
||||
saturate_provider_tool_receipts(&directory);
|
||||
let saturated_bridge = read_receipt_limit_state(&directory)["toolBridge"].clone();
|
||||
seed_pending_receipt_limit_retry(&directory);
|
||||
|
||||
let mut recovered = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
|
||||
let resumed = wait_for_executor_event(&mut recovered, "session.resumed");
|
||||
assert_eq!(resumed.payload["provider"], "codex");
|
||||
let diagnostic = wait_for_executor_event(&mut recovered, "harness.diagnostic");
|
||||
assert_eq!(
|
||||
diagnostic.payload["code"],
|
||||
"semantic_tool_turn_receipt_limit"
|
||||
);
|
||||
let mut emitted = wait_for_executor_event_batch(&mut recovered, "session.resumed");
|
||||
assert!(emitted.iter().any(|event| {
|
||||
event.event_type == "session.resumed" && event.payload["provider"] == "codex"
|
||||
}));
|
||||
assert_eq!(call_count(&directory, "tool-response:failure"), 1);
|
||||
assert_eq!(call_count(&directory, "turn/interrupt"), 1);
|
||||
let interrupted = wait_for_executor_event(&mut recovered, "turn.interrupted");
|
||||
let failed_attempt = read_receipt_limit_state(&directory);
|
||||
assert_at_capacity_receipts_preserved_by_stop(&failed_attempt["toolBridge"], &saturated_bridge);
|
||||
assert_eq!(failed_attempt["receiptLimitInterruptPending"], true);
|
||||
assert_eq!(failed_attempt["receiptLimitInterruptAccepted"], false);
|
||||
assert_eq!(failed_attempt["receiptLimitInterruptAttempts"], 1);
|
||||
assert_eq!(
|
||||
interrupted.payload["provider"], "codex",
|
||||
"the retry must settle the receipt-exhausted turn"
|
||||
failed_attempt["receiptLimitInterruptDeadlineUnixMs"],
|
||||
json!(u64::MAX)
|
||||
);
|
||||
let process_generation = failed_attempt["providerProcessGeneration"].clone();
|
||||
let resumed_calls = call_count(&directory, "thread/resume");
|
||||
|
||||
emitted.extend(poll_and_ack(&mut recovered).expect("retry the first failed interrupt"));
|
||||
assert_eq!(call_count(&directory, "turn/interrupt"), 2);
|
||||
let accepted_attempt = read_receipt_limit_state(&directory);
|
||||
assert_eq!(call_count(&directory, "tool-response:failure"), 1);
|
||||
assert_at_capacity_receipts_preserved_by_stop(
|
||||
&accepted_attempt["toolBridge"],
|
||||
&saturated_bridge,
|
||||
);
|
||||
assert_eq!(accepted_attempt["receiptLimitInterruptPending"], true);
|
||||
assert_eq!(accepted_attempt["receiptLimitInterruptAccepted"], true);
|
||||
assert_eq!(accepted_attempt["receiptLimitInterruptAttempts"], 2);
|
||||
assert_eq!(
|
||||
accepted_attempt["receiptLimitInterruptDeadlineUnixMs"],
|
||||
json!(u64::MAX)
|
||||
);
|
||||
assert_eq!(
|
||||
accepted_attempt["providerProcessGeneration"],
|
||||
process_generation
|
||||
);
|
||||
assert!(!emitted.iter().any(|event| {
|
||||
event.event_type == "turn.interrupted" || event.event_type == "turn.failed"
|
||||
}));
|
||||
|
||||
emitted.extend(wait_for_executor_event_batch(
|
||||
&mut recovered,
|
||||
"turn.interrupted",
|
||||
));
|
||||
let interrupted = emitted
|
||||
.iter()
|
||||
.find(|event| event.event_type == "turn.interrupted")
|
||||
.expect("the third actual RPC must produce its authoritative terminal");
|
||||
assert_eq!(interrupted.payload["provider"], "codex");
|
||||
assert_ne!(
|
||||
interrupted.payload["code"],
|
||||
"semantic_tool_turn_receipt_limit_interrupt_deadline"
|
||||
);
|
||||
assert!(!emitted
|
||||
.iter()
|
||||
.any(|event| event.event_type == "turn.failed"));
|
||||
assert_eq!(call_count(&directory, "turn/interrupt"), 3);
|
||||
assert_eq!(call_count(&directory, "tool-response:failure"), 1);
|
||||
assert_eq!(call_count(&directory, "thread/resume"), resumed_calls);
|
||||
let settled = read_receipt_limit_state(&directory);
|
||||
assert_eq!(settled["providerProcessGeneration"], process_generation);
|
||||
assert_eq!(settled["lifecycle"], "session_open");
|
||||
assert!(settled["activeProviderTurnId"].is_null());
|
||||
assert_eq!(settled["receiptLimitInterruptPending"], false);
|
||||
assert_eq!(settled["receiptLimitInterruptAccepted"], false);
|
||||
assert_eq!(settled["receiptLimitInterruptAttempts"], 0);
|
||||
assert!(settled["receiptLimitInterruptDeadlineUnixMs"].is_null());
|
||||
|
||||
recovered.shutdown().expect("stop recovered provider");
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
@@ -5102,22 +5253,31 @@ fn receipt_limit_retry_preserves_a_turn_settled_during_provider_recovery() {
|
||||
"turn",
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text": "Recover a provider-settled receipt-limited turn."}),
|
||||
json!({"text": "Recover an at-capacity pending stop settled by its provider."}),
|
||||
))
|
||||
.expect("start held provider turn");
|
||||
drop(first);
|
||||
|
||||
saturate_provider_tool_receipts(&directory);
|
||||
let saturated_bridge = read_receipt_limit_state(&directory)["toolBridge"].clone();
|
||||
seed_pending_receipt_limit_retry(&directory);
|
||||
|
||||
let mut recovered = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
|
||||
let resumed = wait_for_executor_event(&mut recovered, "session.resumed");
|
||||
assert_eq!(resumed.payload["provider"], "codex");
|
||||
let diagnostic = wait_for_executor_event(&mut recovered, "harness.diagnostic");
|
||||
assert_eq!(
|
||||
diagnostic.payload["code"],
|
||||
"semantic_tool_turn_receipt_limit"
|
||||
);
|
||||
let mut emitted = wait_for_executor_event_batch(&mut recovered, "session.resumed");
|
||||
assert!(emitted.iter().any(|event| {
|
||||
event.event_type == "session.resumed" && event.payload["provider"] == "codex"
|
||||
}));
|
||||
assert_eq!(call_count(&directory, "tool-response:failure"), 1);
|
||||
assert_eq!(call_count(&directory, "turn/interrupt"), 1);
|
||||
let pending = read_receipt_limit_state(&directory);
|
||||
assert_at_capacity_receipts_preserved_by_stop(&pending["toolBridge"], &saturated_bridge);
|
||||
assert_eq!(pending["receiptLimitInterruptPending"], true);
|
||||
assert_eq!(pending["receiptLimitInterruptAccepted"], false);
|
||||
assert_eq!(pending["receiptLimitInterruptAttempts"], 1);
|
||||
assert_eq!(
|
||||
pending["receiptLimitInterruptDeadlineUnixMs"],
|
||||
json!(u64::MAX)
|
||||
);
|
||||
|
||||
// Lose the live transport while retaining its durable interruption retry,
|
||||
// then model the provider settling the turn before runnerd reconnects.
|
||||
@@ -5135,9 +5295,11 @@ fn receipt_limit_retry_preserves_a_turn_settled_during_provider_recovery() {
|
||||
)
|
||||
.expect("settle the fake provider turn before recovery");
|
||||
|
||||
let events = poll_and_ack(&mut recovered)
|
||||
.expect("an already-settled retry must preserve the recovered terminal state");
|
||||
assert!(events.iter().any(|event| {
|
||||
emitted.extend(
|
||||
poll_and_ack(&mut recovered)
|
||||
.expect("an already-settled retry must preserve the recovered terminal state"),
|
||||
);
|
||||
assert!(emitted.iter().any(|event| {
|
||||
event.event_type == "session.reconciled" && event.payload["activeProviderTurnId"].is_null()
|
||||
}));
|
||||
assert_eq!(
|
||||
@@ -5161,6 +5323,111 @@ fn receipt_limit_retry_preserves_a_turn_settled_during_provider_recovery() {
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_limit_recovers_at_capacity_pending_stop_after_expired_unaccepted_checkpoint() {
|
||||
let _receipt_limit_test = lock_receipt_limit_test();
|
||||
let directory = temporary_directory("receipt-limit-expired-unaccepted");
|
||||
let config = provider_config(
|
||||
&directory,
|
||||
&[
|
||||
"--require-dynamic-tool",
|
||||
"--hold-turn",
|
||||
"--emit-tool-call-on-resume",
|
||||
"--fail-first-interrupt",
|
||||
],
|
||||
);
|
||||
let runner_config = durable_config(&directory);
|
||||
let mut first = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
|
||||
first
|
||||
.execute(&command(
|
||||
"prepare",
|
||||
1,
|
||||
"run.prepare",
|
||||
json!({
|
||||
"provider": config, "authorizedTools": task_context_tool_set(),
|
||||
}),
|
||||
))
|
||||
.expect("prepare Codex provider");
|
||||
first
|
||||
.execute(&command("open", 2, "session.open", json!({})))
|
||||
.expect("open Codex session");
|
||||
first
|
||||
.execute(&command(
|
||||
"turn",
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text": "Recover an at-capacity pending stop, then expire its unaccepted interruption."}),
|
||||
))
|
||||
.expect("start held provider turn");
|
||||
drop(first);
|
||||
saturate_provider_tool_receipts(&directory);
|
||||
let saturated_bridge = read_receipt_limit_state(&directory)["toolBridge"].clone();
|
||||
seed_pending_receipt_limit_retry(&directory);
|
||||
|
||||
let mut recovered = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
|
||||
let mut emitted = wait_for_executor_event_batch(&mut recovered, "session.resumed");
|
||||
assert_eq!(call_count(&directory, "tool-response:failure"), 1);
|
||||
assert_eq!(call_count(&directory, "turn/interrupt"), 1);
|
||||
let pending = read_receipt_limit_state(&directory);
|
||||
assert_at_capacity_receipts_preserved_by_stop(&pending["toolBridge"], &saturated_bridge);
|
||||
assert_eq!(pending["receiptLimitInterruptPending"], true);
|
||||
assert_eq!(pending["receiptLimitInterruptAccepted"], false);
|
||||
assert_eq!(pending["receiptLimitInterruptAttempts"], 1);
|
||||
assert_eq!(
|
||||
pending["receiptLimitInterruptDeadlineUnixMs"],
|
||||
json!(u64::MAX)
|
||||
);
|
||||
recovered
|
||||
.shutdown()
|
||||
.expect("stop the transport before explicitly expiring its deadline");
|
||||
drop(recovered);
|
||||
let before_edit = read_receipt_limit_state(&directory);
|
||||
assert!(
|
||||
before_edit == pending,
|
||||
"shutdown must preserve the pending checkpoint"
|
||||
);
|
||||
let mut expired = before_edit.clone();
|
||||
expired["receiptLimitInterruptDeadlineUnixMs"] = json!(1);
|
||||
let mut reverse = expired.clone();
|
||||
reverse["receiptLimitInterruptDeadlineUnixMs"] =
|
||||
before_edit["receiptLimitInterruptDeadlineUnixMs"].clone();
|
||||
assert!(
|
||||
reverse == before_edit,
|
||||
"expiry must change only the existing deadline field"
|
||||
);
|
||||
fs::write(
|
||||
directory.join("codex-provider-state.json"),
|
||||
serde_json::to_vec_pretty(&expired).unwrap(),
|
||||
)
|
||||
.expect("write explicitly expired unaccepted checkpoint");
|
||||
|
||||
let mut recovered = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
|
||||
emitted.extend(wait_for_executor_event_batch(&mut recovered, "turn.failed"));
|
||||
let failed = emitted
|
||||
.iter()
|
||||
.find(|event| event.event_type == "turn.failed")
|
||||
.expect("the expired unaccepted stop must fail conservatively");
|
||||
assert_eq!(
|
||||
failed.payload["code"],
|
||||
"semantic_tool_turn_receipt_limit_interrupt_unconfirmed"
|
||||
);
|
||||
assert_eq!(failed.payload["interruptAccepted"], false);
|
||||
assert_eq!(failed.payload["providerTerminalObserved"], false);
|
||||
assert!(!emitted
|
||||
.iter()
|
||||
.any(|event| event.event_type == "turn.interrupted"));
|
||||
assert_eq!(call_count(&directory, "turn/interrupt"), 1);
|
||||
let settled = read_receipt_limit_state(&directory);
|
||||
assert_eq!(settled["lifecycle"], "closed");
|
||||
assert!(settled["activeProviderTurnId"].is_null());
|
||||
assert_eq!(settled["receiptLimitInterruptPending"], false);
|
||||
assert_eq!(settled["receiptLimitInterruptAccepted"], false);
|
||||
assert_eq!(settled["receiptLimitInterruptAttempts"], 0);
|
||||
assert!(settled["receiptLimitInterruptDeadlineUnixMs"].is_null());
|
||||
recovered.shutdown().expect("stop closed provider");
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_limit_accepts_a_terminal_after_the_initial_interrupt_deadline() {
|
||||
let _receipt_limit_test = lock_receipt_limit_test();
|
||||
|
||||
@@ -16,6 +16,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { withInstalledPackagePackInput } from "./lib/installed-package-pack.mjs";
|
||||
import { collectRunnerRuntimeDependencyRoots } from "./lib/runner-runtime-dependency-roots.mjs";
|
||||
|
||||
const runnerRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const scratchParent = process.env.PAPERCLIP_RUN_SCRATCH_DIR
|
||||
@@ -101,7 +102,6 @@ async function pack(packageRoot, destination, { runner = false } = {}) {
|
||||
}
|
||||
|
||||
async function packRunnerRuntimeDependencies(destination) {
|
||||
const runnerManifest = JSON.parse(await readFile(resolve(runnerRoot, "package.json"), "utf8"));
|
||||
const overrides = {};
|
||||
const packed = new Map();
|
||||
const queued = new Set();
|
||||
@@ -127,8 +127,8 @@ async function packRunnerRuntimeDependencies(destination) {
|
||||
}
|
||||
};
|
||||
|
||||
for (const packageName of Object.keys(runnerManifest.dependencies ?? {}).sort()) {
|
||||
await enqueue(resolve(runnerRoot, "node_modules", packageName), packageName);
|
||||
for (const { packageRoot, dependencyName } of await collectRunnerRuntimeDependencyRoots(runnerRoot)) {
|
||||
await enqueue(packageRoot, dependencyName);
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
|
||||
@@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v19-identity.json"]]) {
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v20-identity.json"]]) {
|
||||
const identity = await readJson(path);
|
||||
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
|
||||
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/** Seed every installed runtime root before traversing its dependency graph. */
|
||||
export async function collectRunnerRuntimeDependencyRoots(runnerRoot) {
|
||||
const manifest = JSON.parse(await readFile(resolve(runnerRoot, "package.json"), "utf8"));
|
||||
const required = manifest.dependencies ?? {};
|
||||
const optional = manifest.optionalDependencies ?? {};
|
||||
const roots = [];
|
||||
for (const dependencyName of Object.keys({ ...required, ...optional }).sort()) {
|
||||
let packageRoot;
|
||||
try {
|
||||
packageRoot = await realpath(resolve(runnerRoot, "node_modules", dependencyName));
|
||||
} catch (error) {
|
||||
// Platform-filtered optional packages may be absent. Malformed installed
|
||||
// roots and required dependencies must still fail the distribution check.
|
||||
if (!Object.hasOwn(required, dependencyName) && Object.hasOwn(optional, dependencyName) && error?.code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
roots.push({ dependencyName, packageRoot });
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { collectRunnerRuntimeDependencyRoots } from "./lib/runner-runtime-dependency-roots.mjs";
|
||||
|
||||
async function fixture(t, manifest) {
|
||||
const root = await mkdtemp(join(tmpdir(), "runner-runtime-roots-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ name: "synthetic-runner", version: "1.0.0", ...manifest }));
|
||||
return root;
|
||||
}
|
||||
|
||||
async function install(root, name, linked = false) {
|
||||
const packageRoot = linked ? join(root, "installed", name) : join(root, "node_modules", name);
|
||||
await mkdir(packageRoot, { recursive: true });
|
||||
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ name, version: "1.0.88" }));
|
||||
if (linked) {
|
||||
const link = join(root, "node_modules", name);
|
||||
await mkdir(dirname(link), { recursive: true });
|
||||
await symlink(packageRoot, link, "dir");
|
||||
}
|
||||
return realpath(packageRoot);
|
||||
}
|
||||
|
||||
test("collects installed root optional packages alongside required packages using concrete installed roots", async t => {
|
||||
const root = await fixture(t, { dependencies: { required: "1.0.88" }, optionalDependencies: { "@github/copilot-linux-x64": "1.0.88" } });
|
||||
const required = await install(root, "required");
|
||||
const optional = await install(root, "@github/copilot-linux-x64", true);
|
||||
const before = await readFile(join(root, "package.json"));
|
||||
assert.deepEqual(await collectRunnerRuntimeDependencyRoots(root), [
|
||||
{ dependencyName: "@github/copilot-linux-x64", packageRoot: optional },
|
||||
{ dependencyName: "required", packageRoot: required },
|
||||
]);
|
||||
assert.deepEqual(await readFile(join(root, "package.json")), before);
|
||||
});
|
||||
|
||||
test("skips genuinely absent root optional packages without suppressing required packages", async t => {
|
||||
const root = await fixture(t, { dependencies: { required: "1.0.88" }, optionalDependencies: { "@github/copilot-darwin-x64": "1.0.88" } });
|
||||
const required = await install(root, "required");
|
||||
assert.deepEqual(await collectRunnerRuntimeDependencyRoots(root), [{ dependencyName: "required", packageRoot: required }]);
|
||||
});
|
||||
|
||||
test("treats a dangling optional package link as absent", async t => {
|
||||
const root = await fixture(t, { optionalDependencies: { optional: "1.0.88" } });
|
||||
await mkdir(join(root, "node_modules"));
|
||||
await symlink(join(root, "absent"), join(root, "node_modules/optional"), "dir");
|
||||
assert.deepEqual(await collectRunnerRuntimeDependencyRoots(root), []);
|
||||
});
|
||||
|
||||
test("rejects a missing required root even when an optional root is installed", async t => {
|
||||
const root = await fixture(t, { dependencies: { missing: "1.0.88" }, optionalDependencies: { optional: "1.0.88" } });
|
||||
await install(root, "optional");
|
||||
await assert.rejects(collectRunnerRuntimeDependencyRoots(root), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("does not turn other optional-root filesystem errors into absence", async t => {
|
||||
const root = await fixture(t, { optionalDependencies: { "@scope/optional": "1.0.88" } });
|
||||
await mkdir(join(root, "node_modules"));
|
||||
await writeFile(join(root, "node_modules/@scope"), "not a package directory");
|
||||
await assert.rejects(collectRunnerRuntimeDependencyRoots(root), { code: "ENOTDIR" });
|
||||
});
|
||||
|
||||
test("does not omit a declared required root that is also listed as optional", async t => {
|
||||
const root = await fixture(t, { dependencies: { required: "1.0.88" }, optionalDependencies: { required: "1.0.88" } });
|
||||
await assert.rejects(collectRunnerRuntimeDependencyRoots(root), { code: "ENOENT" });
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeRunnerdMuseDriver } from "../drivers/muse/runnerd-muse-driver.js";
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js";
|
||||
@@ -208,9 +209,10 @@ function createTransportBackedNativeSessionBackend(
|
||||
export function describeRunnerdNativeSessionBackend(
|
||||
input: NativeExecutionInput,
|
||||
): Promise<NativeSessionBackendDescriptor> {
|
||||
if (input.schema === "paperclip.native-execution-input.v6") {
|
||||
if (input.schema === "paperclip.native-execution-input.v6" || input.schema === "paperclip.native-execution-input.v8") {
|
||||
// External bots use the shared Rust bridge and retain their provider identity.
|
||||
const descriptor = describeRunnerdExternalBotDriver(input.provider.kind);
|
||||
const descriptor = input.schema === "paperclip.native-execution-input.v8"
|
||||
? describeRunnerdMuseDriver() : describeRunnerdExternalBotDriver(input.provider.kind);
|
||||
return Promise.resolve({
|
||||
kind: "runner",
|
||||
name: descriptor.kind,
|
||||
|
||||
@@ -377,7 +377,7 @@ function assertProviderSessionIdentity(
|
||||
stage: "session.open" | "session.recover",
|
||||
): void {
|
||||
const ids = session.ids();
|
||||
const external = provider === "openai_dot_mcp" || provider === "grok_bot_mcp";
|
||||
const external = provider === "openai_dot_mcp" || provider === "grok_bot_mcp" || provider === "muse_external";
|
||||
if (
|
||||
typeof ids.driverSessionId !== "string" ||
|
||||
ids.driverSessionId.trim().length === 0 ||
|
||||
@@ -775,6 +775,7 @@ class HarnessNativeSession implements NativeSession {
|
||||
}
|
||||
|
||||
resolveRuntimeRequest(input: {
|
||||
commandId?: string;
|
||||
requestId: string;
|
||||
turnId: string;
|
||||
resolution: Parameters<
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RunnerdMuseDriver, type RunnerdMuseDriverOptions } from "../drivers/muse/runnerd-muse-driver.js";
|
||||
import { RunnerdDotDriver, type RunnerdDotDriverOptions } from "../drivers/dot/runnerd-dot-driver.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
import type { NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
@@ -22,6 +23,7 @@ export interface NativeBackendFactoryOptions extends Omit<
|
||||
CodexNativeSessionBackendOptions,
|
||||
"transportFactory"
|
||||
> {
|
||||
museRunnerOptions?: Omit<RunnerdMuseDriverOptions, "execution" | "dynamicTools" | "dynamicToolHandler" | "completionFeedback" | "onSpawn">;
|
||||
dotRunnerOptions?: Omit<RunnerdDotDriverOptions, "execution" | "dynamicTools" | "dynamicToolHandler" | "completionFeedback" | "onSpawn">;
|
||||
codexTransportFactory?: (context?: {
|
||||
baseInstructions?: string;
|
||||
@@ -52,6 +54,12 @@ export function createNativeSessionBackend(
|
||||
input: NativeExecutionInput,
|
||||
options: NativeBackendFactoryOptions = {},
|
||||
): NativeSessionBackend {
|
||||
if (input.schema === "paperclip.native-execution-input.v8") {
|
||||
if (!options.museRunnerOptions) throw new Error("Muse requires an admitted broker port and Rust Runner authority");
|
||||
return new HarnessDriverBackend(new RunnerdMuseDriver({ ...options.museRunnerOptions, execution: input,
|
||||
dynamicTools: options.dynamicTools, dynamicToolHandler: options.dynamicToolHandler,
|
||||
completionFeedback: options.completionFeedback, onSpawn: options.onSpawn }));
|
||||
}
|
||||
if (input.schema === "paperclip.native-execution-input.v6") {
|
||||
if (!options.dotRunnerOptions) throw new Error("External bots require an admitted broker port and Rust Runner authority");
|
||||
return new HarnessDriverBackend(new RunnerdDotDriver({ ...options.dotRunnerOptions, execution: input,
|
||||
|
||||
@@ -27,6 +27,10 @@ export function nativeSystemInstructions(input: NativeExecutionInput): string {
|
||||
throw new Error("native_runtime_context_entry_outside_bundle");
|
||||
}
|
||||
const entry = readFileSync(entryPath, "utf8");
|
||||
if (input.provider.kind === "muse") {
|
||||
return ["You are running as a named Paperclip agent. Complete the assigned task, follow the attached agent instructions, and use the admitted Paperclip catalog for coordination. Use list_agents and create_task when the work calls for delegation. Record durable text deliverables with write_document. When work depends on another task, preserve the existing dependencies with set_dependencies and report the concrete blocker through paperclip_block. Follow each tool's approval and completion rules.", entry.trim(),
|
||||
"Use the private Muse client's operation command for the admitted first-party Paperclip task and document catalog. Use your own approved tools for external work. Paperclip supplies no connector credentials or filesystem command grants. Keep all Paperclip data within this connection's company, agent, and current task. Use the client's ask command for questions; persist the exact saved answer into this run's continuation before consuming its input receipt. Pending or unconsumed answers block completion. Invoke paperclip_finish or paperclip_block through the native tool catalog, then submit that exact accepted report with the client's finish command."].join("\n\n");
|
||||
}
|
||||
if (input.schema === "paperclip.native-execution-input.v6") {
|
||||
const grok = input.provider.kind === "grok_bot";
|
||||
return [input.runtimeContext.prompt.text, entry.trim(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
|
||||
export type ExternalBotProviderKind = "openai_dot" | "grok_bot";
|
||||
export const GROK_BRIDGE_REVISION = "grok-bot-mcp-v1" as const;
|
||||
export const DOT_BRIDGE_REVISION = "dot-mcp-v1" as const;
|
||||
export const MUSE_BRIDGE_REVISION = "muse-v1" as const;
|
||||
export interface DotBindingSnapshot {
|
||||
bindingId: string;
|
||||
bindingGeneration: number;
|
||||
@@ -11,6 +12,8 @@ export interface DotBindingSnapshot {
|
||||
acceptByUnixMs: number;
|
||||
expiresAtUnixMs: number;
|
||||
}
|
||||
/** Public immutable Muse authority; credentials never enter the execution input. */
|
||||
export interface MuseBindingSnapshot extends DotBindingSnapshot {}
|
||||
export interface ExternalProviderBinding extends Omit<DotBindingSnapshot, "acceptByUnixMs" | "expiresAtUnixMs"> {
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
@@ -26,13 +29,15 @@ export interface ExternalProviderOperation {
|
||||
turnId: string;
|
||||
assignmentRevision: number;
|
||||
digest: string;
|
||||
action: "accept" | "tool" | "progress" | "finish" | "renew";
|
||||
action: "accept" | "tool" | "progress" | "finish" | "renew" | "request_user_input" | "consume_input";
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
/** Broker port uses only the authenticated run's existing PRP command lane. */
|
||||
export interface ExternalProviderPort {
|
||||
dispatch(event: { sourceEventId: string; payload: Record<string, unknown> }): Promise<void>;
|
||||
settle(event: { sourceEventId: string; payload: Record<string, unknown> }): Promise<void>;
|
||||
/** Persist the exact answer delivery before acknowledging the Runner event. */
|
||||
inputAvailable?(event: { sourceEventId: string; payload: Record<string, unknown> }): Promise<void>;
|
||||
attach(send: (operation: ExternalProviderOperation) => Promise<void>, revoke?: () => Promise<void>, hasProviderCheckpoint?: boolean): Promise<() => Promise<void>>;
|
||||
}
|
||||
export function externalOperationDigest(action: ExternalProviderOperation["action"], input: Record<string, unknown>): string {
|
||||
|
||||
@@ -533,6 +533,8 @@ export interface HarnessSession {
|
||||
}): Promise<void>;
|
||||
pendingRuntimeRequests?(): HarnessRuntimeRequest[];
|
||||
resolveRuntimeRequest?(input: {
|
||||
/** Durable controller command identity, distinct from the native request identity. */
|
||||
commandId?: string;
|
||||
requestId: string;
|
||||
turnId: string;
|
||||
resolution: HarnessRuntimeRequestResolution;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DotBindingSnapshot, ExternalBotProviderKind } from "./external-provider.js";
|
||||
import type { DotBindingSnapshot, MuseBindingSnapshot, ExternalBotProviderKind } from "./external-provider.js";
|
||||
import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js";
|
||||
import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js";
|
||||
import { isProviderMode } from "./provider-mode.js";
|
||||
@@ -9,6 +9,7 @@ import { explicitTaskSkillNames, parseNativeRuntimeContext, type NativeRuntimeCo
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V2 = "paperclip.native-execution-input.v2" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-input.v3" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V8 = "paperclip.native-execution-input.v8" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V6 = "paperclip.native-execution-input.v6" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V4 = "paperclip.native-execution-input.v4" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v5" as const;
|
||||
@@ -252,7 +253,13 @@ export interface NativeExecutionInputV7 extends Omit<NativeExecutionInputV5, "sc
|
||||
homeMode: NativeHomeMode;
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
export interface NativeExecutionInputV8 extends Omit<NativeExecutionInputV6, "schema" | "provider" | "session"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V8;
|
||||
provider: { kind: "muse"; model: null; binding: MuseBindingSnapshot; bridgeRevision: "muse-v1" };
|
||||
session: Omit<NativeExecutionInputV6["session"], "driverKind"> & { driverKind: "muse_external" };
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
|
||||
/** The only task data that may enter provider-visible model input. */
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
@@ -377,8 +384,24 @@ function parseExternalNativeExecutionInput(input: Record<string, unknown>): Nati
|
||||
session: { ...common.session, driverKind } };
|
||||
}
|
||||
|
||||
function parseMuseNativeExecutionInput(input: Record<string, unknown>): NativeExecutionInputV8 {
|
||||
const provider = record(input.provider, "input.provider");
|
||||
exactKeys(provider, ["kind", "model", "binding", "bridgeRevision"], "input.provider");
|
||||
const session = record(input.session, "input.session");
|
||||
if (provider.kind !== "muse" || provider.bridgeRevision !== "muse-v1" || session.driverKind !== "muse_external") {
|
||||
throw new NativeExecutionInputError("Muse v8 requires the muse-v1 bridge and muse_external driver");
|
||||
}
|
||||
const common = parseExternalNativeExecutionInput({ ...input, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V6,
|
||||
provider: { kind: "openai_dot", model: provider.model, binding: provider.binding },
|
||||
session: { ...session, driverKind: "openai_dot_mcp" } });
|
||||
return { ...common, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V8,
|
||||
provider: { kind: "muse", model: null, binding: common.provider.binding, bridgeRevision: "muse-v1" },
|
||||
session: { ...common.session, driverKind: "muse_external" } };
|
||||
}
|
||||
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
|
||||
const input = record(value, "input");
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8) return parseMuseNativeExecutionInput(input);
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseExternalNativeExecutionInput(input);
|
||||
const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
const isV5 = isV7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
@@ -930,7 +953,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?:
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) {
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8) {
|
||||
const sourceBinding = input.completionSources;
|
||||
const references = sourceBinding?.contractRevision === input.completionContract.contract.revision
|
||||
&& sourceBinding.promptSha256 === createHash("sha256").update(input.task.prompt).digest("hex")
|
||||
@@ -949,7 +972,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?:
|
||||
},
|
||||
executionMode: input.executionMode,
|
||||
planningContext: structuredClone(input.planningContext),
|
||||
workspace: input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore" ? null : { cwd: input.workspace.cwd },
|
||||
workspace: input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8 || input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore" ? null : { cwd: input.workspace.cwd },
|
||||
completionContract: {
|
||||
...structuredClone(input.completionContract.contract),
|
||||
criteria: input.completionContract.contract.criteria.map((criterion) => {
|
||||
|
||||
@@ -216,6 +216,7 @@ export interface NativeSession {
|
||||
signal: AbortSignal;
|
||||
}): NativeSessionCancellation;
|
||||
resolveRuntimeRequest?(input: {
|
||||
commandId?: string;
|
||||
requestId: string;
|
||||
turnId: string;
|
||||
resolution: HarnessRuntimeRequestResolution;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { COPILOT_CLOSURE_SHA256, verifyCopilotInstallation } from "./copilot-ins
|
||||
import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_SYSTEM_INSTRUCTION_DELIVERY } from "./copilot-profile.js";
|
||||
import { COPILOT_PERMISSION_CONTEXT_CONTRACT } from "./copilot-permission-context.js";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js";
|
||||
import { isSupportedAcpxProfileVersion } from "./profile-compatibility.js";
|
||||
|
||||
const PERMISSION_POLICY_SOURCES = [
|
||||
["copilot-permission-context.ts", "permissionContextSourceSha256"],
|
||||
@@ -56,6 +57,20 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise<S
|
||||
? path : pathToFileURL(resolve(root, path)).href));
|
||||
}
|
||||
|
||||
async function withCopilotTestPlatform<T>(arch: "x64" | "arm64", run: () => Promise<T>): Promise<T> {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, "arch");
|
||||
if (!platformDescriptor || !archDescriptor) throw new Error("Missing process platform descriptors");
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { ...platformDescriptor, value: "linux" });
|
||||
Object.defineProperty(process, "arch", { ...archDescriptor, value: arch });
|
||||
return await run();
|
||||
} finally {
|
||||
try { Object.defineProperty(process, "platform", platformDescriptor); }
|
||||
finally { Object.defineProperty(process, "arch", archDescriptor); }
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() }));
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
@@ -95,6 +110,54 @@ describe("Copilot build-owned installation", () => {
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("preserves the historical Muse v18 declaration without granting current launch", async () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-muse-historical-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
|
||||
"agentProfileVersion", "semanticSchemaBundleSourceSha256", "semanticValidatorsSourceSha256",
|
||||
]);
|
||||
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
|
||||
expect(prior.declaration.agentProfileVersion).toBe(17);
|
||||
const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const override of [
|
||||
{ commandDigest: prior.commandDigest },
|
||||
{ agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
{ commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned");
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the historical combined external-provider v19 declaration without granting current launch", async () => {
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v19-muse-historical-identity.json", import.meta.url), "utf8"));
|
||||
expect(current.declaration.agentProfileVersion).toBe(19);
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 18)).toBe(true);
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const [name, changed] of [
|
||||
["copilot-profile-v18-identity.json", ["agentProfileVersion", "semanticSchemaBundleSourceSha256", "semanticValidatorsSourceSha256"]],
|
||||
["copilot-profile-v18-muse-historical-identity.json", ["agentProfileVersion", "semanticSchemaBundleSourceSha256"]],
|
||||
] as const) {
|
||||
const prior = JSON.parse(readFileSync(new URL(`../../../test/fixtures/${name}`, import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(changed);
|
||||
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
for (const override of [
|
||||
{ commandDigest: prior.commandDigest },
|
||||
{ agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
{ commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned");
|
||||
}
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves v18 history while binding the credential update protocol sources", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v19-identity.json", import.meta.url), "utf8"));
|
||||
@@ -104,8 +167,35 @@ describe("Copilot build-owned installation", () => {
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
it("admits the current v19 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v19-identity.json", import.meta.url), "utf8"));
|
||||
it("binds both immutable v19 histories in a new combined v20 declaration", async () => {
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
expect(current.declaration.agentProfileVersion).toBe(20);
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 19)).toBe(true);
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const [name, changed] of [
|
||||
["copilot-profile-v19-identity.json", ["agentProfileVersion", "semanticSchemaBundleSourceSha256", "semanticValidatorsSourceSha256"]],
|
||||
["copilot-profile-v19-muse-historical-identity.json", ["agentProfileVersion", "semanticSchemaBundleSourceSha256", "semanticValidationSourceSha256", "semanticValidatorsSourceSha256"]],
|
||||
] as const) {
|
||||
const prior = JSON.parse(readFileSync(new URL(`../../../test/fixtures/${name}`, import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(changed);
|
||||
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
|
||||
const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
for (const override of [
|
||||
{ commandDigest: prior.commandDigest },
|
||||
{ agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
{ commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned");
|
||||
}
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("admits the current v20 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
@@ -136,7 +226,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v19-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
@@ -157,7 +247,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect(COPILOT_CLOSURE_SHA256[platform as keyof typeof COPILOT_CLOSURE_SHA256]).toBe(digest);
|
||||
}
|
||||
});
|
||||
it("ignores ambient executable selection and supplies only pinned native launch inputs", async () => {
|
||||
it("ignores ambient executable selection and supplies only pinned native launch inputs", () => withCopilotTestPlatform("x64", async () => {
|
||||
const openCommand = vi.fn();
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockResolvedValueOnce({ commandDigest: "sha256:closure", agentServerPackageJsonPath: "/verified/closure.json", agentRuntimePackageJsonPath: null, openCommand });
|
||||
const before = process.env.COPILOT_PATH;
|
||||
@@ -174,10 +264,15 @@ describe("Copilot build-owned installation", () => {
|
||||
expect(result.commandDigest).toBe(QUALIFIED_ACPX_PROFILES.copilot.commandDigest);
|
||||
expect(result.openCommand).toBe(openCommand);
|
||||
} finally { if (before === undefined) delete process.env.COPILOT_PATH; else process.env.COPILOT_PATH = before; }
|
||||
});
|
||||
}));
|
||||
it("rejects an unpinned linux-arm64 distribution before native file access", () => withCopilotTestPlatform("arm64", async () => {
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
await expect(verifyCopilotInstallation(QUALIFIED_ACPX_PROFILES.copilot)).rejects.toThrow(/^Copilot native distribution is not pinned for linux-arm64$/);
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
}));
|
||||
it("rejects changed profile identities before native file access", async () => {
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }, { agentProfileVersion: 17 }]) {
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }, { agentProfileVersion: 17 }, { agentProfileVersion: 18 }, { agentProfileVersion: 19 }]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned");
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
|
||||
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 19,
|
||||
"agentProfileVersion": 20,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:38abf359adeef43f1f19230245c6e2051c55fdd79571882ef6f00b7cb2958d93",
|
||||
"commandDigest": "sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
"qualificationStatus": "pending",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,20 @@ const roots: string[] = [];
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
const candidate = (): AcpxReleaseProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi });
|
||||
|
||||
async function withPiTestPlatform<T>(arch: "x64" | "arm64", run: () => Promise<T>): Promise<T> {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, "arch");
|
||||
if (!platformDescriptor || !archDescriptor) throw new Error("Missing process platform descriptors");
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { ...platformDescriptor, value: "linux" });
|
||||
Object.defineProperty(process, "arch", { ...archDescriptor, value: arch });
|
||||
return await run();
|
||||
} finally {
|
||||
try { Object.defineProperty(process, "platform", platformDescriptor); }
|
||||
finally { Object.defineProperty(process, "arch", archDescriptor); }
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root);
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }));
|
||||
@@ -150,7 +164,7 @@ describe("Pi installation factory", () => {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
});
|
||||
it("never takes closure authority from installed metadata", async () => {
|
||||
it("never takes closure authority from installed metadata", () => withPiTestPlatform("x64", async () => {
|
||||
const { assets } = await fixture();
|
||||
await writeFile(join(assets, "pi-distribution.json"), JSON.stringify({
|
||||
schema: "paperclip.pi-distribution.v1", runtimeRoot: "runtime", nativeClosureSha256: "0".repeat(64),
|
||||
@@ -158,7 +172,11 @@ describe("Pi installation factory", () => {
|
||||
pins: { nodeVersion: "24.21.0", wrapper: "0.0.33", runtime: "1.0.0", sdk: "0.26.0", zod: "3.25.76" },
|
||||
}));
|
||||
await expect(verifyPiInstallation(candidate())).rejects.toThrow("trusted target pin");
|
||||
});
|
||||
}));
|
||||
it("rejects an unsupported linux-arm64 target", () => withPiTestPlatform("arm64", async () => {
|
||||
expect(Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, "linux-arm64")).toBe(false);
|
||||
await expect(verifyPiInstallation(candidate())).rejects.toThrow(/^Pi distribution target is unsupported$/);
|
||||
}));
|
||||
it.runIf(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT)("launches the actual closed Pi snapshot with bound subprocess paths", async () => {
|
||||
const { root, assets } = await fixture();
|
||||
await cp(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT!, assets, { recursive: true });
|
||||
@@ -203,10 +221,10 @@ describe("Pi installation factory", () => {
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it("refuses symlinked target assets and metadata", async () => {
|
||||
it("refuses symlinked target assets and metadata", () => withPiTestPlatform("x64", async () => {
|
||||
const { root, assets } = await fixture();
|
||||
await rm(assets, { recursive: true });
|
||||
await symlink(root, assets);
|
||||
await expect(verifyPiInstallation(candidate())).rejects.toThrow("fixed asset directory");
|
||||
});
|
||||
}));
|
||||
});
|
||||
@@ -8,7 +8,7 @@ export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfi
|
||||
|
||||
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
|
||||
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,293 +1,13 @@
|
||||
import type { RunnerExecutionDriver } from "../../control-plane/runner-execution-driver.js";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import type { HarnessDriver, HarnessDriverDescriptor, HarnessSession, OpenHarnessSessionInput, PersistedHarnessSession } from "../../contracts/harness-driver.js";
|
||||
import type { NativeExecutionInputV6 } from "../../contracts/native-execution.js";
|
||||
import type { ExternalProviderPort, ExternalBotProviderKind } from "../../contracts/external-provider.js";
|
||||
import type { PrpEvent } from "../../protocol/replay-contract.js";
|
||||
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { DurablePrpControlPlane, spawnRunner, type RunnerProcessHandle } from "../../control-plane/durable-prp-control-plane.js";
|
||||
import type { DurableRecoveryIdentity } from "../../contracts/durable-recovery.js";
|
||||
import { authorizedToolSetForProvider, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding } from "../../live/runnerd-codex-transport.js";
|
||||
import { codexSemanticToolSpecs } from "../codex/codex-driver-values.js";
|
||||
import { nativeSystemInstructions } from "../../backends/runtime-context.js";
|
||||
import type { CodexNativeSessionBackendOptions } from "../../backends/codex-native-backend.js";
|
||||
|
||||
export interface RunnerdDotDriverOptions extends Pick<RunnerExecutionDriver, "runnerStateDirectory" | "runnerProcessLauncher" | "controlPlaneRegistration" | "adoptExistingRunner"> {
|
||||
execution: NativeExecutionInputV6;
|
||||
stateDirectory: string;
|
||||
identity: DurableRecoveryIdentity;
|
||||
port: ExternalProviderPort;
|
||||
runnerBinary?: string;
|
||||
/** Reads the checkpoint from the execution target. Missing state cannot authorize replay. */
|
||||
readProviderState?: () => Promise<Record<string, unknown> | null>;
|
||||
onSpawn?: CodexNativeSessionBackendOptions["onSpawn"];
|
||||
dynamicTools?: CodexNativeSessionBackendOptions["dynamicTools"];
|
||||
dynamicToolHandler?: CodexNativeSessionBackendOptions["dynamicToolHandler"];
|
||||
completionFeedback?: CodexNativeSessionBackendOptions["completionFeedback"];
|
||||
import type { ExternalBotProviderKind } from "../../contracts/external-provider.js";
|
||||
import { DOT_EXTERNAL_CODEC, GROK_EXTERNAL_CODEC, RunnerdExternalDriver, describeRunnerdExternalDriver, type RunnerdExternalDriverOptions } from "../external/runnerd-external-driver.js";
|
||||
export { externalRunnerExitMessage, runnerDisconnectDiagnostic } from "../external/runnerd-external-driver.js";
|
||||
export interface RunnerdDotDriverOptions extends Omit<RunnerdExternalDriverOptions, "execution"> { execution: NativeExecutionInputV6 }
|
||||
export function describeRunnerdExternalBotDriver(provider: ExternalBotProviderKind) {
|
||||
return describeRunnerdExternalDriver(provider === "grok_bot" ? GROK_EXTERNAL_CODEC : DOT_EXTERNAL_CODEC);
|
||||
}
|
||||
|
||||
/** Keep the remote monitor's closed failure code without logging its stderr,
|
||||
* which can contain private transport details. */
|
||||
export function externalRunnerExitMessage(result: Awaited<RunnerProcessHandle["completion"]>): string {
|
||||
const diagnostics = result.stderr ?? "";
|
||||
const reasons = [
|
||||
["transport_reconnect_grace_exceeded", "transport reconnect grace exceeded"],
|
||||
["transport_reconnect_deadline_exceeded", "transport reconnect deadline elapsed"],
|
||||
["lease_expired_requires_bootstrap", "connection lease expired"],
|
||||
] as const;
|
||||
const reason = reasons.find(([code, message]) => diagnostics.includes(code) || diagnostics.includes(message))?.[0];
|
||||
return `dot_runner_process_exited_recovery_required: code=${result.code} signal=${result.signal}${reason ? ` reason=${reason}` : ""}`;
|
||||
}
|
||||
|
||||
const capabilities = {
|
||||
resume: false, typedEvents: true, steering: false, interruption: false,
|
||||
structuredResult: true, read: true, reconciliation: true, usage: false,
|
||||
dynamicTools: true, unsupported: ["resume", "steering", "interruption", "usage", "goals", "runtimeRequestResolution", "threadLineage"],
|
||||
};
|
||||
|
||||
/** Inspect external capabilities without allocating a broker port or Runner. */
|
||||
export function describeRunnerdExternalBotDriver(provider: ExternalBotProviderKind): HarnessDriverDescriptor {
|
||||
const grok = provider === "grok_bot";
|
||||
return { kind: grok ? "grok_bot_mcp" : "openai_dot_mcp", displayName: grok ? "Grok Bot" : "OpenAI Dot", version: grok ? "grok-bot-mcp-v1" : "dot-mcp-v1", protocolVersion: "prp.v3",
|
||||
capabilities: { ...structuredClone(capabilities), completionBoundary: "paperclip_work_session", providerTermination: "unavailable", providerTranscriptStreaming: false }, runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } };
|
||||
}
|
||||
|
||||
export function describeRunnerdDotDriver(): HarnessDriverDescriptor {
|
||||
return describeRunnerdExternalBotDriver("openai_dot");
|
||||
}
|
||||
|
||||
/** Thin SDK projection. Rust owns every lifecycle decision and durable receipt. */
|
||||
export class RunnerdDotDriver implements HarnessDriver {
|
||||
constructor(readonly options: RunnerdDotDriverOptions) {}
|
||||
async descriptor() {
|
||||
return describeRunnerdExternalBotDriver(this.options.execution.provider.kind);
|
||||
}
|
||||
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
|
||||
if (input.runId !== this.options.identity.runId || input.normalizedSessionId !== this.options.identity.normalizedSessionId) {
|
||||
throw new Error("dot_runner_identity_mismatch");
|
||||
}
|
||||
input.signal?.throwIfAborted();
|
||||
const session = new RunnerdDotSession(this.options);
|
||||
try { await session.open(input.signal); return session; }
|
||||
catch (error) { await session.detachControllerForRestart().catch(() => {}); throw error; }
|
||||
}
|
||||
async recoverSession(snapshot: PersistedHarnessSession, options: { signal: AbortSignal }) {
|
||||
if (snapshot.driverKind !== describeRunnerdExternalBotDriver(this.options.execution.provider.kind).kind || snapshot.runId !== this.options.identity.runId
|
||||
|| snapshot.driverSessionId !== this.options.identity.normalizedSessionId || snapshot.providerSessionId != null) {
|
||||
return { recovered: false, reason: "Dot bridge checkpoint identity mismatch" };
|
||||
}
|
||||
let saved: Record<string, unknown> | null;
|
||||
try { saved = await readProviderCheckpoint(this.options); }
|
||||
catch { return { recovered: false, reason: "Dot bridge checkpoint invalid; external work requires reconciliation" }; }
|
||||
if (!saved) return { recovered: false, reason: "External bridge checkpoint missing; external work requires reconciliation" };
|
||||
const descriptor = saved.descriptor as Record<string, unknown> | undefined;
|
||||
if (this.options.execution.provider.kind === "grok_bot" && descriptor?.kind !== "grok_bot") {
|
||||
return { recovered: false, reason: "External bridge checkpoint provider mismatch" };
|
||||
}
|
||||
if (saved.schema !== "paperclip.runner.dot-provider-state.v1" || saved.runId !== snapshot.runId || saved.sessionId !== snapshot.driverSessionId || saved.turnId !== this.options.identity.turnId) {
|
||||
return { recovered: false, reason: "Dot bridge checkpoint authority mismatch" };
|
||||
}
|
||||
return { recovered: true, session: await this.openSession({ runId: snapshot.runId,
|
||||
normalizedSessionId: snapshot.driverSessionId, workingDirectory: this.options.stateDirectory, signal: options.signal }) };
|
||||
}
|
||||
}
|
||||
|
||||
async function readProviderCheckpoint(options: RunnerdDotDriverOptions): Promise<Record<string, unknown> | null> {
|
||||
if (options.readProviderState) return options.readProviderState();
|
||||
const path = resolve(options.runnerStateDirectory ?? resolve(options.stateDirectory, "runner"), "dot-provider-state.json");
|
||||
if (!existsSync(path)) return null;
|
||||
if (statSync(path).size > 32 * 1024 * 1024) throw new Error("dot_provider_checkpoint_too_large");
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
export function runnerDisconnectDiagnostic(core: Pick<DurablePrpControlPlane, "runnerDisconnectedForMs">): string {
|
||||
const disconnectedMs = core.runnerDisconnectedForMs();
|
||||
return `disconnectedMs=${disconnectedMs ?? "none"}`;
|
||||
}
|
||||
|
||||
class RunnerdDotSession implements HarnessSession {
|
||||
#core: DurablePrpControlPlane | null = null;
|
||||
#process: RunnerProcessHandle | null = null;
|
||||
#events: PrpEvent[] = [];
|
||||
#waiters = new Set<() => void>();
|
||||
#release: (() => Promise<void> | void) | null = null;
|
||||
#detachPort: (() => Promise<void>) | null = null;
|
||||
#closed = false;
|
||||
#failure: Error | null = null;
|
||||
constructor(readonly options: RunnerdDotDriverOptions) {}
|
||||
ids() { return { driverSessionId: this.options.identity.normalizedSessionId, providerSessionId: null, displayId: describeRunnerdExternalBotDriver(this.options.execution.provider.kind).displayName }; }
|
||||
|
||||
async open(signal?: AbortSignal) {
|
||||
const o = this.options;
|
||||
mkdirSync(o.stateDirectory, { recursive: true, mode: 0o700 });
|
||||
const runnerState = o.runnerStateDirectory ?? resolve(o.stateDirectory, "runner");
|
||||
if (!o.runnerProcessLauncher) mkdirSync(runnerState, { recursive: true, mode: 0o700 });
|
||||
const binary = o.runnerBinary ?? defaultCapabilityRunnerdBinary();
|
||||
const artifact = readRunnerdArtifactBinding(binary);
|
||||
const core = this.#core = new DurablePrpControlPlane({
|
||||
identity: o.identity, stateDirectory: resolve(o.stateDirectory, "control-plane"),
|
||||
expectedRunnerVersion: artifact.version, expectedRunnerDigest: artifact.digest,
|
||||
connectionLeaseTtlMs: Math.min(7_200_000, Math.max(60_000, o.execution.provider.binding.expiresAtUnixMs - Date.now())),
|
||||
onProtocolIntegrityError: error => { this.#failure = error; this.#wake(); },
|
||||
onCommittedEvent: async event => {
|
||||
if (event.eventType === "external_provider.dispatch_requested") await o.port.dispatch(event);
|
||||
if (event.eventType === "external_provider.operation_settled") await o.port.settle(event);
|
||||
if (!this.#events.some(e => e.sourceEventId === event.sourceEventId)) this.#events.push(event);
|
||||
this.#wake();
|
||||
},
|
||||
onSemanticToolInput: async call => {
|
||||
if (call.operationId === "paperclip_finish" || call.operationId === "paperclip_block") {
|
||||
const result = validatePrpStructuredRunResult(call.input);
|
||||
if (!result.ok || (call.operationId === "paperclip_block") !== (result.ok && result.result.reportedWorkDisposition === "blocked")) {
|
||||
return { result: { error: "Invalid completion report. Use the admitted completion contract and the correct completion tool." }, isError: true };
|
||||
}
|
||||
try { return { result: { accepted: true, completionReport: result.result, feedback: await o.completionFeedback?.(result.result) ?? "Completion accepted; finish the external turn." } }; }
|
||||
catch (error) { return { result: { error: error instanceof Error ? error.message : "Completion rejected" }, isError: true }; }
|
||||
}
|
||||
if (!o.dynamicToolHandler) throw new Error("dot_semantic_authority_unavailable");
|
||||
const result = await o.dynamicToolHandler({ tool: call.operationId, callId: call.callId,
|
||||
threadId: o.identity.normalizedSessionId, turnId: o.identity.turnId, arguments: call.input });
|
||||
const outcome = result && typeof result === "object" ? (result as Record<string, unknown>).outcome : undefined;
|
||||
return { result, isError: typeof outcome === "string" && !["completed", "succeeded", "success"].includes(outcome) };
|
||||
},
|
||||
});
|
||||
// Rehydrate the projection from the authenticated transport journal. Broker
|
||||
// persistence happens before ACK, so these events have durable projections.
|
||||
this.#events = core.store.state.committedEvents.map(e => e.envelope.payload as unknown as PrpEvent);
|
||||
const registration = o.controlPlaneRegistration ? await o.controlPlaneRegistration(core, o.identity) : null;
|
||||
this.#release = registration?.release ?? null;
|
||||
if (!registration) await core.start();
|
||||
signal?.throwIfAborted();
|
||||
if (!o.adoptExistingRunner) {
|
||||
this.#process = spawnRunner({
|
||||
processLauncher: o.runnerProcessLauncher, connectUrl: registration?.connectUrl ?? (registration?.connection ? undefined : core.connectUrl),
|
||||
connection: registration?.connection, stateDirectory: runnerState, identity: o.identity,
|
||||
ticket: core.issueBootstrapTicket(60_000), runnerBinaryPath: binary,
|
||||
runnerVersion: artifact.version, runnerDigest: artifact.digest,
|
||||
maxOutboxBytes: 16 * 1024 * 1024, p0ReserveBytes: 1024 * 1024,
|
||||
// A managed controller rollout can outlast a minute. Task authority
|
||||
// remains gated by the current controller lease throughout the gap.
|
||||
maxRuntimeMs: 0, reconnectGraceMs: o.runnerProcessLauncher ? 300_000 : 60_000,
|
||||
// No agent, OAuth, ChatGPT or provider API credentials are inherited.
|
||||
environment: { PATH: process.env.PATH },
|
||||
});
|
||||
const pid = this.#process.child.pid;
|
||||
if (pid && !o.runnerProcessLauncher) await o.onSpawn?.({ pid, processGroupId: this.#process.processGroupId ?? null,
|
||||
startedAt: this.#process.startedAt ?? new Date().toISOString() });
|
||||
void this.#process.completion.then(result => {
|
||||
if (this.#closed) return;
|
||||
// Rust exits after the authenticated shutdown receipt is committed and
|
||||
// ACKed. That exit can precede the SDK's next command poll.
|
||||
// Remote monitors do not report an exit code. The authenticated,
|
||||
// persisted receipt is the evidence of shutdown, for either launcher.
|
||||
if (core.getCommand("dot_shutdown")?.status === "completed") return;
|
||||
// Retain closed remote-monitor codes and the controller's safe
|
||||
// disconnect age; raw stderr must never reach the run failure message.
|
||||
this.#failure ??= new Error(`${externalRunnerExitMessage(result)} ${runnerDisconnectDiagnostic(core)}`);
|
||||
this.#wake();
|
||||
}, () => {
|
||||
if (this.#closed || core.getCommand("dot_shutdown")?.status === "completed") return;
|
||||
this.#failure ??= new Error(`dot_runner_process_exited_recovery_required: ${runnerDisconnectDiagnostic(core)}`);
|
||||
this.#wake();
|
||||
});
|
||||
} else if (!await o.adoptExistingRunner.isAlive()) { throw new Error("dot_runner_adoption_failed"); }
|
||||
await registration?.activate?.();
|
||||
await registration?.ready?.();
|
||||
if (registration?.failure) void registration.failure.catch(error => {
|
||||
if (!this.#closed) { this.#failure = error instanceof Error ? error : new Error("dot_runner_transport_failed"); this.#wake(); }
|
||||
});
|
||||
const checkpoint = await readProviderCheckpoint(o);
|
||||
if (!checkpoint && this.#events.some(event => event.eventType === "external_provider.dispatch_requested")) {
|
||||
throw new Error("dot_provider_checkpoint_missing_reconciliation_required");
|
||||
}
|
||||
if (checkpoint && (checkpoint.schema !== "paperclip.runner.dot-provider-state.v1"
|
||||
|| checkpoint.runId !== o.identity.runId || checkpoint.sessionId !== o.identity.normalizedSessionId
|
||||
|| checkpoint.turnId !== o.identity.turnId)) throw new Error("dot_runner_checkpoint_authority_mismatch");
|
||||
if (checkpoint && o.execution.provider.kind === "grok_bot"
|
||||
&& (checkpoint.descriptor as Record<string, unknown> | undefined)?.kind !== "grok_bot") {
|
||||
throw new Error("external_provider_checkpoint_provider_mismatch");
|
||||
}
|
||||
this.#detachPort = await o.port.attach(async operation => {
|
||||
await this.#command("external_provider.operation", { ...operation }, `dot_operation_${operation.requestId}`);
|
||||
}, () => this.interrupt(), !!checkpoint);
|
||||
await this.#command("run.prepare", {
|
||||
provider: { kind: o.execution.provider.kind, ...o.execution.provider.binding,
|
||||
instructions: `${nativeSystemInstructions(o.execution)}\n\nAccept the assignment before calling tools. Invoke paperclip_finish or paperclip_block, then submit the same structured result with ${o.execution.provider.kind === "grok_bot" ? "paperclip_grok_finish" : "paperclip_dot_finish"}. Keep request IDs stable across retries.` },
|
||||
authorizedTools: authorizedToolSetForProvider(undefined, [...o.dynamicTools ?? [], ...codexSemanticToolSpecs()]),
|
||||
completionContract: { revision: o.execution.completionContract.contract.revision,
|
||||
criterionIds: o.execution.completionContract.contract.criteria.map(c => c.id) },
|
||||
}, "dot_prepare");
|
||||
await this.#command("session.open", {}, "dot_open");
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
async #command(type: string, payload: Record<string, unknown>, id: string) {
|
||||
if (!this.#core || this.#closed) throw new Error("dot_runner_unavailable");
|
||||
this.#core.queueCommand(type, payload, id, true);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (this.#failure) throw this.#failure;
|
||||
const command = this.#core.getCommand(id);
|
||||
if (command?.status === "completed") return (command.result?.result ?? {}) as Record<string, unknown>;
|
||||
if (command && ["failed", "rejected"].includes(command.status)) {
|
||||
const error = new Error("dot_runner_operation_rejected");
|
||||
Object.assign(error, { dotOperationRejected: true });
|
||||
throw error;
|
||||
}
|
||||
if (command?.status === "indeterminate") throw new Error("dot_runner_operation_unknown_reconcile_required");
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error("dot_runner_command_pending_reconcile_with_same_request_id");
|
||||
}
|
||||
async startTurn(input: { message: { text: string } }) {
|
||||
await this.#command("turn.start", { text: input.message.text }, "dot_start");
|
||||
return { turnId: this.options.identity.turnId };
|
||||
}
|
||||
#wake() { for (const wake of this.#waiters) wake(); this.#waiters.clear(); }
|
||||
async *events(): AsyncIterable<PrpEvent> {
|
||||
let index = 0;
|
||||
while (!this.#closed) {
|
||||
while (index < this.#events.length) { const event = this.#events[index++]!; yield event; if (event.eventType === "run.terminal") return; }
|
||||
if (this.#failure) throw this.#failure;
|
||||
await new Promise<void>(resolve => this.#waiters.add(resolve));
|
||||
}
|
||||
}
|
||||
async read() { return await this.#command("session.snapshot", {}, `dot_snapshot_${randomUUID()}`); }
|
||||
async reconcile() { return { ...await this.read(), externalStopConfirmed: false, usage: null, cost: null }; }
|
||||
async usage() { return null; }
|
||||
async interrupt() { await this.#command("run.cancel", {}, "dot_cancel"); }
|
||||
async snapshot(): Promise<PersistedHarnessSession> {
|
||||
const state = await this.read();
|
||||
// Target-owned state is authoritative. Also retain the controller's recovery
|
||||
// evidence through the supplied reader after this authenticated snapshot.
|
||||
if (this.options.readProviderState && !await readProviderCheckpoint(this.options)) {
|
||||
throw new Error("dot_provider_checkpoint_missing_reconciliation_required");
|
||||
}
|
||||
const proposed = this.#events.findLast(event => event.eventType === "run.result.proposed");
|
||||
const result = proposed ? validatePrpStructuredRunResult(proposed.payload) : null;
|
||||
const terminal = this.#events.findLast(event => event.eventType === "run.terminal");
|
||||
return { driverKind: describeRunnerdExternalBotDriver(this.options.execution.provider.kind).kind, driverSessionId: this.ids().driverSessionId,
|
||||
providerSessionId: null, runId: this.options.identity.runId,
|
||||
normalizedSessionId: this.options.identity.normalizedSessionId,
|
||||
activeTurnId: typeof state.activeProviderTurnId === "string" ? state.activeProviderTurnId : null,
|
||||
semanticResult: result?.ok ? { result: result.result, turnId: this.options.identity.turnId, fingerprint: JSON.stringify(result.result) } : null,
|
||||
terminalTurns: terminal ? [{ turnId: this.options.identity.turnId, fingerprint: JSON.stringify(terminal.payload) }] : [],
|
||||
providerRecoveryPolicy: "same_session_only",
|
||||
lastSourceSequence: this.#events.at(-1)?.sourceSeq ?? 0 };
|
||||
}
|
||||
async detachControllerForRestart() {
|
||||
this.#closed = true; this.#wake();
|
||||
await this.#detachPort?.(); await this.#release?.(); await this.#core?.stop();
|
||||
await this.#core?.drainPendingConnectionProcessing();
|
||||
this.#core?.retireSemanticToolCallbacks();
|
||||
}
|
||||
async close(input: { reason: string; force?: boolean }) {
|
||||
if (this.#closed) return;
|
||||
if (input.force) await this.interrupt();
|
||||
await this.#command("session.close", {}, "dot_close");
|
||||
await this.#command("runner.shutdown", {}, "dot_shutdown");
|
||||
await this.detachControllerForRestart();
|
||||
}
|
||||
export function describeRunnerdDotDriver() { return describeRunnerdExternalBotDriver("openai_dot"); }
|
||||
/** Compatibility wrapper: deployed V6 checkpoint, schema and command identities remain unchanged. */
|
||||
export class RunnerdDotDriver extends RunnerdExternalDriver {
|
||||
constructor(options: RunnerdDotDriverOptions) { super(options, options.execution.provider.kind === "grok_bot" ? GROK_EXTERNAL_CODEC : DOT_EXTERNAL_CODEC); }
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import type { HarnessRuntimeRequest, HarnessRuntimeRequestHandoff, HarnessRuntimeRequestResolution, HarnessDriver, HarnessDriverDescriptor, HarnessSession, OpenHarnessSessionInput, PersistedHarnessSession } from "../../contracts/harness-driver.js";
|
||||
import type { NativeExecutionInputV6, NativeExecutionInputV8 } from "../../contracts/native-execution.js";
|
||||
import type { ExternalProviderPort } from "../../contracts/external-provider.js";
|
||||
import type { PrpEvent } from "../../protocol/replay-contract.js";
|
||||
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { DurablePrpControlPlane, spawnRunner, type RunnerProcessHandle } from "../../control-plane/durable-prp-control-plane.js";
|
||||
import type { DurableRecoveryIdentity } from "../../contracts/durable-recovery.js";
|
||||
import { authorizedToolSetForProvider, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, type CapabilityRunnerdCodexTransportOptions } from "../../live/runnerd-codex-transport.js";
|
||||
import { codexSemanticToolSpecs } from "../codex/codex-driver-values.js";
|
||||
import { nativeSystemInstructions } from "../../backends/runtime-context.js";
|
||||
import { parsePaperclipQuestionSet } from "../../contracts/question-set.js";
|
||||
import type { CodexNativeSessionBackendOptions } from "../../backends/codex-native-backend.js";
|
||||
|
||||
export interface RunnerdExternalDriverOptions {
|
||||
execution: NativeExecutionInputV6 | NativeExecutionInputV8;
|
||||
stateDirectory: string;
|
||||
identity: DurableRecoveryIdentity;
|
||||
port: ExternalProviderPort;
|
||||
runnerBinary?: string;
|
||||
runnerStateDirectory?: string;
|
||||
runnerProcessLauncher?: CapabilityRunnerdCodexTransportOptions["runnerProcessLauncher"];
|
||||
/** Reads the checkpoint from the execution target. Missing state cannot authorize replay. */
|
||||
readProviderState?: () => Promise<Record<string, unknown> | null>;
|
||||
controlPlaneRegistration?: CapabilityRunnerdCodexTransportOptions["controlPlaneRegistration"];
|
||||
onSpawn?: CodexNativeSessionBackendOptions["onSpawn"];
|
||||
dynamicTools?: CodexNativeSessionBackendOptions["dynamicTools"];
|
||||
dynamicToolHandler?: CodexNativeSessionBackendOptions["dynamicToolHandler"];
|
||||
completionFeedback?: CodexNativeSessionBackendOptions["completionFeedback"];
|
||||
adoptExistingRunner?: CapabilityRunnerdCodexTransportOptions["adoptExistingRunner"];
|
||||
}
|
||||
|
||||
export interface ExternalProviderCodec {
|
||||
provider: "openai_dot" | "grok_bot" | "muse";
|
||||
driverKind: "openai_dot_mcp" | "grok_bot_mcp" | "muse_external";
|
||||
displayName: string;
|
||||
revision: "dot-mcp-v1" | "grok-bot-mcp-v1" | "muse-v1";
|
||||
checkpointFile: string;
|
||||
checkpointSchema: string;
|
||||
commandPrefix: "dot" | "muse";
|
||||
completionGuidance: string;
|
||||
runtimeRequestResolution: boolean;
|
||||
}
|
||||
export const DOT_EXTERNAL_CODEC: ExternalProviderCodec = {
|
||||
provider: "openai_dot", driverKind: "openai_dot_mcp", displayName: "OpenAI Dot", revision: "dot-mcp-v1",
|
||||
checkpointFile: "dot-provider-state.json", checkpointSchema: "paperclip.runner.dot-provider-state.v1", commandPrefix: "dot",
|
||||
completionGuidance: "paperclip_dot_finish", runtimeRequestResolution: false,
|
||||
};
|
||||
export const GROK_EXTERNAL_CODEC: ExternalProviderCodec = {
|
||||
provider: "grok_bot", driverKind: "grok_bot_mcp", displayName: "Grok Bot", revision: "grok-bot-mcp-v1",
|
||||
checkpointFile: "dot-provider-state.json", checkpointSchema: "paperclip.runner.dot-provider-state.v1", commandPrefix: "dot",
|
||||
completionGuidance: "paperclip_grok_finish", runtimeRequestResolution: false,
|
||||
};
|
||||
export const MUSE_EXTERNAL_CODEC: ExternalProviderCodec = {
|
||||
provider: "muse", driverKind: "muse_external", displayName: "Muse", revision: "muse-v1",
|
||||
checkpointFile: "muse-provider-state.json", checkpointSchema: "paperclip.runner.muse-provider-state.v1", commandPrefix: "muse",
|
||||
completionGuidance: "the private client's finish command", runtimeRequestResolution: true,
|
||||
};
|
||||
const capabilities = {
|
||||
resume: false, typedEvents: true, steering: false, interruption: false,
|
||||
structuredResult: true, read: true, reconciliation: true, usage: false,
|
||||
dynamicTools: true, unsupported: ["resume", "steering", "interruption", "usage", "goals", "runtimeRequestResolution", "threadLineage"],
|
||||
};
|
||||
|
||||
/** The admission path can inspect Dot without allocating a broker port or Runner. */
|
||||
export function describeRunnerdExternalDriver(codec: ExternalProviderCodec): HarnessDriverDescriptor {
|
||||
return { kind: codec.driverKind, displayName: codec.displayName, version: codec.revision, protocolVersion: "prp.v3",
|
||||
capabilities: { ...structuredClone(capabilities), completionBoundary: "paperclip_work_session", providerTermination: "unavailable", providerTranscriptStreaming: false, runtimeRequestResolution: codec.runtimeRequestResolution,
|
||||
...(codec.runtimeRequestResolution ? { runtimeRequestHandoff: true } : {}),
|
||||
unsupported: capabilities.unsupported.filter(name => !codec.runtimeRequestResolution || name !== "runtimeRequestResolution") }, runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } };
|
||||
}
|
||||
|
||||
/** Thin SDK projection. Rust owns every lifecycle decision and durable receipt. */
|
||||
export class RunnerdExternalDriver implements HarnessDriver {
|
||||
constructor(readonly options: RunnerdExternalDriverOptions, readonly codec: ExternalProviderCodec) {}
|
||||
async descriptor() {
|
||||
return describeRunnerdExternalDriver(this.codec);
|
||||
}
|
||||
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
|
||||
if (input.runId !== this.options.identity.runId || input.normalizedSessionId !== this.options.identity.normalizedSessionId) {
|
||||
throw new Error(`${this.codec.commandPrefix}_runner_identity_mismatch`);
|
||||
}
|
||||
input.signal?.throwIfAborted();
|
||||
const session = new RunnerdExternalSession(this.options, this.codec);
|
||||
try { await session.open(input.signal); return session; }
|
||||
catch (error) { await session.detachControllerForRestart().catch(() => {}); throw error; }
|
||||
}
|
||||
async recoverSession(snapshot: PersistedHarnessSession, options: { signal: AbortSignal }) {
|
||||
if (snapshot.driverKind !== this.codec.driverKind || snapshot.runId !== this.options.identity.runId
|
||||
|| snapshot.driverSessionId !== this.options.identity.normalizedSessionId || snapshot.providerSessionId != null) {
|
||||
return { recovered: false, reason: `${this.codec.displayName} bridge checkpoint identity mismatch` };
|
||||
}
|
||||
let saved: Record<string, unknown> | null;
|
||||
try { saved = await readProviderCheckpoint(this.options, this.codec); }
|
||||
catch { return { recovered: false, reason: `${this.codec.displayName} bridge checkpoint invalid; external work requires reconciliation` }; }
|
||||
if (!saved) return { recovered: false, reason: `${this.codec.displayName} bridge checkpoint missing; external work requires reconciliation` };
|
||||
if (!checkpointMatchesProvider(saved, this.codec) || saved.runId !== snapshot.runId || saved.sessionId !== snapshot.driverSessionId || saved.turnId !== this.options.identity.turnId) {
|
||||
return { recovered: false, reason: `${this.codec.displayName} bridge checkpoint authority mismatch` };
|
||||
}
|
||||
return { recovered: true, session: await this.openSession({ runId: snapshot.runId,
|
||||
normalizedSessionId: snapshot.driverSessionId, workingDirectory: this.options.stateDirectory, signal: options.signal }) };
|
||||
}
|
||||
}
|
||||
|
||||
function checkpointMatchesProvider(state: Record<string, unknown>, codec: ExternalProviderCodec): boolean {
|
||||
return state.schema === codec.checkpointSchema && runtimeRequestPayload(state.descriptor)?.kind === codec.provider;
|
||||
}
|
||||
|
||||
export function externalRunnerExitMessage(result: Awaited<RunnerProcessHandle["completion"]>, commandPrefix: ExternalProviderCodec["commandPrefix"] = "dot"): string {
|
||||
const diagnostics = result.stderr ?? "";
|
||||
const reasons = [
|
||||
["transport_reconnect_grace_exceeded", "transport reconnect grace exceeded"],
|
||||
["transport_reconnect_deadline_exceeded", "transport reconnect deadline elapsed"],
|
||||
["lease_expired_requires_bootstrap", "connection lease expired"],
|
||||
] as const;
|
||||
const reason = reasons.find(([code, message]) => diagnostics.includes(code) || diagnostics.includes(message))?.[0];
|
||||
return `${commandPrefix}_runner_process_exited_recovery_required: code=${result.code} signal=${result.signal}${reason ? ` reason=${reason}` : ""}`;
|
||||
}
|
||||
|
||||
export function runnerDisconnectDiagnostic(core: Pick<DurablePrpControlPlane, "runnerDisconnectedForMs">): string {
|
||||
const disconnectedMs = core.runnerDisconnectedForMs();
|
||||
return `disconnectedMs=${disconnectedMs ?? "none"}`;
|
||||
}
|
||||
|
||||
async function readProviderCheckpoint(options: RunnerdExternalDriverOptions, codec: ExternalProviderCodec): Promise<Record<string, unknown> | null> {
|
||||
if (options.readProviderState) return options.readProviderState();
|
||||
const path = resolve(options.runnerStateDirectory ?? resolve(options.stateDirectory, "runner"), codec.checkpointFile);
|
||||
if (!existsSync(path)) return null;
|
||||
if (statSync(path).size > 32 * 1024 * 1024) throw new Error(`${codec.commandPrefix}_provider_checkpoint_too_large`);
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
class RunnerdExternalSession implements HarnessSession {
|
||||
#core: DurablePrpControlPlane | null = null;
|
||||
#process: RunnerProcessHandle | null = null;
|
||||
#events: PrpEvent[] = [];
|
||||
#waiters = new Set<() => void>();
|
||||
#release: (() => Promise<void> | void) | null = null;
|
||||
#detachPort: (() => Promise<void>) | null = null;
|
||||
#closed = false;
|
||||
#failure: Error | null = null;
|
||||
#resolvingRequests = new Set<string>();
|
||||
readonly handoffRuntimeRequest?: NonNullable<HarnessSession["handoffRuntimeRequest"]>;
|
||||
constructor(readonly options: RunnerdExternalDriverOptions, readonly codec: ExternalProviderCodec) {
|
||||
if (codec.runtimeRequestResolution) this.handoffRuntimeRequest = input => this.#handoffRuntimeRequest(input);
|
||||
}
|
||||
#handoffRuntimeRequest(input: Parameters<NonNullable<HarnessSession["handoffRuntimeRequest"]>>[0]): HarnessRuntimeRequestHandoff {
|
||||
if (input.signal.aborted || this.#closed || this.#failure || input.turnId !== this.options.identity.turnId
|
||||
|| Date.now() >= this.options.execution.provider.binding.expiresAtUnixMs
|
||||
|| this.#resolvingRequests.has(input.requestId)
|
||||
|| this.#events.some(event => ["turn.completed", "turn.cancelled", "turn.failed", "run.terminal"].includes(event.eventType))
|
||||
|| !this.pendingRuntimeRequests().some(request => request.requestId === input.requestId && request.turnId === input.turnId)) {
|
||||
return { result: "already_settled", cleanup: Promise.resolve() };
|
||||
}
|
||||
// Rust checkpoints the pending request before publishing its ACKed created
|
||||
// event. That same request already owns the durable external wait; no
|
||||
// process-bound RPC needs expiring, interruption, or a second question card.
|
||||
// A late answer still resolves and is ingested on this exact active turn.
|
||||
return { result: "handed_off", cleanup: Promise.resolve() };
|
||||
}
|
||||
ids() { return { driverSessionId: this.options.identity.normalizedSessionId, providerSessionId: null, displayId: this.codec.displayName }; }
|
||||
|
||||
async open(signal?: AbortSignal) {
|
||||
const o = this.options;
|
||||
if (o.execution.provider.kind !== this.codec.provider || (this.codec.runtimeRequestResolution && !o.port.inputAvailable)) {
|
||||
throw new Error("external_runner_provider_contract_mismatch");
|
||||
}
|
||||
mkdirSync(o.stateDirectory, { recursive: true, mode: 0o700 });
|
||||
const runnerState = o.runnerStateDirectory ?? resolve(o.stateDirectory, "runner");
|
||||
if (!o.runnerProcessLauncher) mkdirSync(runnerState, { recursive: true, mode: 0o700 });
|
||||
const binary = o.runnerBinary ?? defaultCapabilityRunnerdBinary();
|
||||
const artifact = readRunnerdArtifactBinding(binary);
|
||||
const core = this.#core = new DurablePrpControlPlane({
|
||||
identity: o.identity, stateDirectory: resolve(o.stateDirectory, "control-plane"),
|
||||
expectedRunnerVersion: artifact.version, expectedRunnerDigest: artifact.digest,
|
||||
connectionLeaseTtlMs: Math.min(7_200_000, Math.max(60_000, o.execution.provider.binding.expiresAtUnixMs - Date.now())),
|
||||
onProtocolIntegrityError: error => { this.#failure = error; this.#wake(); },
|
||||
onCommittedEvent: async event => {
|
||||
if (event.eventType === "external_provider.dispatch_requested") await o.port.dispatch(event);
|
||||
if (event.eventType === "external_provider.operation_settled") await o.port.settle(event);
|
||||
if (event.eventType === "external_provider.input_available") {
|
||||
if (!o.port.inputAvailable) throw new Error("external_input_delivery_unavailable");
|
||||
await o.port.inputAvailable(event);
|
||||
}
|
||||
if (!this.#events.some(e => e.sourceEventId === event.sourceEventId)) this.#events.push(event);
|
||||
this.#wake();
|
||||
},
|
||||
onSemanticToolInput: async call => {
|
||||
if (call.operationId === "paperclip_finish" || call.operationId === "paperclip_block") {
|
||||
const result = validatePrpStructuredRunResult(call.input);
|
||||
if (!result.ok || (call.operationId === "paperclip_block") !== (result.ok && result.result.reportedWorkDisposition === "blocked")) {
|
||||
return { result: { error: "Invalid completion report. Use the admitted completion contract and the correct completion tool." }, isError: true };
|
||||
}
|
||||
try { return { result: { accepted: true, completionReport: result.result, feedback: await o.completionFeedback?.(result.result) ?? "Completion accepted; finish the external turn." } }; }
|
||||
catch (error) { return { result: { error: error instanceof Error ? error.message : "Completion rejected" }, isError: true }; }
|
||||
}
|
||||
if (!o.dynamicToolHandler) throw new Error("dot_semantic_authority_unavailable");
|
||||
const result = await o.dynamicToolHandler({ tool: call.operationId, callId: call.callId,
|
||||
threadId: o.identity.normalizedSessionId, turnId: o.identity.turnId, arguments: call.input });
|
||||
const outcome = result && typeof result === "object" ? (result as Record<string, unknown>).outcome : undefined;
|
||||
return { result, isError: typeof outcome === "string" && !["completed", "succeeded", "success"].includes(outcome) };
|
||||
},
|
||||
});
|
||||
// Rehydrate the projection from the authenticated transport journal. Broker
|
||||
// persistence happens before ACK, so these events have durable projections.
|
||||
this.#events = core.store.state.committedEvents.map(e => e.envelope.payload as unknown as PrpEvent);
|
||||
const registration = o.controlPlaneRegistration ? await o.controlPlaneRegistration(core, o.identity) : null;
|
||||
this.#release = registration?.release ?? null;
|
||||
if (!registration) await core.start();
|
||||
signal?.throwIfAborted();
|
||||
if (!o.adoptExistingRunner) {
|
||||
this.#process = spawnRunner({
|
||||
processLauncher: o.runnerProcessLauncher, connectUrl: registration?.connectUrl ?? (registration?.connection ? undefined : core.connectUrl),
|
||||
connection: registration?.connection, stateDirectory: runnerState, identity: o.identity,
|
||||
ticket: core.issueBootstrapTicket(60_000), runnerBinaryPath: binary,
|
||||
runnerVersion: artifact.version, runnerDigest: artifact.digest,
|
||||
maxOutboxBytes: 16 * 1024 * 1024, p0ReserveBytes: 1024 * 1024,
|
||||
// A managed controller rollout can outlast a minute. Task authority
|
||||
// remains gated by the current controller lease throughout the gap.
|
||||
maxRuntimeMs: 0, reconnectGraceMs: o.runnerProcessLauncher ? 300_000 : 60_000,
|
||||
// No agent, OAuth, ChatGPT or provider API credentials are inherited.
|
||||
environment: { PATH: process.env.PATH },
|
||||
});
|
||||
const pid = this.#process.child.pid;
|
||||
if (pid && !o.runnerProcessLauncher) await o.onSpawn?.({ pid, processGroupId: this.#process.processGroupId ?? null,
|
||||
startedAt: this.#process.startedAt ?? new Date().toISOString() });
|
||||
void this.#process.completion.then(result => {
|
||||
if (this.#closed) return;
|
||||
// Rust exits after the authenticated shutdown receipt is committed and
|
||||
// ACKed. That exit can precede the SDK's next command poll.
|
||||
// Remote monitors do not report an exit code. The authenticated,
|
||||
// persisted receipt is the evidence of shutdown, for either launcher.
|
||||
if (core.getCommand(`${this.codec.commandPrefix}_shutdown`)?.status === "completed") return;
|
||||
this.#failure ??= new Error(`${externalRunnerExitMessage(result, this.codec.commandPrefix)} ${runnerDisconnectDiagnostic(core)}`);
|
||||
this.#wake();
|
||||
}, () => {
|
||||
if (this.#closed || core.getCommand(`${this.codec.commandPrefix}_shutdown`)?.status === "completed") return;
|
||||
this.#failure ??= new Error(`${this.codec.commandPrefix}_runner_process_exited_recovery_required: ${runnerDisconnectDiagnostic(core)}`);
|
||||
this.#wake();
|
||||
});
|
||||
} else if (!await o.adoptExistingRunner.isAlive()) { throw new Error("dot_runner_adoption_failed"); }
|
||||
await registration?.activate?.();
|
||||
await registration?.ready?.();
|
||||
if (registration?.failure) void registration.failure.catch(error => {
|
||||
if (!this.#closed) { this.#failure = error instanceof Error ? error : new Error("dot_runner_transport_failed"); this.#wake(); }
|
||||
});
|
||||
const checkpoint = await readProviderCheckpoint(o, this.codec);
|
||||
if (!checkpoint && this.#events.some(event => event.eventType === "external_provider.dispatch_requested")) {
|
||||
throw new Error(`${this.codec.commandPrefix}_provider_checkpoint_missing_reconciliation_required`);
|
||||
}
|
||||
if (checkpoint && (!checkpointMatchesProvider(checkpoint, this.codec)
|
||||
|| checkpoint.runId !== o.identity.runId || checkpoint.sessionId !== o.identity.normalizedSessionId
|
||||
|| checkpoint.turnId !== o.identity.turnId)) throw new Error("dot_runner_checkpoint_authority_mismatch");
|
||||
this.#detachPort = await o.port.attach(async operation => {
|
||||
await this.#command("external_provider.operation", { ...operation }, `${this.codec.commandPrefix}_operation_${operation.requestId}`);
|
||||
}, () => this.interrupt(), !!checkpoint);
|
||||
await this.#command("run.prepare", {
|
||||
provider: { kind: this.codec.provider, ...o.execution.provider.binding,
|
||||
instructions: `${nativeSystemInstructions(o.execution)}\n\nAccept the assignment before calling tools. Invoke paperclip_finish or paperclip_block, then submit the same structured result with ${this.codec.completionGuidance}. Keep request IDs stable across retries.` },
|
||||
authorizedTools: authorizedToolSetForProvider(undefined, [...o.dynamicTools ?? [], ...codexSemanticToolSpecs()]),
|
||||
completionContract: { revision: o.execution.completionContract.contract.revision,
|
||||
criterionIds: o.execution.completionContract.contract.criteria.map(c => c.id) },
|
||||
}, `${this.codec.commandPrefix}_prepare`);
|
||||
await this.#command("session.open", {}, `${this.codec.commandPrefix}_open`);
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
async #command(type: string, payload: Record<string, unknown>, id: string) {
|
||||
if (!this.#core || this.#closed) throw new Error("dot_runner_unavailable");
|
||||
this.#core.queueCommand(type, payload, id, true);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (this.#failure) throw this.#failure;
|
||||
const command = this.#core.getCommand(id);
|
||||
if (command?.status === "completed") return (command.result?.result ?? {}) as Record<string, unknown>;
|
||||
if (command && ["failed", "rejected"].includes(command.status)) {
|
||||
const error = new Error("dot_runner_operation_rejected");
|
||||
Object.assign(error, { dotOperationRejected: true });
|
||||
throw error;
|
||||
}
|
||||
if (command?.status === "indeterminate") throw new Error("dot_runner_operation_unknown_reconcile_required");
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error("dot_runner_command_pending_reconcile_with_same_request_id");
|
||||
}
|
||||
async startTurn(input: { message: { text: string } }) {
|
||||
await this.#command("turn.start", { text: input.message.text }, `${this.codec.commandPrefix}_start`);
|
||||
return { turnId: this.options.identity.turnId };
|
||||
}
|
||||
#wake() { for (const wake of this.#waiters) wake(); this.#waiters.clear(); }
|
||||
async *events(): AsyncIterable<PrpEvent> {
|
||||
let index = 0;
|
||||
while (!this.#closed) {
|
||||
while (index < this.#events.length) { const event = this.#events[index++]!; yield event; if (event.eventType === "run.terminal") return; }
|
||||
if (this.#failure) throw this.#failure;
|
||||
await new Promise<void>(resolve => this.#waiters.add(resolve));
|
||||
}
|
||||
}
|
||||
pendingRuntimeRequests(): HarnessRuntimeRequest[] {
|
||||
if (!this.codec.runtimeRequestResolution) return [];
|
||||
const settled = new Set(this.#events.filter(event => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(event.eventType))
|
||||
.map(event => runtimeRequestPayload(event.payload.request)?.requestId ?? event.payload.requestId));
|
||||
return this.#events.filter(event => event.eventType === "runtime_request.created").flatMap(event => {
|
||||
const request = runtimeRequestPayload(event.payload.request);
|
||||
if (!request || typeof request.requestId !== "string" || settled.has(request.requestId)) return [];
|
||||
return [{ requestId: request.requestId, requestKind: "user_input" as const, method: "request_user_input",
|
||||
turnId: this.options.identity.turnId, itemId: this.options.identity.itemId,
|
||||
status: "pending" as const, prompt: String(request.prompt ?? "Muse requests user input."), details: {},
|
||||
input: parsePaperclipQuestionSet(request.input), origin: { adapter: this.codec.revision, provider: this.codec.provider, method: "request_user_input" } }];
|
||||
});
|
||||
}
|
||||
async resolveRuntimeRequest(input: { commandId?: string; requestId: string; turnId: string; resolution: HarnessRuntimeRequestResolution }): Promise<void> {
|
||||
if (!this.codec.runtimeRequestResolution || input.turnId !== this.options.identity.turnId
|
||||
|| input.resolution.action !== "submit" || !("response" in input.resolution)) {
|
||||
throw new Error("external_runtime_request_resolution_invalid");
|
||||
}
|
||||
const defaultCommandId = `${this.codec.commandPrefix}_resolve_${input.requestId}`;
|
||||
const commandId = input.commandId ?? (defaultCommandId.length <= 160 ? defaultCommandId
|
||||
: `${this.codec.commandPrefix}_resolve_.${createHash("sha256").update(input.requestId).digest("hex")}`);
|
||||
this.#resolvingRequests.add(input.requestId);
|
||||
try {
|
||||
await this.#command("request.resolve", { requestId: input.requestId, response: input.resolution.response }, commandId);
|
||||
} catch (error) {
|
||||
// A rejected answer did not settle the request. Unknown deliveries retain
|
||||
// the resolving barrier until their exact command receipt is reconciled.
|
||||
if (error instanceof Error && "dotOperationRejected" in error && error.dotOperationRejected === true) {
|
||||
this.#resolvingRequests.delete(input.requestId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async read() { return await this.#command("session.snapshot", {}, `${this.codec.commandPrefix}_snapshot_${randomUUID()}`); }
|
||||
async reconcile() { return { ...await this.read(), externalStopConfirmed: false, usage: null, cost: null }; }
|
||||
async usage() { return null; }
|
||||
async interrupt() { await this.#command("run.cancel", {}, `${this.codec.commandPrefix}_cancel`); }
|
||||
async snapshot(): Promise<PersistedHarnessSession> {
|
||||
const state = await this.read();
|
||||
// Target-owned state is authoritative. Also retain the controller's recovery
|
||||
// evidence through the supplied reader after this authenticated snapshot.
|
||||
if (this.options.readProviderState && !await readProviderCheckpoint(this.options, this.codec)) {
|
||||
throw new Error(`${this.codec.commandPrefix}_provider_checkpoint_missing_reconciliation_required`);
|
||||
}
|
||||
const proposed = this.#events.findLast(event => event.eventType === "run.result.proposed");
|
||||
const result = proposed ? validatePrpStructuredRunResult(proposed.payload) : null;
|
||||
const terminal = this.#events.findLast(event => event.eventType === "run.terminal");
|
||||
return { driverKind: this.codec.driverKind, driverSessionId: this.ids().driverSessionId,
|
||||
providerSessionId: null, runId: this.options.identity.runId,
|
||||
normalizedSessionId: this.options.identity.normalizedSessionId,
|
||||
activeTurnId: typeof state.activeProviderTurnId === "string" ? state.activeProviderTurnId : null,
|
||||
semanticResult: result?.ok ? { result: result.result, turnId: this.options.identity.turnId, fingerprint: JSON.stringify(result.result) } : null,
|
||||
terminalTurns: terminal ? [{ turnId: this.options.identity.turnId, fingerprint: JSON.stringify(terminal.payload) }] : [],
|
||||
pendingRuntimeRequests: this.pendingRuntimeRequests(),
|
||||
providerRecoveryPolicy: "same_session_only",
|
||||
lastSourceSequence: this.#events.at(-1)?.sourceSeq ?? 0 };
|
||||
}
|
||||
async detachControllerForRestart() {
|
||||
this.#closed = true; this.#wake();
|
||||
await this.#detachPort?.(); await this.#release?.(); await this.#core?.stop();
|
||||
await this.#core?.drainPendingConnectionProcessing();
|
||||
this.#core?.retireSemanticToolCallbacks();
|
||||
}
|
||||
async close(input: { reason: string; force?: boolean }) {
|
||||
if (this.#closed) return;
|
||||
if (input.force) await this.interrupt();
|
||||
await this.#command("session.close", {}, `${this.codec.commandPrefix}_close`);
|
||||
await this.#command("runner.shutdown", {}, `${this.codec.commandPrefix}_shutdown`);
|
||||
await this.detachControllerForRestart();
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRequestPayload(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return Object.fromEntries(Object.entries(value));
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { RunnerdMuseDriver, type RunnerdMuseDriverOptions } from "./runnerd-muse-driver.js";
|
||||
import { describeRunnerdNativeSessionBackend } from "../../backends/codex-native-backend.js";
|
||||
import { createNativeSessionBackend } from "../../backends/native-backend-factory.js";
|
||||
import * as controlPlane from "../../control-plane/durable-prp-control-plane.js";
|
||||
import { externalOperationDigest, type ExternalProviderOperation } from "../../contracts/external-provider.js";
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInputV8 } from "../../contracts/native-execution.js";
|
||||
import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
||||
nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest } from "../../contracts/runtime-context.js";
|
||||
|
||||
import { HarnessDriverBackend } from "../../backends/harness-driver-backend.js";
|
||||
import { executeNativeSession } from "../../native-session-runtime.js";
|
||||
import type { NativeSession } from "../../contracts/native-session-backend.js";
|
||||
import type { ControlPlanePort } from "../../contracts/control-plane-port.js";
|
||||
import type { PrpEvent } from "../../protocol/replay-contract.js";
|
||||
import { digestPaperclipSemanticContent } from "../../semantic-tools/receipts.js";
|
||||
function execution(root: string): NativeExecutionInputV8 {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
const digest = "0".repeat(64);
|
||||
const context = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: root, fileCount: 1, totalBytes: 4 } },
|
||||
skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null },
|
||||
};
|
||||
return parseNativeExecutionInput({
|
||||
schema: "paperclip.native-execution-input.v8",
|
||||
binding: { companyId, agentId, runId: randomUUID(), issueId: randomUUID(), executionWorkspaceId: randomUUID() },
|
||||
task: { identifier: "DOT-1", title: "Synthetic bridge recovery", description: null, prompt: "Read the synthetic counter.", workMode: "standard" },
|
||||
provider: { kind: "muse", model: null, bridgeRevision: "muse-v1", binding: { companyId, agentId, bindingId: randomUUID(), bindingGeneration: 1,
|
||||
acceptByUnixMs: Date.now() + 600_000, expiresAtUnixMs: Date.now() + 7_200_000 } },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: randomUUID(), driverKind: "muse_external", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
||||
executionMode: "default", planningContext: null,
|
||||
completionContract: { id: randomUUID(), sha256: "sha256:" + digest, schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: { revision: "1", objective: "Read the counter", criteria: [{ id: "objective", requirement: "Read the counter" }] } },
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, interactionResponses: [], credentialBindings: [],
|
||||
}) as NativeExecutionInputV8;
|
||||
}
|
||||
|
||||
it("v8 closes Muse authority, workspace, credentials and bridge revision", () => {
|
||||
const input = execution("/synthetic/pinned-instructions");
|
||||
expect(buildNativeModelEnvelope(input).workspace).toBeNull();
|
||||
for (const changed of [
|
||||
{ ...input, schema: "paperclip.native-execution-input.v7" },
|
||||
{ ...input, homeMode: "preconfigured" },
|
||||
{ ...input, provider: { ...input.provider, bridgeRevision: "dot-mcp-v1" } },
|
||||
{ ...input, session: { ...input.session, driverKind: "openai_dot_mcp" } },
|
||||
{ ...input, workspace: { ...input.workspace, cwd: "/private/project" } },
|
||||
{ ...input, provider: { ...input.provider, model: "a-model" } },
|
||||
{ ...input, credentialBindings: [{ bindingId: "secret-binding", service: "muse", destination: "api.example.com", expiresAt: null, displayName: "API" }] },
|
||||
]) expect(() => parseNativeExecutionInput(changed)).toThrow();
|
||||
});
|
||||
it("describes Muse without allocating a broker or process", async () => {
|
||||
const input = execution("/synthetic/pinned-instructions");
|
||||
const spawnSpy = vi.spyOn(controlPlane, "spawnRunner");
|
||||
try {
|
||||
const backend = createNativeSessionBackend(input, { museRunnerOptions: {
|
||||
stateDirectory: "/must-not-be-created", identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn", itemId: "item" },
|
||||
port: { dispatch: async () => {}, settle: async () => {}, inputAvailable: async () => {}, attach: async () => async () => {} },
|
||||
} });
|
||||
const descriptor = await describeRunnerdNativeSessionBackend(input);
|
||||
expect(descriptor).toEqual(await backend.descriptor());
|
||||
expect(descriptor).toMatchObject({ name: "muse_external", version: "muse-v1", capabilities: { runtimeRequestResolution: true, runtimeRequestHandoff: true, usage: false } });
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
} finally { spawnSpy.mockRestore(); }
|
||||
});
|
||||
it("uses the native request, exact question command ID and durable answer receipt across controller recovery", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "muse-driver-input-"));
|
||||
await writeFile(join(root, "AGENTS.md"), "Use only first-party tools.");
|
||||
const input = execution(root);
|
||||
let send: ((operation: ExternalProviderOperation) => Promise<void>) | undefined;
|
||||
let spawned: { pid: number; processGroupId: number | null; startedAt: string } | undefined;
|
||||
const deliveries: Array<{ sourceEventId: string; payload: Record<string, unknown> }> = [];
|
||||
const settlements = new Map<string, unknown>();
|
||||
let controllerPort = 0;
|
||||
const options: RunnerdMuseDriverOptions = {
|
||||
execution: input, stateDirectory: join(root, "state"), runnerBinary: resolve("runner/target/debug/paperclip-runnerd"),
|
||||
identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn-" + input.binding.runId, itemId: "item-" + input.binding.runId },
|
||||
onSpawn: process => { spawned = process; },
|
||||
controlPlaneRegistration: async authority => { await authority.start(controllerPort); controllerPort = Number(new URL(authority.connectUrl).port);
|
||||
return { connectUrl: authority.connectUrl, release: () => authority.stop() }; },
|
||||
port: { dispatch: async () => {}, settle: async event => { settlements.set(String(event.payload.requestId), event.payload.outcome); },
|
||||
inputAvailable: async event => { deliveries.push(event); }, attach: async callback => { send = callback; return async () => { send = undefined; }; } },
|
||||
};
|
||||
const operation = (action: ExternalProviderOperation["action"], args: Record<string, unknown>): ExternalProviderOperation => ({
|
||||
requestId: randomUUID(), bindingId: input.provider.binding.bindingId, bindingGeneration: 1, runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: options.identity.turnId, assignmentRevision: 1,
|
||||
action, input: args, digest: externalOperationDigest(action, args),
|
||||
});
|
||||
const commandSpy = vi.spyOn(controlPlane.DurablePrpControlPlane.prototype, "queueCommand");
|
||||
let driver = new RunnerdMuseDriver(options);
|
||||
let session = await driver.openSession({ runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId! });
|
||||
try {
|
||||
const prepared = commandSpy.mock.calls.find(call => call[0] === "run.prepare");
|
||||
expect(JSON.stringify(prepared?.[1])).not.toContain("connections_search");
|
||||
expect(JSON.stringify(prepared?.[1])).not.toContain("search_api");
|
||||
await session.startTurn({ message: { role: "user", text: "Read the selected environment." } });
|
||||
await send!(operation("accept", {}));
|
||||
const requestId = "original-native-question";
|
||||
const questionSet = { schema: "paperclip.question_set.v1", questions: [{ id: "environment", prompt: "Which environment?", required: true,
|
||||
answerMode: "single_select", options: [{ id: "test", label: "Test" }, { id: "prod", label: "Production" }] }] };
|
||||
await send!(operation("request_user_input", { requestId, questionSet }));
|
||||
await vi.waitFor(() => expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId, turnId: options.identity.turnId, input: questionSet }]), { timeout: 10000 });
|
||||
const handoff = { requestId, turnId: options.identity.turnId, reason: "durable_handoff" as const, signal: new AbortController().signal };
|
||||
expect(session.handoffRuntimeRequest!(handoff).result).toBe("handed_off");
|
||||
expect(session.handoffRuntimeRequest!({ ...handoff, turnId: "other-turn" }).result).toBe("already_settled");
|
||||
expect(session.handoffRuntimeRequest!({ ...handoff, signal: AbortSignal.abort() }).result).toBe("already_settled");
|
||||
const waitingSnapshot = await session.snapshot();
|
||||
await session.detachControllerForRestart!();
|
||||
driver = new RunnerdMuseDriver({ ...options, adoptExistingRunner: { ...spawned!, isAlive: () => true } });
|
||||
const waitingRecovery = await driver.recoverSession(waitingSnapshot, { signal: new AbortController().signal });
|
||||
expect(waitingRecovery.recovered).toBe(true);
|
||||
session = waitingRecovery.session!;
|
||||
expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId }]);
|
||||
expect(session.handoffRuntimeRequest!(handoff).result).toBe("handed_off");
|
||||
await expect(session.resolveRuntimeRequest!({ commandId: "invalid_question_answer", requestId, turnId: options.identity.turnId,
|
||||
resolution: { action: "submit", response: { schema: "paperclip.question_response.v1", answers: { environment: { selectedOptionIds: ["missing-option"] } } } } })).rejects.toThrow();
|
||||
expect(session.handoffRuntimeRequest!(handoff).result).toBe("handed_off");
|
||||
const response = { schema: "paperclip.question_response.v1" as const, answers: { environment: { selectedOptionIds: ["test"] } } };
|
||||
const commandId = "question_" + randomUUID();
|
||||
await session.resolveRuntimeRequest!({ commandId, requestId, turnId: options.identity.turnId, resolution: { action: "submit", response } });
|
||||
expect(commandSpy).toHaveBeenCalledWith("request.resolve", { requestId, response }, commandId, true);
|
||||
await vi.waitFor(() => expect(deliveries).toHaveLength(1), { timeout: 10000 });
|
||||
const inputDigest = digestPaperclipSemanticContent({ requestId, turnId: options.identity.turnId, response });
|
||||
expect(deliveries[0]!.payload).toMatchObject({ requestId, turnId: options.identity.turnId, response, inputDigest });
|
||||
expect(session.pendingRuntimeRequests!()).toEqual([]);
|
||||
expect(session.handoffRuntimeRequest!(handoff).result).toBe("already_settled");
|
||||
expect(await session.read!()).toMatchObject({ unconsumedInputs: 1 });
|
||||
const snapshot = await session.snapshot();
|
||||
await session.detachControllerForRestart!();
|
||||
driver = new RunnerdMuseDriver({ ...options, adoptExistingRunner: { ...spawned!, isAlive: () => true } });
|
||||
const recovered = await driver.recoverSession(snapshot, { signal: new AbortController().signal });
|
||||
expect(recovered.recovered).toBe(true);
|
||||
session = recovered.session!;
|
||||
expect(await session.read!()).toMatchObject({ unconsumedInputs: 1 });
|
||||
const consume = operation("consume_input", { requestId, inputDigest });
|
||||
await send!(consume);
|
||||
await vi.waitFor(() => expect(settlements.get(consume.requestId)).toMatchObject({ status: "consumed", requestId, inputDigest }), { timeout: 10000 });
|
||||
expect(await session.read!()).toMatchObject({ unconsumedInputs: 0 });
|
||||
const checkpoint = JSON.parse(await readFile(join(root, "state/runner/muse-provider-state.json"), "utf8"));
|
||||
expect(checkpoint).toMatchObject({ schema: "paperclip.runner.muse-provider-state.v1", runtimeInputs: { [requestId]: { consumed: true, inputDigest } } });
|
||||
} finally {
|
||||
commandSpy.mockRestore();
|
||||
await session.close({ reason: "Test cleanup", force: true }).catch(() => {});
|
||||
if (spawned) { try { process.kill(spawned.pid, "SIGTERM"); } catch {} }
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it("bounds default Muse question answer command IDs at the native queue boundary and across recovery", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "muse-question-command-id-"));
|
||||
await writeFile(join(root, "AGENTS.md"), "Use only first-party tools.");
|
||||
const input = execution(root);
|
||||
let send: ((operation: ExternalProviderOperation) => Promise<void>) | undefined;
|
||||
let spawned: { pid: number; processGroupId: number | null; startedAt: string } | undefined;
|
||||
let controllerPort = 0;
|
||||
const deliveries: Array<{ sourceEventId: string; payload: Record<string, unknown> }> = [];
|
||||
const settlements = new Map<string, unknown>();
|
||||
const options: RunnerdMuseDriverOptions = {
|
||||
execution: input, stateDirectory: join(root, "state"), runnerBinary: resolve("runner/target/debug/paperclip-runnerd"),
|
||||
identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn-" + input.binding.runId, itemId: "item-" + input.binding.runId },
|
||||
onSpawn: process => { spawned = process; },
|
||||
controlPlaneRegistration: async authority => { await authority.start(controllerPort); controllerPort = Number(new URL(authority.connectUrl).port);
|
||||
return { connectUrl: authority.connectUrl, release: () => authority.stop() }; },
|
||||
port: { dispatch: async () => {}, settle: async event => { settlements.set(String(event.payload.requestId), event.payload.outcome); },
|
||||
inputAvailable: async event => { deliveries.push(event); }, attach: async callback => { send = callback; return async () => { send = undefined; }; } },
|
||||
};
|
||||
const operation = (action: ExternalProviderOperation["action"], args: Record<string, unknown>): ExternalProviderOperation => ({
|
||||
requestId: randomUUID(), bindingId: input.provider.binding.bindingId, bindingGeneration: 1, runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: options.identity.turnId, assignmentRevision: 1,
|
||||
action, input: args, digest: externalOperationDigest(action, args),
|
||||
});
|
||||
const questionSet = { schema: "paperclip.question_set.v1", questions: [{ id: "environment", prompt: "Which environment?", required: true,
|
||||
answerMode: "single_select", options: [{ id: "test", label: "Test" }] }] };
|
||||
const response = { schema: "paperclip.question_response.v1" as const, answers: { environment: { selectedOptionIds: ["test"] } } };
|
||||
const commandSpy = vi.spyOn(controlPlane.DurablePrpControlPlane.prototype, "queueCommand");
|
||||
let driver = new RunnerdMuseDriver(options);
|
||||
let session = await driver.openSession({ runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId! });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "Answer boundary questions." } });
|
||||
await send!(operation("accept", {}));
|
||||
const longId = "q".repeat(160);
|
||||
const requestIds = ["q".repeat(147), "q".repeat(148), longId, "q".repeat(159) + "z",
|
||||
createHash("sha256").update(longId).digest("hex")];
|
||||
const commandIds: string[] = [];
|
||||
for (const [index, requestId] of requestIds.entries()) {
|
||||
await send!(operation("request_user_input", { requestId, questionSet }));
|
||||
await vi.waitFor(() => expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId, input: questionSet }]), { timeout: 10000 });
|
||||
if (index === 0) {
|
||||
await expect(session.resolveRuntimeRequest!({ commandId: "x".repeat(161), requestId, turnId: options.identity.turnId,
|
||||
resolution: { action: "submit", response } })).rejects.toThrow("Durable PRP command is invalid.");
|
||||
expect(deliveries).toHaveLength(0);
|
||||
expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId }]);
|
||||
}
|
||||
await session.resolveRuntimeRequest!({ requestId, turnId: options.identity.turnId, resolution: { action: "submit", response } });
|
||||
const queued = commandSpy.mock.calls.filter(call => call[0] === "request.resolve"
|
||||
&& call[1]?.requestId === requestId && call[2] !== "x".repeat(161)).at(-1)!;
|
||||
expect(queued).toBeDefined();
|
||||
expect(queued[1]).toEqual({ requestId, response });
|
||||
expect(queued[3]).toBe(true);
|
||||
const commandId = queued[2]!;
|
||||
expect(commandId.length).toBeLessThanOrEqual(160);
|
||||
expect(commandId).toMatch(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
|
||||
if (requestId.length <= 147) expect(commandId).toBe(`muse_resolve_${requestId}`);
|
||||
else { expect(commandId).toMatch(/^muse_resolve_\.[a-f0-9]{64}$/); expect(commandId).toHaveLength(78); }
|
||||
commandIds.push(commandId);
|
||||
await vi.waitFor(() => expect(deliveries).toHaveLength(index + 1), { timeout: 10000 });
|
||||
const inputDigest = digestPaperclipSemanticContent({ requestId, turnId: options.identity.turnId, response });
|
||||
expect(deliveries[index]!.payload).toMatchObject({ requestId, turnId: options.identity.turnId, response, inputDigest });
|
||||
expect(session.pendingRuntimeRequests!()).toEqual([]);
|
||||
const snapshot = await session.snapshot();
|
||||
await session.detachControllerForRestart!();
|
||||
driver = new RunnerdMuseDriver({ ...options, adoptExistingRunner: { ...spawned!, isAlive: () => true } });
|
||||
const recovered = await driver.recoverSession(snapshot, { signal: new AbortController().signal });
|
||||
expect(recovered.recovered).toBe(true);
|
||||
session = recovered.session!;
|
||||
await session.resolveRuntimeRequest!({ requestId, turnId: options.identity.turnId, resolution: { action: "submit", response } });
|
||||
const replayed = commandSpy.mock.calls.filter(call => call[0] === "request.resolve"
|
||||
&& call[1]?.requestId === requestId && call[2] !== "x".repeat(161)).at(-1)!;
|
||||
expect(replayed).toEqual(["request.resolve", { requestId, response }, commandId, true]);
|
||||
expect(await session.read!()).toMatchObject({ unconsumedInputs: 1 });
|
||||
expect(deliveries).toHaveLength(index + 1);
|
||||
const consume = operation("consume_input", { requestId, inputDigest });
|
||||
await send!(consume);
|
||||
await vi.waitFor(() => expect(settlements.get(consume.requestId)).toMatchObject({ status: "consumed", requestId, inputDigest }), { timeout: 10000 });
|
||||
expect(await session.read!()).toMatchObject({ unconsumedInputs: 0 });
|
||||
const checkpoint = JSON.parse(await readFile(join(root, "state/runner/muse-provider-state.json"), "utf8"));
|
||||
expect(checkpoint.runtimeInputs[requestId]).toMatchObject({ consumed: true, inputDigest });
|
||||
}
|
||||
expect(new Set(commandIds).size).toBe(requestIds.length);
|
||||
} finally {
|
||||
commandSpy.mockRestore();
|
||||
await session.close({ reason: "Test cleanup", force: true }).catch(() => {});
|
||||
if (spawned) { try { process.kill(spawned.pid, "SIGTERM"); } catch {} }
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it("rejects bridge-unsupported questions at native admission without a stored request or created event", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "muse-question-admission-"));
|
||||
await writeFile(join(root, "AGENTS.md"), "Use only first-party tools.");
|
||||
const input = execution(root);
|
||||
let send: ((operation: ExternalProviderOperation) => Promise<void>) | undefined;
|
||||
let spawned: { pid: number; processGroupId: number | null; startedAt: string } | undefined;
|
||||
const options: RunnerdMuseDriverOptions = {
|
||||
execution: input, stateDirectory: join(root, "state"), runnerBinary: resolve("runner/target/debug/paperclip-runnerd"),
|
||||
identity: { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn-" + input.binding.runId, itemId: "item-" + input.binding.runId },
|
||||
onSpawn: process => { spawned = process; },
|
||||
port: { dispatch: async () => {}, settle: async () => {}, inputAvailable: async () => {},
|
||||
attach: async callback => { send = callback; return async () => { send = undefined; }; } },
|
||||
};
|
||||
const operation = (args: Record<string, unknown>): ExternalProviderOperation => ({
|
||||
requestId: randomUUID(), bindingId: input.provider.binding.bindingId, bindingGeneration: 1, runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: options.identity.turnId, assignmentRevision: 1,
|
||||
action: "request_user_input", input: args, digest: externalOperationDigest("request_user_input", args),
|
||||
});
|
||||
const driver = new RunnerdMuseDriver(options);
|
||||
const session = await driver.openSession({ runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId! });
|
||||
const events: PrpEvent[] = [];
|
||||
let eventReadFailure: unknown;
|
||||
const reading = (async () => { for await (const event of session.events()) events.push(event); })()
|
||||
.catch(error => { eventReadFailure = error; });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "Ask a supported synthetic question." } });
|
||||
const accept = operation({});
|
||||
await send!({ ...accept, action: "accept", digest: externalOperationDigest("accept", {}) });
|
||||
const valid = { schema: "paperclip.question_set.v1", questions: [{ id: ".question", prompt: "Amount?", required: true,
|
||||
answerMode: "text", textValidation: { inputType: "integer", minimum: 1, maximum: 10 } }] };
|
||||
const unsupported = [
|
||||
{ requestId: ".question", questionSet: valid },
|
||||
{ requestId: "_question", questionSet: valid },
|
||||
{ requestId: "q".repeat(161), questionSet: valid },
|
||||
{ requestId: "question-pattern", questionSet: { ...valid, questions: [{ ...valid.questions[0], textValidation: { pattern: "^[0-9]+$" } }] } },
|
||||
{ requestId: "question-bounds", questionSet: { ...valid, questions: [{ ...valid.questions[0], textValidation: { minimum: 10, maximum: 1 } }] } },
|
||||
];
|
||||
await vi.waitFor(() => expect(events.some(event => event.eventType === "external_provider.operation_settled"
|
||||
&& event.payload.requestId === accept.requestId)).toBe(true), { timeout: 10000 });
|
||||
for (const args of unsupported) {
|
||||
const before = JSON.parse(await readFile(join(root, "state/runner/muse-provider-state.json"), "utf8"));
|
||||
const ask = operation(args);
|
||||
await expect(send!(ask)).rejects.toThrow("dot_runner_operation_rejected");
|
||||
const after = JSON.parse(await readFile(join(root, "state/runner/muse-provider-state.json"), "utf8"));
|
||||
expect(after).not.toHaveProperty("runtimeInputs");
|
||||
expect(after.operations).toEqual(before.operations);
|
||||
expect(await session.read!()).toMatchObject({ status: "running", pendingRuntimeRequests: [], unconsumedInputs: 0 });
|
||||
expect(events.filter(event => event.eventType === "runtime_request.created")).toEqual([]);
|
||||
expect(events.some(event => event.eventType === "external_provider.operation_settled"
|
||||
&& event.payload.requestId === ask.requestId)).toBe(false);
|
||||
expect(eventReadFailure).toBeUndefined();
|
||||
}
|
||||
await send!(operation({ requestId: "1question._:-", questionSet: valid }));
|
||||
await vi.waitFor(() => expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId: "1question._:-", input: valid }]), { timeout: 10000 });
|
||||
const snapshot = await session.snapshot();
|
||||
expect(snapshot.pendingRuntimeRequests).toHaveLength(1);
|
||||
expect(events.filter(event => event.eventType === "runtime_request.created")).toHaveLength(1);
|
||||
expect(eventReadFailure).toBeUndefined();
|
||||
} finally {
|
||||
await session.close({ reason: "Test cleanup", force: true }).catch(() => {});
|
||||
await reading;
|
||||
if (spawned) { try { process.kill(spawned.pid, "SIGTERM"); } catch {} }
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it("keeps the exact Muse question and assignment alive past the native live window, then settles a delayed answer", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "muse-native-live-window-"));
|
||||
await writeFile(join(root, "AGENTS.md"), "Use only first-party tools.");
|
||||
const input = execution(root);
|
||||
const events: PrpEvent[] = [];
|
||||
const dispatches: PrpEvent[] = [];
|
||||
const deliveries: PrpEvent[] = [];
|
||||
const settlements = new Map<string, Record<string, unknown>>();
|
||||
let send: ((operation: ExternalProviderOperation) => Promise<void>) | undefined;
|
||||
let spawned: { pid: number; processGroupId: number | null; startedAt: string } | undefined;
|
||||
let nativeSession: NativeSession | undefined;
|
||||
let handoffSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let completed = false;
|
||||
const identity = { runnerInstanceId: randomUUID(), environmentLeaseId: randomUUID(), runId: input.binding.runId,
|
||||
normalizedSessionId: input.session.normalizedSessionId!, turnId: "turn-" + input.binding.runId, itemId: "item-" + input.binding.runId };
|
||||
const driver = new RunnerdMuseDriver({
|
||||
execution: input, stateDirectory: join(root, "state"), identity,
|
||||
runnerBinary: resolve("runner/target/debug/paperclip-runnerd"), onSpawn: process => { spawned = process; },
|
||||
port: { dispatch: async event => { dispatches.push(event); }, inputAvailable: async event => { deliveries.push(event); },
|
||||
settle: async event => { settlements.set(String(event.payload.requestId), event.payload.outcome as Record<string, unknown>); },
|
||||
attach: async callback => { send = callback; return async () => { send = undefined; }; } },
|
||||
});
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {}, async checkpointSession() {},
|
||||
async appendEvent(event) {
|
||||
const durable = event as PrpEvent;
|
||||
if (!events.some(saved => saved.sourceEventId === durable.sourceEventId)) events.push(structuredClone(durable));
|
||||
return { cursor: events.length, highestContiguousSourceSeq: durable.sourceSeq, disposition: "committed" };
|
||||
},
|
||||
async replayEvents(query) {
|
||||
const replay = events.filter(event => event.sourceInstanceId === query.sourceInstanceId && event.sourceSeq > query.afterSourceSeq);
|
||||
return { events: replay, highestContiguousSourceSeq: replay.at(-1)?.sourceSeq ?? query.afterSourceSeq };
|
||||
},
|
||||
async completeRun() { completed = true; },
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const running = executeNativeSession({ input, backend: new HarnessDriverBackend(driver), controlPlane: port,
|
||||
runnerInstanceId: identity.runnerInstanceId, controlPlaneInstanceId: randomUUID(), runtimeInputLiveWindowMs: 60,
|
||||
requireSessionCloseBeforeReturn: true, signal: abort.signal,
|
||||
onSession: session => { if (session) { nativeSession = session; handoffSpy = vi.spyOn(session, "handoffRuntimeRequest"); } },
|
||||
});
|
||||
void running.catch(() => undefined);
|
||||
const operation = (action: ExternalProviderOperation["action"], args: Record<string, unknown>): ExternalProviderOperation => ({
|
||||
requestId: randomUUID(), bindingId: input.provider.binding.bindingId, bindingGeneration: 1, runId: input.binding.runId,
|
||||
normalizedSessionId: identity.normalizedSessionId, turnId: identity.turnId, assignmentRevision: 1,
|
||||
action, input: args, digest: externalOperationDigest(action, args),
|
||||
});
|
||||
let stage = "assignment-dispatch";
|
||||
try {
|
||||
await vi.waitFor(() => expect(dispatches).toHaveLength(1), { timeout: 10000 });
|
||||
expect(await nativeSession!.capabilities()).toMatchObject({ runtimeRequestResolution: true, runtimeRequestHandoff: true });
|
||||
await send!(operation("accept", {}));
|
||||
stage = "durable-question-handoff";
|
||||
const requestId = "delayed-native-question";
|
||||
const questionSet = { schema: "paperclip.question_set.v1", questions: [{ id: "environment", prompt: "Which environment?", required: true,
|
||||
answerMode: "single_select", options: [{ id: "test", label: "Test" }] }] };
|
||||
await send!(operation("request_user_input", { requestId, questionSet }));
|
||||
await vi.waitFor(() => expect(handoffSpy).toHaveBeenCalledOnce(), { timeout: 10000 });
|
||||
expect(handoffSpy!.mock.results[0]!.value).toMatchObject({ result: "handed_off" });
|
||||
const checkpoint = JSON.parse(await readFile(join(root, "state/runner/muse-provider-state.json"), "utf8"));
|
||||
expect(checkpoint).toMatchObject({ lifecycle: "running", runtimeInputs: { [requestId]: { request: { requestId, status: "pending" }, response: null, consumed: false } } });
|
||||
expect(events.filter(event => event.eventType === "runtime_request.created")).toHaveLength(1);
|
||||
expect(events.some(event => ["runtime_request.expired", "turn.cancelled", "run.terminal"].includes(event.eventType))).toBe(false);
|
||||
expect(completed).toBe(false);
|
||||
const response = { schema: "paperclip.question_response.v1" as const, answers: { environment: { selectedOptionIds: ["test"] } } };
|
||||
stage = "delayed-answer-resolution";
|
||||
await nativeSession!.resolveRuntimeRequest!({ commandId: "question_delayed-interaction", requestId, turnId: identity.turnId, resolution: { action: "submit", response } });
|
||||
await vi.waitFor(() => expect(events.some(event => event.eventType === "runtime_request.resolved")).toBe(true), { timeout: 10000 });
|
||||
const resolved = events.find(event => event.eventType === "runtime_request.resolved")!;
|
||||
expect(resolved.payload).toMatchObject({ requestId, turnId: identity.turnId, action: "submit", response });
|
||||
await vi.waitFor(() => expect(deliveries).toHaveLength(1), { timeout: 10000 });
|
||||
const inputDigest = digestPaperclipSemanticContent({ requestId, turnId: identity.turnId, response });
|
||||
const consume = operation("consume_input", { requestId, inputDigest });
|
||||
stage = "answer-ingestion";
|
||||
await send!(consume);
|
||||
await vi.waitFor(() => expect(settlements.get(consume.requestId)).toMatchObject({ status: "consumed", requestId, inputDigest }), { timeout: 10000 });
|
||||
const result = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Ingested the delayed answer",
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] },
|
||||
evidence: [], verification: [], attentionRequests: [], artifacts: [] };
|
||||
stage = "canonical-completion";
|
||||
const completion = operation("tool", { name: "paperclip_finish", arguments: result });
|
||||
await send!(completion);
|
||||
await vi.waitFor(() => expect(settlements.get(completion.requestId)).toMatchObject({ status: "completed", isError: false }), { timeout: 10000 });
|
||||
await send!(operation("finish", { result }));
|
||||
stage = "native-finalization";
|
||||
await expect(running).resolves.toMatchObject({ result: { reportedWorkDisposition: "done" } });
|
||||
expect(completed).toBe(true);
|
||||
expect(events.some(event => event.eventType === "runtime_request.expired")).toBe(false);
|
||||
expect(handoffSpy).toHaveBeenCalledOnce();
|
||||
} catch (error) {
|
||||
throw new Error(`Muse native live-window regression failed at ${stage}`, { cause: error });
|
||||
} finally {
|
||||
abort.abort(new Error("Synthetic native live-window test cleanup"));
|
||||
const settled = await Promise.race([
|
||||
running.then(() => true, () => true),
|
||||
new Promise<false>(resolve => setTimeout(() => resolve(false), 5000)),
|
||||
]);
|
||||
handoffSpy?.mockRestore();
|
||||
if (spawned) { try { process.kill(spawned.pid, "SIGTERM"); } catch {} }
|
||||
if (settled) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45000);
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NativeExecutionInputV8 } from "../../contracts/native-execution.js";
|
||||
import type { ExternalProviderPort } from "../../contracts/external-provider.js";
|
||||
import { MUSE_EXTERNAL_CODEC, RunnerdExternalDriver, describeRunnerdExternalDriver, type RunnerdExternalDriverOptions } from "../external/runnerd-external-driver.js";
|
||||
export interface RunnerdMuseDriverOptions extends Omit<RunnerdExternalDriverOptions, "execution" | "port"> {
|
||||
execution: NativeExecutionInputV8;
|
||||
port: ExternalProviderPort & Required<Pick<ExternalProviderPort, "inputAvailable">>;
|
||||
}
|
||||
export function describeRunnerdMuseDriver() { return describeRunnerdExternalDriver(MUSE_EXTERNAL_CODEC); }
|
||||
export class RunnerdMuseDriver extends RunnerdExternalDriver {
|
||||
constructor(options: RunnerdMuseDriverOptions) { super(options, MUSE_EXTERNAL_CODEC); }
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export * from "./compatibility.js";
|
||||
export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "./drivers/acpx/installation-integrity.js";
|
||||
export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./live/bundled-remote-provider-pack.js";
|
||||
|
||||
export { RunnerdMuseDriver, describeRunnerdMuseDriver, type RunnerdMuseDriverOptions } from "./drivers/muse/runnerd-muse-driver.js";
|
||||
export { RunnerdDotDriver, type RunnerdDotDriverOptions } from "./drivers/dot/runnerd-dot-driver.js";
|
||||
|
||||
export type { RunnerExecutionDriver } from "./control-plane/runner-execution-driver.js";
|
||||
@@ -888,7 +888,10 @@ export const commandV3Schema = {
|
||||
"accept",
|
||||
"tool",
|
||||
"progress",
|
||||
"finish"
|
||||
"finish",
|
||||
"renew",
|
||||
"request_user_input",
|
||||
"consume_input"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
@@ -1062,6 +1065,140 @@ export const commandV3Schema = {
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "external_provider.operation"
|
||||
},
|
||||
"payload": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "renew"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAtUnixMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiresAtUnixMs"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "external_provider.operation"
|
||||
},
|
||||
"payload": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "request_user_input"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"questionSet": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId",
|
||||
"questionSet"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "external_provider.operation"
|
||||
},
|
||||
"payload": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "consume_input"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"inputDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId",
|
||||
"inputDigest"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": true
|
||||
@@ -1088,7 +1225,8 @@ export const providerDescriptorSchema = {
|
||||
"aws_agentcore",
|
||||
"acpx",
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
"grok_bot",
|
||||
"muse"
|
||||
]
|
||||
},
|
||||
"driver": {
|
||||
@@ -1099,7 +1237,8 @@ export const providerDescriptorSchema = {
|
||||
"aws_agentcore_harness_api",
|
||||
"acpx_runtime",
|
||||
"openai_dot_mcp",
|
||||
"grok_bot_mcp"
|
||||
"grok_bot_mcp",
|
||||
"muse_external"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
@@ -1125,7 +1264,8 @@ export const providerDescriptorSchema = {
|
||||
"anthropic_managed_agents",
|
||||
"aws_bedrock_agentcore_harness",
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
"grok_bot",
|
||||
"muse"
|
||||
]
|
||||
},
|
||||
"providerSessionId": {
|
||||
@@ -1340,6 +1480,19 @@ export const providerDescriptorSchema = {
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "muse"
|
||||
},
|
||||
"driver": {
|
||||
"const": "muse_external"
|
||||
},
|
||||
"executionKind": {
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1510,6 +1663,38 @@ export const providerDescriptorSchema = {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "muse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"service",
|
||||
"providerSessionId"
|
||||
],
|
||||
"properties": {
|
||||
"service": {
|
||||
"const": "muse"
|
||||
},
|
||||
"model": {
|
||||
"const": null
|
||||
},
|
||||
"providerSessionId": {
|
||||
"const": null
|
||||
},
|
||||
"agentProcessId": {
|
||||
"const": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
@@ -5523,6 +5708,7 @@ export const eventV3Schema = {
|
||||
"run.terminal",
|
||||
"external_provider.dispatch_requested",
|
||||
"external_provider.operation_settled",
|
||||
"external_provider.input_available",
|
||||
"auth.credentials.updated"
|
||||
]
|
||||
},
|
||||
@@ -5919,6 +6105,105 @@ export const eventV3Schema = {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"const": "external_provider.input_available"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"binding": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"companyId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"agentId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"bindingId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"runId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"normalizedSessionId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"bindingGeneration": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"assignmentRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"companyId",
|
||||
"agentId",
|
||||
"bindingId",
|
||||
"runId",
|
||||
"normalizedSessionId",
|
||||
"turnId",
|
||||
"bindingGeneration",
|
||||
"assignmentRevision"
|
||||
]
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"inputDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"response": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"binding",
|
||||
"requestId",
|
||||
"turnId",
|
||||
"inputDigest",
|
||||
"response"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import equalModule from "ajv/dist/runtime/equal.js";
|
||||
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
|
||||
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"commandDigest": "sha256:5e79c2b1a42b98a545bafe9e93f8c6b6c423bec1c18dc1f0a3cf48477b757389",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 18,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
|
||||
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06",
|
||||
"semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e",
|
||||
"semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594",
|
||||
"semanticValidatorsSourceSha256": "2232194f5da2efb3681a5727d93af5253fe7416fdea41bc4fc98ca5deeb0e4b8",
|
||||
"semanticSchemaBundleSourceSha256": "1e2b5d573a929c34a7104efe6dea9621218fb7fe7eac3851a24c41d8284c8dd4",
|
||||
"semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1"
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"commandDigest": "sha256:2fec061f29f9cac4f6aa70f9329e1b27ce0c9cf4825030fdbb78979ced9bc201",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 19,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
|
||||
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06",
|
||||
"semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e",
|
||||
"semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594",
|
||||
"semanticValidatorsSourceSha256": "2232194f5da2efb3681a5727d93af5253fe7416fdea41bc4fc98ca5deeb0e4b8",
|
||||
"semanticSchemaBundleSourceSha256": "559bde5f5e6433080fc5afd54ea35dc51a378f395b2c45728261b328b7d4d3fc",
|
||||
"semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"commandDigest": "sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 20,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
|
||||
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06",
|
||||
"semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e",
|
||||
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594",
|
||||
"semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c",
|
||||
"semanticSchemaBundleSourceSha256": "36ad8a83a17111167d3cc4ef4123b36fa648a1825acd59bd90b3f8b566a4d335",
|
||||
"semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1"
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ test("accepted fixtures satisfy the complete JSON Schemas", async () => {
|
||||
assert.doesNotThrow(() => assertSchemaInstance(validators.fixture, unsupported, "required-v2", false));
|
||||
});
|
||||
|
||||
test("PRP v3 external operations are closed and Dot has no native model or thread", async () => {
|
||||
test("PRP v3 external operations are closed and external providers have no native model or thread", async () => {
|
||||
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
|
||||
const validators = compileProtocolValidators(schemas);
|
||||
const happy = await fixture("replay/happy-path.json");
|
||||
@@ -111,6 +111,11 @@ test("PRP v3 external operations are closed and Dot has no native model or threa
|
||||
assert.equal(validators.providerDescriptor(descriptor), true);
|
||||
assert.equal(validators.providerDescriptor({ ...descriptor, model: "invented" }), false);
|
||||
assert.equal(validators.providerDescriptor({ ...descriptor, providerSessionId: "invented-thread" }), false);
|
||||
const museDescriptor = { ...descriptor, provider: "muse", driver: "muse_external", service: "muse" };
|
||||
assert.equal(validators.providerDescriptor(museDescriptor), true);
|
||||
assert.equal(validators.providerDescriptor({ ...museDescriptor, model: "invented" }), false);
|
||||
assert.equal(validators.providerDescriptor({ ...museDescriptor, providerSessionId: "invented-thread" }), false);
|
||||
assert.equal(validators.providerDescriptor({ ...museDescriptor, driver: "openai_dot_mcp" }), false);
|
||||
});
|
||||
|
||||
test("provider descriptors require coherent provider, driver, and execution combinations", async () => {
|
||||
|
||||
@@ -141,6 +141,11 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableMuse: {
|
||||
title: "Muse — Personal agent",
|
||||
description: "Connect your personal Meta Muse through its managed background receiver and the external Paperclip Runner. Experimental; requires authenticated public HTTPS and Paperclip Runner.",
|
||||
tier: "managed", cloudDefault: false, selfHostedDefault: false,
|
||||
},
|
||||
enableChatConnectors: {
|
||||
title: "Chat connectors",
|
||||
description:
|
||||
|
||||
@@ -2877,6 +2877,8 @@ export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOp
|
||||
|
||||
export type { AgentLifecycleRequest, AgentLifecycleResult } from "./types/agent-lifecycle.js";
|
||||
|
||||
export * from "./muse-protocol.js";
|
||||
export * from "./muse-invitations.js";
|
||||
export * from "./types/task-workspace.js";
|
||||
export * from "./validators/task-workspace.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AgentLifecycleState } from "./types/agent-lifecycle.js";
|
||||
import type { MuseStopBoundary } from "./muse-protocol.js";
|
||||
|
||||
/** Only transient pairing responses contain the ticket/setup instruction. */
|
||||
export interface MuseBinding {
|
||||
id: string; generation: number; revision: number;
|
||||
status: "pairing" | "connected" | "ready" | "revoked";
|
||||
paired: boolean; receiverDetected: boolean; backgroundReplyVerified: boolean;
|
||||
pairingExpiresAt: string | null; challengeExpiresAt: string | null;
|
||||
lastReceiverContactAt: string | null; lastWorkerActivityAt: string | null; lastVerifiedReplyAt: string | null;
|
||||
contactPersistenceLagMs: number; clientVersion: string | null;
|
||||
qualification: { id: string; expiresAt: string } | null;
|
||||
liveAssignments: number; uncertainOperations: number; pendingInputs: number;
|
||||
cleanup: { pending: boolean; detectorRemovalRequested?: boolean; detectorRemoved: boolean; workerQuiescenceReported: boolean; expiresAt: string | null };
|
||||
stop: { status: "none" | "cannot_confirm" | "worker_reported" | "operator_attested"; nativeEffectsUnknown: boolean; boundary: MuseStopBoundary | null };
|
||||
}
|
||||
export interface MuseInvitation { agent: { id: string; name: string; status: string }; approvalId: string | null; binding: MuseBinding | null }
|
||||
export interface MuseConnection {
|
||||
enabled: boolean; publicOrigin: string | null; agentStatus: string; agentLifecycleState: AgentLifecycleState;
|
||||
canConfigureConnection: boolean; binding: MuseBinding | null; usage: null; cost: null;
|
||||
}
|
||||
export interface MusePairing {
|
||||
bindingId: string; generation: number; revision: number; ticket: string; expiresAt: string;
|
||||
setupInstruction: string; assetVersion: 1;
|
||||
}
|
||||
export interface MuseVerifyResult { bindingId: string; generation: number; revision: number; expiresAt: string; status: "pending" }
|
||||
export interface MuseInvitationInput { name: string; role: string }
|
||||
export interface MusePairingInput { replaceBindingId?: string; expectedRevision?: number }
|
||||
export interface MuseVerifyInput { bindingId: string; generation: number; expectedRevision: number }
|
||||
export interface MuseRevokeInput extends MuseVerifyInput {}
|
||||
export interface MuseAttestStopInput { boundary: MuseStopBoundary; expectedRevision: number; workerStopped: true }
|
||||
|
||||
export interface MuseQualificationBeginResult { qualificationId: string; startedAt: string; expiresAt: string; revision: number }
|
||||
export interface MuseQualificationEvidence {
|
||||
qualificationId: string; startedAt: string; expiresAt: string; stoppedAt: string | null; deadlineEnforcedAt: string | null;
|
||||
authorityRevoked: boolean; persistenceLagMs: number; cadenceEvidenceComplete: boolean;
|
||||
contacts: Array<{ replicaId: string; bucketAt: string; firstAt: string; lastAt: string; contacts: number; gapsAtMostSevenSeconds: number; maxGapMs: number; timestamps: string[]; incomplete: boolean }>;
|
||||
assignments: Array<{ id: string; runId: string; issueId: string | null; status: string; offeredAt: string; claimedAt: string | null; nativeAcceptedAt: string | null; acceptedResultAt: string | null; finalizedAt: string | null }>;
|
||||
idleWindows: Array<{ startedAt: string; endedAt: string }>;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { museCommandSchema, museQuerySchema } from "./muse-protocol.js";
|
||||
import { paperclipQuestionSetPayloadSchema } from "./validators/issue.js";
|
||||
|
||||
const assignmentId = "00000000-0000-4000-8000-000000000001";
|
||||
const requestId = "00000000-0000-4000-8000-000000000002";
|
||||
const operation = { version: 1, assignmentId, requestId };
|
||||
const questionSet = {
|
||||
schema: "paperclip.question_set.v1",
|
||||
questions: [{ id: "answer", prompt: "What should happen next?", required: true, answerMode: "text" }],
|
||||
};
|
||||
const nativeRequestEnvelopes = [
|
||||
{
|
||||
name: "request_user_input",
|
||||
validate: (nativeRequestId: string) => museCommandSchema.safeParse({
|
||||
...operation, command: "request_user_input", nativeRequestId, questionSet,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "consume_input",
|
||||
validate: (nativeRequestId: string) => museCommandSchema.safeParse({
|
||||
...operation, command: "consume_input", nativeRequestId,
|
||||
inputDigest: `sha256:${"a".repeat(64)}`,
|
||||
continuationReceiptId: "00000000-0000-4000-8000-000000000003",
|
||||
continuationPersisted: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "input.pending",
|
||||
validate: (nativeRequestId: string) => museQuerySchema.safeParse({
|
||||
version: 1, query: "input.pending", assignmentId, nativeRequestId,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Muse native request identity bounds", () => {
|
||||
it.each(nativeRequestEnvelopes)("accepts the 160-byte ASCII boundary for $name", ({ validate }) => {
|
||||
expect(validate("Ab09._:-".repeat(20)).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(nativeRequestEnvelopes)("rejects identities Runner cannot accept for $name", ({ validate }) => {
|
||||
for (const value of ["", "a".repeat(161), "request id", "request/1", "é", "回答", "request\n", "request\u0000", ".question", "_question", ":question", "-question"]) {
|
||||
expect(validate(value).success, JSON.stringify(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Muse progress UTF-8 bounds", () => {
|
||||
const validate = (text: string) => museCommandSchema.safeParse({ ...operation, command: "progress", text });
|
||||
|
||||
it("accepts exactly 12000 ASCII bytes and rejects the next byte", () => {
|
||||
expect(validate("a".repeat(12000)).success).toBe(true);
|
||||
expect(validate("a".repeat(12001)).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["é", "答", "😀"])("bounds %s by UTF-8 bytes rather than JavaScript length", (character) => {
|
||||
const bytes = new TextEncoder().encode(character).byteLength;
|
||||
const boundary = character.repeat(12000 / bytes);
|
||||
expect(validate(boundary).success).toBe(true);
|
||||
expect(validate(`${boundary}a`).success).toBe(false);
|
||||
});
|
||||
|
||||
it("retains trimmed text semantics at the byte boundary and rejects blank text", () => {
|
||||
const text = "é".repeat(6000);
|
||||
const parsed = validate(` \n${text}\t `);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success && parsed.data.command === "progress") expect(parsed.data.text).toBe(text);
|
||||
expect(validate(" \n\t ").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Muse native question-set durable byte bound", () => {
|
||||
const encoder = new TextEncoder();
|
||||
const validate = (questionSet: unknown) => museCommandSchema.safeParse({
|
||||
...operation, command: "request_user_input", nativeRequestId: "scope-01", questionSet,
|
||||
});
|
||||
const body = (initialText: string) => ({ ...questionSet, title: "t".repeat(1000),
|
||||
description: "d".repeat(100000), questions: [{ ...questionSet.questions[0], initialText }] });
|
||||
|
||||
it("accepts the exact serialized 196 KiB boundary and rejects one more byte", () => {
|
||||
const length = 196 * 1024 - encoder.encode(JSON.stringify(body(""))).byteLength;
|
||||
const boundary = body("a".repeat(length));
|
||||
expect(encoder.encode(JSON.stringify(boundary))).toHaveLength(196 * 1024);
|
||||
expect(validate(boundary).success).toBe(true);
|
||||
expect(validate(body("a".repeat(length + 1))).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["é", "答", "😀", "\n", '"'])("counts UTF-8 and JSON escaping for %s", character => {
|
||||
const budget = 196 * 1024 - encoder.encode(JSON.stringify(body(""))).byteLength;
|
||||
const unit = encoder.encode(JSON.stringify(character)).byteLength - 2;
|
||||
const count = Math.floor(budget / unit), padding = "a".repeat(budget % unit);
|
||||
expect(validate(body(character.repeat(count) + padding)).success).toBe(true);
|
||||
expect(validate(body(character.repeat(count) + padding + "a")).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Muse bridge-supported question admission", () => {
|
||||
const validate = (questionSet: unknown) => museCommandSchema.safeParse({
|
||||
...operation, command: "request_user_input", nativeRequestId: "1question._:-", questionSet,
|
||||
});
|
||||
|
||||
it.each(["^[0-9]+$", "", "(a+)+$"])("rejects provider pattern %j while the generic canonical form remains valid", pattern => {
|
||||
const payload = { ...questionSet, questions: [{ ...questionSet.questions[0], textValidation: { pattern } }] };
|
||||
expect(paperclipQuestionSetPayloadSchema.safeParse(payload).success).toBe(true);
|
||||
expect(validate(payload).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([{ minLength: 2, maxLength: 1 }, { minimum: 2, maximum: 1 }])("rejects conflicting canonical bounds %j", textValidation => {
|
||||
expect(validate({ ...questionSet, questions: [{ ...questionSet.questions[0], textValidation }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves independent question and option identities plus supported text and choice forms", () => {
|
||||
const payload = { ...questionSet, questions: [
|
||||
{ id: ".question", prompt: "Amount?", required: true, answerMode: "text", initialText: "2",
|
||||
textValidation: { inputType: "integer", minLength: 0, maxLength: 3, minimum: 1, maximum: 10 } },
|
||||
{ id: "é choice", prompt: "Choose", required: false, answerMode: "single_select",
|
||||
options: [{ id: "_option", label: "Test" }, { id: "option space", label: "Production" }], customAnswer: { enabled: true } },
|
||||
] };
|
||||
const result = validate(payload);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success && result.data.command === "request_user_input") expect(result.data.questionSet).toEqual(payload);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
import { paperclipQuestionSetPayloadSchema } from "./validators/issue.js";
|
||||
|
||||
export const MUSE_PROTOCOL_VERSION = 1 as const;
|
||||
export const MUSE_ASSET_VERSION = 1 as const;
|
||||
export const MUSE_PUBLIC_ROUTES = [
|
||||
{ method: "GET", path: "/api/muse/v1/assets/1/manifest.json" },
|
||||
{ method: "GET", path: "/api/muse/v1/assets/1/client.py" },
|
||||
{ method: "GET", path: "/api/muse/v1/assets/1/detector.sh" },
|
||||
{ method: "GET", path: "/api/muse/v1/assets/1/instructions.md" },
|
||||
{ method: "POST", path: "/api/muse/v1/pair" },
|
||||
{ method: "POST", path: "/api/muse/v1/refresh" },
|
||||
{ method: "GET", path: "/api/muse/v1/signal" },
|
||||
{ method: "POST", path: "/api/muse/v1/commands" },
|
||||
{ method: "POST", path: "/api/muse/v1/queries" },
|
||||
{ method: "POST", path: "/api/muse/v1/cleanup" },
|
||||
{ method: "POST", path: "/api/muse/v1/detector-cleanup" },
|
||||
] as const;
|
||||
|
||||
/** Machine identity is never a human actor or a Dot OAuth grant. */
|
||||
export interface AgentConnectionSubject {
|
||||
provider: "muse";
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
bindingId: string;
|
||||
generation: number;
|
||||
authorizingUserId: string;
|
||||
credentialId: string;
|
||||
}
|
||||
export const MUSE_MAX_BODY_BYTES = 256 * 1024;
|
||||
export const MUSE_TICKET_TTL_MS = 10 * 60_000;
|
||||
export const MUSE_ACCESS_TTL_MS = 15 * 60_000;
|
||||
export const MUSE_REFRESH_INACTIVITY_MS = 30 * 24 * 60 * 60_000;
|
||||
export const MUSE_CLEANUP_TTL_MS = 24 * 60 * 60_000;
|
||||
export const MUSE_IDLE_OPERATIONS = ["identify", "task.list", "task.search", "task.read", "task.history", "task.document.read", "task.create", "task.comment"] as const;
|
||||
const id = z.uuid();
|
||||
const boundedId = z.string().min(1).max(200);
|
||||
// Runtime correlation must also be representable by the native question bridge.
|
||||
// Canonical per-question/option identities retain their separate string grammar.
|
||||
const nativeRequestId = z.string().min(1).max(160).refine(
|
||||
(value) => /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(value),
|
||||
"Native request IDs must start with an ASCII letter or digit and contain only ASCII letters, digits, '.', '_', ':', or '-'.",
|
||||
);
|
||||
const utf8Encoder = new TextEncoder();
|
||||
const nativeQuestionSet = paperclipQuestionSetPayloadSchema.refine(
|
||||
value => utf8Encoder.encode(JSON.stringify(value)).byteLength <= 196 * 1024,
|
||||
"Question sets must not exceed Runner's 196 KiB durable UTF-8 byte bound.",
|
||||
).refine(
|
||||
value => value.questions.every(question => question.textValidation?.pattern === undefined),
|
||||
"Native question patterns are unsupported by the bounded question bridge.",
|
||||
);
|
||||
const progressText = z.string().trim().min(1).max(12000).refine(
|
||||
(value) => utf8Encoder.encode(value).byteLength <= 12000,
|
||||
"Progress text must not exceed 12000 UTF-8 bytes.",
|
||||
);
|
||||
const envelope = { version: z.literal(MUSE_PROTOCOL_VERSION) };
|
||||
const operation = { ...envelope, assignmentId: id, requestId: id };
|
||||
export const museCommandSchema = z.discriminatedUnion("command", [
|
||||
z.object({ ...envelope, command: z.literal("challenge.confirm"), nonce: z.string().min(1).max(200), requestId: id }).strict(),
|
||||
z.object({ ...envelope, command: z.literal("work.request"), issueId: id, requestId: id }).strict(),
|
||||
z.object({ ...envelope, command: z.literal("turn.request"), prompt: z.string().trim().min(1).max(16000), requestId: id }).strict(),
|
||||
z.object({ ...envelope, command: z.literal("task.create"), requestId: id, title: z.string().trim().min(1).max(500), description: z.string().max(16000).optional(), parentId: id.optional(), projectId: id.optional() }).strict(),
|
||||
z.object({ ...envelope, command: z.literal("task.comment"), requestId: id, issueId: id, body: z.string().trim().min(1).max(16000) }).strict(),
|
||||
z.object({ ...operation, command: z.literal("accept") }).strict(),
|
||||
z.object({ ...operation, command: z.literal("tool"), name: boundedId, arguments: z.record(z.string(), z.unknown()) }).strict(),
|
||||
z.object({ ...operation, command: z.literal("progress"), text: progressText }).strict(),
|
||||
z.object({ ...operation, command: z.literal("finish"), result: z.record(z.string(), z.unknown()) }).strict(),
|
||||
z.object({ ...operation, command: z.literal("renew"), expiresAtUnixMs: z.number().int().positive() }).strict(),
|
||||
z.object({ ...operation, command: z.literal("request_user_input"), nativeRequestId, questionSet: nativeQuestionSet }).strict(),
|
||||
z.object({ ...operation, command: z.literal("consume_input"), nativeRequestId, inputDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/), continuationReceiptId: id, continuationPersisted: z.literal(true) }).strict(),
|
||||
]);
|
||||
export const museQuerySchema = z.discriminatedUnion("query", [
|
||||
z.object({ ...envelope, query: z.literal("identify") }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("mailbox"), after: z.number().int().nonnegative().default(0) }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("assignment.read"), assignmentId: id }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("input.pending"), assignmentId: id, nativeRequestId }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("operation.receipt"), assignmentId: id, requestId: id }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("task.list"), after: id.optional() }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("task.search"), text: z.string().trim().min(1).max(500), after: id.optional() }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("task.read"), issueId: id }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("task.history"), issueId: id }).strict(),
|
||||
z.object({ ...envelope, query: z.literal("task.document.read"), issueId: id, key: z.string().min(1).max(100) }).strict(),
|
||||
]);
|
||||
export const musePairSchema = z.object({ ...envelope, ticket: z.string().min(20).max(200), clientVersion: z.literal("1") }).strict();
|
||||
export const museRefreshSchema = z.object({ ...envelope, refreshToken: z.string().min(20).max(200) }).strict();
|
||||
export const museStopBoundarySchema = z.object({ bindingId: id, generation: z.number().int().positive(), assignmentId: id, runId: id,
|
||||
turnId: boundedId, assignmentRevision: z.number().int().positive(), stopNonce: id, operationBoundary: z.string().max(200).nullable() }).strict();
|
||||
export const museCleanupSchema = z.discriminatedUnion("command", [
|
||||
z.object({ ...envelope, command: z.literal("control.inspect") }).strict(),
|
||||
z.object({ ...envelope, command: z.literal("worker.quiescent"), requestId: id, boundary: museStopBoundarySchema }).strict(),
|
||||
]);
|
||||
export const museDetectorCleanupSchema = z.union([
|
||||
z.object({ ...envelope, requestId: id, bindingId: id, generation: z.number().int().positive(), detectorRemoved: z.literal(true) }).strict(),
|
||||
z.object({ ...envelope, requestId: id, bindingId: id, generation: z.number().int().positive(), detectorRemovalRequested: z.literal(true) }).strict(),
|
||||
]);
|
||||
export type MuseCommand = z.infer<typeof museCommandSchema>;
|
||||
export type MuseQuery = z.infer<typeof museQuerySchema>;
|
||||
export type MuseStopBoundary = z.infer<typeof museStopBoundarySchema>;
|
||||
export interface MuseCredentials {
|
||||
version: 1; bindingId: string; generation: number; companyId: string; agentId: string;
|
||||
accessToken: string; accessExpiresAt: string; refreshToken: string;
|
||||
refreshExpiresAt: string; signalToken: string; cleanupToken: string; detectorCleanupToken: string;
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export interface InstanceExperimentalSettings {
|
||||
enableOpenAiDot: boolean;
|
||||
/** Enable experimental Grok Bot agents; requires MCP and the managed/native Runner. */
|
||||
enableGrokBot: boolean;
|
||||
enableMuse: boolean;
|
||||
/** @deprecated Compatibility key only. MCP aggregators are always enabled. */
|
||||
enableMcpAggregators: boolean;
|
||||
/** Show experimental memory connection setup. Existing connections remain usable. */
|
||||
|
||||
@@ -54,6 +54,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
enablePublicMcp: z.boolean().default(false),
|
||||
enableOpenAiDot: z.boolean().default(false),
|
||||
enableGrokBot: z.boolean().default(false),
|
||||
enableMuse: z.boolean().default(false),
|
||||
enableChatConnectors: z.boolean().default(false),
|
||||
enableGitHubReviewBots: z.boolean().default(false),
|
||||
// Compatibility only: old stored and managed values must still parse.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Secret-free interpretation of a bounded personal-Muse probe, not a Product E2E campaign. */
|
||||
export const MUSE_QUALIFICATION_GRADER = "paperclip.muse-qualification.v4";
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
const timestamp = value => typeof value === "string" ? Date.parse(value) : NaN;
|
||||
const nonnegative = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const sha = value => typeof value === "string" && /^[a-f0-9]{40,64}$/.test(value);
|
||||
const duration = (from, to) => {
|
||||
const value = timestamp(to) - timestamp(from);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
};
|
||||
const distribution = values => {
|
||||
const sorted = values.filter(value => value !== null).sort((a, b) => a - b);
|
||||
const percentile = fraction => sorted.length ? sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] : null;
|
||||
return { count: sorted.length, p50Ms: percentile(0.5), p95Ms: percentile(0.95), maxMs: sorted.at(-1) ?? null };
|
||||
};
|
||||
|
||||
/** Explicitly project safe facts; never spread retained evidence or print task/account/session identifiers. */
|
||||
export function qualificationReport(evidence, now = Date.now()) {
|
||||
const run = evidence.qualification ?? {};
|
||||
const provenance = evidence.provenance ?? {};
|
||||
const start = timestamp(run.startedAt), expiry = timestamp(run.expiresAt);
|
||||
const bounded = Number.isFinite(start) && expiry - start === DAY && run.requestedPollIntervalMs === 5000;
|
||||
const samples = Array.isArray(evidence.samples) ? evidence.samples : [];
|
||||
const uniqueSlots = new Set(samples.map(sample => sample.slot)).size === samples.length;
|
||||
const ordered = sample => {
|
||||
const times = [sample.queuedAt, sample.offeredAt, sample.claimedAt, sample.nativeAcceptedAt, sample.acceptedResultAt, sample.finalizedAt].map(timestamp);
|
||||
return times.every((value, index) => Number.isFinite(value) && value >= start && value <= expiry && (index === 0 || value >= times[index - 1]));
|
||||
};
|
||||
const successful = samples.filter(sample => ordered(sample) && sample.unattended === true && sample.attempts === 1
|
||||
&& sample.nativeSucceeded === true && sha(sample.documentSha256));
|
||||
const cadence = evidence.cadence ?? {};
|
||||
const intervalsValid = nonnegative(cadence.observedIntervals) && cadence.observedIntervals > 0
|
||||
&& nonnegative(cadence.withinSevenSeconds) && cadence.withinSevenSeconds <= cadence.observedIntervals;
|
||||
const fraction = intervalsValid ? cadence.withinSevenSeconds / cadence.observedIntervals : null;
|
||||
const cadenceComplete = bounded && cadence.complete === true && cadence.missingIntervals === 0
|
||||
&& nonnegative(cadence.maxUnobservedGapMs) && cadence.maxUnobservedGapMs <= 60_000
|
||||
&& timestamp(cadence.from) <= start + 60_000 && timestamp(cadence.to) >= expiry - 60_000
|
||||
&& intervalsValid && cadence.observedIntervals >= Math.floor(DAY / 7000);
|
||||
const windows = Array.isArray(evidence.idleWindows) ? evidence.idleWindows : [];
|
||||
const idleVerified = windows.some(window => duration(window.from, window.to) >= 2 * HOUR
|
||||
&& timestamp(window.from) >= start && timestamp(window.to) <= expiry
|
||||
&& window.coverageComplete === true && window.healthy === true
|
||||
&& window.activeAssignments === 0 && window.unconsumedInputs === 0
|
||||
&& !samples.some(sample => timestamp(sample.queuedAt) < timestamp(window.to)
|
||||
&& (!Number.isFinite(timestamp(sample.finalizedAt)) || timestamp(sample.finalizedAt) > timestamp(window.from))));
|
||||
const cleanup = evidence.cleanup ?? {};
|
||||
const authorityRevoked = cleanup.authorityRevoked === true;
|
||||
const detectorRemovalRequested = cleanup.detectorRemovalRequested === true;
|
||||
const detectorRemovalReported = cleanup.detectorRemoved === true;
|
||||
const detectorEvidenceSource = ["authenticated_worker_report", "operator_runtime_attestation"].includes(cleanup.detectorEvidenceSource) ? cleanup.detectorEvidenceSource : null;
|
||||
const detectorEvidenceRecordedAt = timestamp(cleanup.detectorEvidenceRecordedAt), detectorObservationAt = timestamp(cleanup.detectorObservationAt);
|
||||
const detectorEvidenceTimely = detectorEvidenceRecordedAt >= expiry && detectorEvidenceRecordedAt <= now && detectorObservationAt >= expiry && detectorObservationAt <= detectorEvidenceRecordedAt;
|
||||
const stopped = detectorEvidenceSource !== null && detectorEvidenceTimely && authorityRevoked && detectorRemovalRequested && detectorRemovalReported
|
||||
&& timestamp(run.deadlineEnforcedAt) >= expiry && timestamp(run.deadlineEnforcedAt) <= expiry + 60_000;
|
||||
const noUncertainty = cleanup.nativeEffectsUnknown === false && cleanup.workerStopUnconfirmed === false;
|
||||
const sourceKnown = sha(provenance.coreRevision) && sha(provenance.runnerRevision)
|
||||
&& provenance.protocolVersion === 1 && provenance.profile === "muse-personal"
|
||||
&& ["local", "cloud", "self-hosted"].includes(provenance.environment);
|
||||
const live = provenance.mode === "live";
|
||||
const reasons = [];
|
||||
if (!bounded) reasons.push("invalid_duration_or_requested_cadence");
|
||||
if (!sourceKnown) reasons.push("missing_source_provenance");
|
||||
if (!live) reasons.push("not_live_personal_muse");
|
||||
if (!bounded || now < expiry) reasons.push("twenty_four_hours_not_elapsed");
|
||||
if (!stopped) reasons.push("deadline_cleanup_not_verified");
|
||||
if (!uniqueSlots || samples.some(sample => !Number.isInteger(sample.slot) || sample.slot < 0 || sample.slot >= 24)) reasons.push("invalid_or_duplicate_slots");
|
||||
if (successful.length < 20 || successful.length !== samples.length) reasons.push("insufficient_unattended_native_document_samples");
|
||||
if (!cadenceComplete) reasons.push("incomplete_receiver_cadence_evidence");
|
||||
if (fraction === null || fraction < 0.9) reasons.push("five_second_cadence_not_qualified");
|
||||
if (!idleVerified) reasons.push("sustained_idle_not_verified");
|
||||
if (!noUncertainty) reasons.push("unresolved_remote_work");
|
||||
if ((evidence.interventions ?? []).length) reasons.push("intervened_samples");
|
||||
const workflowNames = ["researchDocument", "clarification", "followUp", "freshConversationProactive"];
|
||||
const workflows = Object.fromEntries(workflowNames.map(name => [name, evidence.workflows?.[name]?.verified === true]));
|
||||
const passed = reasons.length === 0;
|
||||
return {
|
||||
schema: MUSE_QUALIFICATION_GRADER,
|
||||
provenance: { coreRevision: sha(provenance.coreRevision) ? provenance.coreRevision : null,
|
||||
runnerRevision: sha(provenance.runnerRevision) ? provenance.runnerRevision : null,
|
||||
protocolVersion: provenance.protocolVersion === 1 ? 1 : null, mode: live ? "live" : "synthetic_or_unspecified",
|
||||
environment: ["local", "cloud", "self-hosted"].includes(provenance.environment) ? provenance.environment : null },
|
||||
startedAt: Number.isFinite(start) ? new Date(start).toISOString() : null,
|
||||
expiresAt: Number.isFinite(expiry) ? new Date(expiry).toISOString() : null,
|
||||
elapsedHours: Number.isFinite(start) ? Math.max(0, now - start) / HOUR : null,
|
||||
samples: samples.map(sample => ({ slot: Number.isInteger(sample.slot) ? sample.slot : null,
|
||||
queueToOfferMs: duration(sample.queuedAt, sample.offeredAt), queueToClaimMs: duration(sample.queuedAt, sample.claimedAt),
|
||||
queueToNativeAcceptMs: duration(sample.queuedAt, sample.nativeAcceptedAt),
|
||||
queueToAcceptedResultMs: duration(sample.queuedAt, sample.acceptedResultAt), fullResponseMs: duration(sample.queuedAt, sample.finalizedAt),
|
||||
nativeSucceeded: sample.nativeSucceeded === true, unattended: sample.unattended === true,
|
||||
attempts: nonnegative(sample.attempts) ? sample.attempts : null, documentSha256: sha(sample.documentSha256) ? sample.documentSha256 : null })),
|
||||
successfulNativeTasks: successful.length,
|
||||
queueToClaim: distribution(samples.map(sample => duration(sample.queuedAt, sample.claimedAt))),
|
||||
fullResponse: distribution(samples.map(sample => duration(sample.queuedAt, sample.finalizedAt))),
|
||||
receiverIntervals: intervalsValid ? cadence.observedIntervals : null, fractionWithinSevenSeconds: fraction,
|
||||
requestedPollIntervalMs: 5000, cadenceEvidenceComplete: cadenceComplete, sustainedIdleVerified: idleVerified,
|
||||
authorityRevoked, detectorRemovalRequested, detectorRemovalReported, detectorEvidenceSource,
|
||||
deadlineCleanupVerified: stopped, unresolvedRemoteWork: !noUncertainty,
|
||||
usage: null, cost: null, usageAccounting: "unavailable", workflows,
|
||||
qualificationPassed: passed, releaseQualified: passed && Object.values(workflows).every(Boolean), reasons,
|
||||
evidenceBoundary: `Authenticated unattended round trips and native receipts; detector removal is ${detectorEvidenceSource === "operator_runtime_attestation" ? "operator-attested from supported-runtime observation" : detectorEvidenceSource === "authenticated_worker_report" ? "authenticated worker-reported" : "unconfirmed"}, not independently confirmed by the provider. Private Muse tool activity is not independently observed.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge persisted replica buckets before measuring gaps; bucket totals alone hide cross-replica gaps. */
|
||||
export function qualificationCadence(evidence) {
|
||||
const start = timestamp(evidence.startedAt), expiry = timestamp(evidence.expiresAt);
|
||||
const contacts = Array.isArray(evidence.contacts) ? evidence.contacts : [];
|
||||
const times = [...new Set(contacts.flatMap(bucket => bucket.timestamps ?? []).map(timestamp)
|
||||
.filter(at => Number.isFinite(at) && at >= start && at <= expiry))].sort((a, b) => a - b);
|
||||
const gaps = times.slice(1).map((at, index) => at - times[index]);
|
||||
const missing = contacts.filter(bucket => bucket.incomplete || bucket.contacts !== bucket.timestamps?.length).length;
|
||||
const boundaryGap = times.length ? Math.max(0, times[0] - start, expiry - times.at(-1)) : Infinity;
|
||||
const largestGap = gaps.reduce((maximum, gap) => Math.max(maximum, gap), boundaryGap);
|
||||
return { from: times.length ? new Date(times[0]).toISOString() : null,
|
||||
to: times.length ? new Date(times.at(-1)).toISOString() : null,
|
||||
observedIntervals: gaps.length, withinSevenSeconds: gaps.filter(gap => gap <= 7000).length,
|
||||
missingIntervals: missing + (evidence.cadenceEvidenceComplete === true ? 0 : 1),
|
||||
maxUnobservedGapMs: Number.isFinite(largestGap) ? largestGap : null,
|
||||
complete: evidence.cadenceEvidenceComplete === true && missing === 0 && times.length > 1 };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { qualificationReport, qualificationCadence } from "./muse-qualification-report.mjs";
|
||||
const HOUR = 3_600_000, start = Date.UTC(2099, 0, 1), expiry = start + 24 * HOUR;
|
||||
const iso = ms => new Date(ms).toISOString();
|
||||
function evidence() {
|
||||
const samples = Array.from({ length: 24 }, (_, slot) => slot).filter(slot => slot < 6 || slot >= 9).map(slot => {
|
||||
const queued = start + slot * HOUR;
|
||||
return { slot, queuedAt: iso(queued), offeredAt: iso(queued + 100), claimedAt: iso(queued + 2000),
|
||||
nativeAcceptedAt: iso(queued + 2100), acceptedResultAt: iso(queued + 9000), finalizedAt: iso(queued + 10000),
|
||||
nativeSucceeded: true, unattended: true, attempts: 1, documentSha256: "b".repeat(64) };
|
||||
});
|
||||
return { qualification: { startedAt: iso(start), expiresAt: iso(expiry), deadlineEnforcedAt: iso(expiry + 1000), requestedPollIntervalMs: 5000 },
|
||||
provenance: { coreRevision: "a".repeat(40), runnerRevision: "b".repeat(40), protocolVersion: 1, mode: "live", profile: "muse-personal", environment: "local" },
|
||||
samples, cadence: { from: iso(start), to: iso(expiry), observedIntervals: 17279, withinSevenSeconds: 17000, missingIntervals: 0, maxUnobservedGapMs: 0, complete: true },
|
||||
idleWindows: [{ from: iso(start + 6 * HOUR), to: iso(start + 9 * HOUR), coverageComplete: true, healthy: true, activeAssignments: 0, unconsumedInputs: 0 }],
|
||||
cleanup: { authorityRevoked: true, detectorRemovalRequested: true, detectorRemoved: true, detectorEvidenceSource: "authenticated_worker_report", detectorEvidenceRecordedAt: iso(expiry + 1000), detectorObservationAt: iso(expiry + 1000), workerStopUnconfirmed: false, nativeEffectsUnknown: false }, interventions: [],
|
||||
workflows: Object.fromEntries(["researchDocument", "clarification", "followUp", "freshConversationProactive"].map(key => [key, { verified: true }])) };
|
||||
}
|
||||
test("complete live evidence reports claim and full-response latency separately", () => {
|
||||
const result = qualificationReport(evidence(), expiry + 2000);
|
||||
assert.equal(result.qualificationPassed, true); assert.equal(result.releaseQualified, true);
|
||||
assert.equal(result.successfulNativeTasks, 21); assert.equal(result.queueToClaim.p95Ms, 2000);
|
||||
assert.equal(result.fullResponse.p95Ms, 10000); assert.equal(result.cost, null);
|
||||
});
|
||||
test("synthetic evidence and old sixty-second cadence cannot qualify", () => {
|
||||
for (const change of [e => e.provenance.mode = "synthetic", e => e.qualification.requestedPollIntervalMs = 60000,
|
||||
e => { e.cadence.observedIntervals = 1440; e.cadence.withinSevenSeconds = 0; }]) {
|
||||
const e = evidence(); change(e); assert.equal(qualificationReport(e, expiry + 2000).qualificationPassed, false);
|
||||
}
|
||||
});
|
||||
test("missing cadence tails, idle gaps, claims, native completion and cleanup each fail closed", () => {
|
||||
const changes = [e => e.cadence.complete = false, e => e.cadence.missingIntervals = 1,
|
||||
e => e.cadence.to = iso(expiry - HOUR), e => e.cadence.maxUnobservedGapMs = 61000,
|
||||
e => e.idleWindows[0].coverageComplete = false, e => e.idleWindows[0].activeAssignments = 1,
|
||||
e => delete e.samples[0].claimedAt, e => e.samples[0].nativeSucceeded = false,
|
||||
e => e.samples[0].unattended = false, e => e.samples[0].attempts = 2,
|
||||
e => e.samples[0].slot = e.samples[1].slot, e => e.samples[0].acceptedResultAt = iso(expiry + 1),
|
||||
e => e.samples[0].finalizedAt = iso(start + 7 * HOUR), e => e.cleanup.workerStopUnconfirmed = true,
|
||||
e => e.cleanup.nativeEffectsUnknown = true, e => e.qualification.deadlineEnforcedAt = iso(expiry + HOUR),
|
||||
e => e.interventions.push({ kind: "manual_chat" }), e => e.provenance.runnerRevision = "unknown"];
|
||||
for (const change of changes) { const e = evidence(); change(e); assert.equal(qualificationReport(e, expiry + 2000).qualificationPassed, false, String(change)); }
|
||||
assert.equal(qualificationReport(evidence(), start + HOUR).qualificationPassed, false);
|
||||
});
|
||||
test("successful soak alone does not imply product journey qualification", () => {
|
||||
const e = evidence(); e.workflows.clarification.verified = false;
|
||||
const result = qualificationReport(e, expiry + 2000);
|
||||
assert.equal(result.qualificationPassed, true); assert.equal(result.releaseQualified, false);
|
||||
});
|
||||
test("public projection does not copy credentials, private text or identity fields", () => {
|
||||
const e = evidence(); e.accessToken = "private-secret"; e.samples[0].issueId = "private-task";
|
||||
e.provenance.privateConversation = "private-conversation";
|
||||
const output = JSON.stringify(qualificationReport(e, expiry + 2000));
|
||||
for (const secret of ["private-secret", "private-task", "private-conversation"]) assert.equal(output.includes(secret), false);
|
||||
});
|
||||
|
||||
test("cadence merges replica timestamps and exposes missing persisted contacts", () => {
|
||||
const input = { startedAt: iso(start), expiresAt: iso(start + 20000), cadenceEvidenceComplete: true,
|
||||
contacts: [
|
||||
{ contacts: 3, timestamps: [iso(start), iso(start + 10000), iso(start + 20000)], incomplete: false },
|
||||
{ contacts: 2, timestamps: [iso(start + 5000), iso(start + 15000)], incomplete: false },
|
||||
] };
|
||||
assert.deepEqual(qualificationCadence(input), { from: iso(start), to: iso(start + 20000), observedIntervals: 4,
|
||||
withinSevenSeconds: 4, missingIntervals: 0, maxUnobservedGapMs: 5000, complete: true });
|
||||
input.contacts[1].contacts = 3;
|
||||
assert.equal(qualificationCadence(input).complete, false);
|
||||
assert.equal(qualificationCadence(input).missingIntervals, 1);
|
||||
input.contacts[1].contacts = 2; input.cadenceEvidenceComplete = false;
|
||||
assert.equal(qualificationCadence(input).complete, false);
|
||||
});
|
||||
|
||||
|
||||
test("a receiver outage fails qualification despite otherwise regular five-second polling", () => {
|
||||
const e = evidence();
|
||||
const outageStart = start + 12 * HOUR;
|
||||
const timestamps = Array.from({ length: 24 * HOUR / 5000 + 1 }, (_, index) => start + index * 5000)
|
||||
.filter(at => at <= outageStart || at >= outageStart + 10 * 60_000).map(iso);
|
||||
e.cadence = qualificationCadence({ startedAt: iso(start), expiresAt: iso(expiry), cadenceEvidenceComplete: true,
|
||||
contacts: [{ contacts: timestamps.length, timestamps, incomplete: false }] });
|
||||
assert.equal(e.cadence.maxUnobservedGapMs, 10 * 60_000);
|
||||
assert.ok(e.cadence.withinSevenSeconds / e.cadence.observedIntervals > 0.99);
|
||||
assert.ok(e.cadence.observedIntervals >= Math.floor(24 * HOUR / 7000));
|
||||
const report = qualificationReport(e, expiry + 2000);
|
||||
assert.equal(report.qualificationPassed, false);
|
||||
assert.equal(report.releaseQualified, false);
|
||||
assert.ok(report.reasons.includes("incomplete_receiver_cadence_evidence"));
|
||||
});
|
||||
|
||||
test("deadline cleanup remains incomplete until detector removal is requested and reported", () => {
|
||||
for (const flags of [{}, { detectorRemovalRequested: false, detectorRemoved: false },
|
||||
{ detectorRemovalRequested: true, detectorRemoved: false },
|
||||
{ detectorRemovalRequested: false, detectorRemoved: true },
|
||||
{ detectorRemovalRequested: "true", detectorRemoved: true },
|
||||
{ detectorRemovalRequested: true, detectorRemoved: 1 }]) {
|
||||
const e = evidence();
|
||||
delete e.cleanup.detectorRemovalRequested; delete e.cleanup.detectorRemoved;
|
||||
Object.assign(e.cleanup, flags);
|
||||
const report = qualificationReport(e, expiry + 2000);
|
||||
assert.equal(report.qualificationPassed, false);
|
||||
assert.equal(report.releaseQualified, false); assert.equal(report.deadlineCleanupVerified, false);
|
||||
assert.equal(report.authorityRevoked, true);
|
||||
assert.equal(report.detectorRemovalRequested, flags.detectorRemovalRequested === true);
|
||||
assert.equal(report.detectorRemovalReported, flags.detectorRemoved === true);
|
||||
assert.ok(report.reasons.includes("deadline_cleanup_not_verified"));
|
||||
}
|
||||
const complete = qualificationReport(evidence(), expiry + 2000);
|
||||
assert.equal(complete.schema, "paperclip.muse-qualification.v4");
|
||||
assert.equal(complete.qualificationPassed, true); assert.equal(complete.releaseQualified, true);
|
||||
assert.equal(complete.deadlineCleanupVerified, true);
|
||||
assert.equal(complete.detectorRemovalRequested, true); assert.equal(complete.detectorRemovalReported, true);
|
||||
assert.match(complete.evidenceBoundary, /worker-reported, not independently confirmed by the provider/);
|
||||
});
|
||||
|
||||
|
||||
test("deadline cleanup requires trusted timely provenance and distinguishes operator observation", () => {
|
||||
for (const source of [undefined, "disabled", "public_activity", "operator_summary"]) {
|
||||
const e = evidence(); e.cleanup.detectorEvidenceSource = source;
|
||||
assert.equal(qualificationReport(e, expiry + 2000).qualificationPassed, false);
|
||||
}
|
||||
for (const field of ["detectorEvidenceRecordedAt", "detectorObservationAt"]) {
|
||||
const e = evidence(); e.cleanup[field] = iso(expiry - 1);
|
||||
assert.equal(qualificationReport(e, expiry + 2000).deadlineCleanupVerified, false);
|
||||
}
|
||||
const e = evidence(); e.cleanup.detectorEvidenceSource = "operator_runtime_attestation";
|
||||
const report = qualificationReport(e, expiry + 2000);
|
||||
assert.equal(report.qualificationPassed, true); assert.equal(report.detectorEvidenceSource, "operator_runtime_attestation");
|
||||
assert.match(report.evidenceBoundary, /operator-attested from supported-runtime observation/);
|
||||
assert.doesNotMatch(report.evidenceBoundary, /removal is authenticated worker-reported/);
|
||||
e.cleanup.nativeEffectsUnknown = true;
|
||||
assert.equal(qualificationReport(e, expiry + 2000).qualificationPassed, false);
|
||||
});
|
||||
@@ -60,6 +60,7 @@ describe("instance settings service", () => {
|
||||
enableAgentChat: false,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableChatConnectors: false,
|
||||
|
||||
@@ -311,6 +311,10 @@ describe("redaction", () => {
|
||||
providerValue: input.nested.providerValue,
|
||||
},
|
||||
});
|
||||
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v7", runtimeSchema: "paperclip.native-execution-input.v8" })).toEqual({
|
||||
schema: "paperclip.native-execution-input.v7",
|
||||
runtimeSchema: "paperclip.native-execution-input.v8",
|
||||
});
|
||||
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v4", runtimeSchema: "paperclip.native-model-envelope.v2" })).toEqual({
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
runtimeSchema: "paperclip.native-model-envelope.v2",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, heartbeatRuns, dotRunnerAssignments, externalAgentHolds, museRunnerAssignments } from "@paperclipai/db";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { conflict } from "../../errors.js";
|
||||
export type ExternalAdmissionTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
/** Acquire only after the queue's issue/wake/run locks. Generation/stop operations acquire this guard before binding locks. */
|
||||
export async function withExternalAdmissionGuard<T>(tx: ExternalAdmissionTransaction, companyId: string, agentId: string, action: () => Promise<T>): Promise<T> {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:external-admission:${companyId}:${agentId}`},0))`);
|
||||
return action();
|
||||
}
|
||||
export async function assertNoExternalOverlap(tx: ExternalAdmissionTransaction, companyId: string, agentId: string): Promise<void> {
|
||||
const [hold] = await tx.select({ id: externalAgentHolds.id }).from(externalAgentHolds).where(and(eq(externalAgentHolds.companyId,companyId),eq(externalAgentHolds.agentId,agentId),isNull(externalAgentHolds.releasedAt),or(eq(externalAgentHolds.workerUnknown,true),eq(externalAgentHolds.nativeEffectsUnknown,true)))).limit(1);
|
||||
const [muse] = await tx.select({ id:museRunnerAssignments.id }).from(museRunnerAssignments).where(and(eq(museRunnerAssignments.companyId,companyId),eq(museRunnerAssignments.agentId,agentId),inArray(museRunnerAssignments.status,["offered","claimed","accepted"]))).limit(1);
|
||||
const [dot] = await tx.select({ id:dotRunnerAssignments.id }).from(dotRunnerAssignments).where(and(eq(dotRunnerAssignments.companyId,companyId),eq(dotRunnerAssignments.agentId,agentId),inArray(dotRunnerAssignments.status,["offered","accepted"]))).limit(1);
|
||||
const [claimed] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId,companyId),eq(heartbeatRuns.agentId,agentId),eq(heartbeatRuns.status,"running"),or(inArray(heartbeatRuns.driverKind,["openai_dot_mcp","grok_bot_mcp","muse_external"]),sql`exists (select 1 from ${agents} where ${agents.id} = ${agentId}::uuid and ${agents.companyId} = ${companyId}::uuid and ${agents.adapterConfig}->>'provider' in ('openai_dot','grok_bot','muse'))`))).limit(1);
|
||||
if (hold || muse || dot || claimed) throw conflict("External work is live or unresolved for this agent. Reconcile the exact assignment before starting another run.", { code: "external_agent_overlap" });
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
||||
"paperclip.native-execution-input.v4",
|
||||
"paperclip.native-execution-input.v5",
|
||||
"paperclip.native-execution-input.v7",
|
||||
"paperclip.native-execution-input.v8",
|
||||
"paperclip.native-finalization.v1",
|
||||
"paperclip.native-finalization.v2",
|
||||
"paperclip.native-harness-backup-stamp.v1",
|
||||
|
||||
@@ -130,6 +130,34 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary",
|
||||
expect(result.requestedReusableExecutionWorkspaceConfig).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("keeps %s managed placement without adopting the task's selected workspace", async provider => {
|
||||
const { input, issue, workspace } = await fixture();
|
||||
const source = await workspace();
|
||||
const [sandbox] = await db.insert(environments).values({ name: `External controller ${provider}`, driver: "sandbox", status: "active", config: { provider: "fake" } }).returning();
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
input.agent.adapterConfig = { provider };
|
||||
input.agent.defaultEnvironmentId = sandbox.id;
|
||||
input.task.issueRef!.executionWorkspaceId = source.id;
|
||||
input.task.issueRef!.projectWorkspaceId = randomUUID();
|
||||
input.task.boundSourceWorkspace = source;
|
||||
input.task.workspaceProjectContext = { hasWorkspace: true };
|
||||
input.task.hasSelectedWorkspaceSource = true;
|
||||
input.task.taskWorkspaceIntent = { version: 1, source: "explicit", selection: { kind: "task_directory" } };
|
||||
await db.update(issues).set({ originKind: "chat_channel" }).where(eq(issues.id, issue.id));
|
||||
const read = vi.spyOn(input.services.executionWorkspacesSvc, "getStoredById").mockRejectedValue(new Error("Task source must remain inaccessible"));
|
||||
const select = vi.spyOn(input.services.executionWorkspacesSvc, "validateSelection").mockRejectedValue(new Error("Task selection must remain inaccessible"));
|
||||
try {
|
||||
const result = await selectHeartbeatEnvironment(db, input);
|
||||
expect(result).toMatchObject({ isDotRun: true, selectedEnvironmentId: sandbox.id,
|
||||
existingExecutionWorkspace: null, reusableExistingExecutionWorkspace: null,
|
||||
nativeChatWorkspaceScope: null, useIsolatedTaskDirectory: false, explicitTaskDirectory: false });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(input.services.findSharedWorkspaceHolder).not.toHaveBeenCalled();
|
||||
expect(input.task.issueRef!.executionWorkspaceId).toBe(source.id);
|
||||
} finally { read.mockRestore(); select.mockRestore(); }
|
||||
});
|
||||
|
||||
it("uses the low-trust task sandbox even when workspace feature gates hide issue settings", async () => {
|
||||
const { input, issue } = await fixture();
|
||||
const [sandbox] = await db.insert(environments).values({ name: "Task sandbox", driver: "sandbox", status: "active", config: { provider: "fake" } }).returning();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db";
|
||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings, ProjectExecutionWorkspacePolicy } from "@paperclipai/shared";
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
@@ -97,11 +98,11 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
persistedRunnerProfile.nativeExecutionInput,
|
||||
)
|
||||
: null;
|
||||
const isDotRun = persistedNativeExecutionInput?.provider.kind === "openai_dot"
|
||||
|| (!persistedNativeExecutionInput && agent.adapterType === "paperclip_runner" && parseObject(agent.adapterConfig).provider === "openai_dot");
|
||||
// This retained name covers both external bridges; neither adopts task files.
|
||||
const isDotRun = heartbeatExternalProvider({ run, agent }) !== null;
|
||||
const persistedNativeExecutionWorkspaceId =
|
||||
persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
|
||||
const requestedExecutionWorkspaceId =
|
||||
const requestedExecutionWorkspaceId = isDotRun ? null :
|
||||
persistedNativeExecutionWorkspaceId ??
|
||||
readNonEmptyString(issueRef?.executionWorkspaceId);
|
||||
const existingExecutionWorkspace = requestedExecutionWorkspaceId
|
||||
@@ -115,7 +116,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
const workspaceReuseRequest =
|
||||
resolveExecutionWorkspaceReuseRequestForIssue({
|
||||
issueExecutionWorkspaceId: requestedExecutionWorkspaceId,
|
||||
issueExecutionWorkspacePreference: nativeRecoveryExecutionWorkspaceId
|
||||
issueExecutionWorkspacePreference: isDotRun ? null : nativeRecoveryExecutionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (issueRef?.executionWorkspacePreference ?? null),
|
||||
existingExecutionWorkspaceStatus:
|
||||
@@ -129,7 +130,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: null;
|
||||
const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId,
|
||||
source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
|
||||
if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
if (!isDotRun && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
|
||||
if (workspaceRequiringAccess) {
|
||||
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId });
|
||||
@@ -253,13 +254,13 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: selectedEnvironmentId
|
||||
? await environmentsSvc.getById(selectedEnvironmentId)
|
||||
: null;
|
||||
const nativeChatWorkspaceScope = await findNativeChatWorkspaceScope(db, {
|
||||
const nativeChatWorkspaceScope = isDotRun ? null : await findNativeChatWorkspaceScope(db, {
|
||||
adapterType: agent.adapterType,
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
admittedCwd: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6"
|
||||
admittedCwd: nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput)
|
||||
? persistedNativeExecutionInput.workspace.cwd : null,
|
||||
});
|
||||
let nativeChatExpectedCwd = nativeChatWorkspaceScope
|
||||
@@ -271,8 +272,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: null;
|
||||
if (
|
||||
nativeChatWorkspaceScope &&
|
||||
persistedNativeExecutionInput &&
|
||||
persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6" &&
|
||||
nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput) &&
|
||||
!nativeChatWorkspaceMatches({
|
||||
scope: nativeChatWorkspaceScope,
|
||||
expectedCwd: nativeChatExpectedCwd,
|
||||
@@ -352,7 +352,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
// project or issue settings request serialization. Sandbox protection still uses
|
||||
// the existing holder staleness and workspace_busy retry ladder.
|
||||
if (
|
||||
issueRef?.projectWorkspaceId &&
|
||||
!isDotRun && issueRef?.projectWorkspaceId &&
|
||||
effectiveExecutionWorkspaceMode === "shared_workspace"
|
||||
) {
|
||||
const workspaceHolder = await findSharedWorkspaceHolder({
|
||||
@@ -433,14 +433,14 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
);
|
||||
}
|
||||
}
|
||||
if (taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
if (!isDotRun && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId,
|
||||
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
|
||||
selection: taskWorkspaceIntent.selection, issueId,
|
||||
preserveImplicitDefaultSettings: ["project", "task_default"].includes(taskWorkspaceIntent.source) });
|
||||
}
|
||||
const explicitTaskDirectory = taskWorkspaceIntent?.selection.kind === "task_directory";
|
||||
const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({
|
||||
const explicitTaskDirectory = !isDotRun && taskWorkspaceIntent?.selection.kind === "task_directory";
|
||||
const useIsolatedTaskDirectory = !isDotRun && issueRef !== null && shouldUseIsolatedTaskDirectory({
|
||||
trustPreset: trustPreset.kind,
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNativeExecutionInput } from "../native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
|
||||
import { heartbeatExternalProvider, nativeRuntimeHasInstructionFilesystem, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
|
||||
function execution(provider: "muse" | "openai_dot" | "grok_bot" | "codex") {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
const binding = { companyId, agentId, bindingId: randomUUID(), bindingGeneration: 1,
|
||||
acceptByUnixMs: Date.now() + 60_000, expiresAtUnixMs: Date.now() + 600_000 };
|
||||
return buildNativeExecutionInput({ companyId, agentId, runId: randomUUID(), provider,
|
||||
...(provider === "muse" ? { museBinding: binding } : provider === "openai_dot" || provider === "grok_bot" ? { dotBinding: binding } : {}),
|
||||
issue: { id: randomUUID(), identifier: "EXT-1", title: "Bound task", description: null, workMode: "standard" },
|
||||
taskPrompt: "Complete the task", normalizedSessionId: randomUUID(),
|
||||
workspace: { id: randomUUID(), cwd: "/controller", repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Complete the task", criteria: [{ id: "objective", requirement: "Complete the task" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("shared external heartbeat classification", () => {
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("keeps %s external on fresh admission and after a configured provider change", provider => {
|
||||
const agent = { adapterType: "paperclip_runner", adapterConfig: { provider } };
|
||||
expect(heartbeatExternalProvider({ agent, run: { runtimeMode: "legacy", runnerProfileJson: null } })).toBe(provider);
|
||||
const admitted = execution(provider);
|
||||
expect(admitted.schema).toBe(provider === "muse" ? "paperclip.native-execution-input.v8" : "paperclip.native-execution-input.v6");
|
||||
expect(admitted.provider.kind).toBe(provider);
|
||||
expect(admitted.session.driverKind).toBe(provider === "muse" ? "muse_external" : provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp");
|
||||
expect(heartbeatExternalProvider({ agent: { ...agent, adapterConfig: { provider: "codex" } },
|
||||
run: { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: admitted } } })).toBe(provider);
|
||||
expect(nativeRuntimeHasInstructionFilesystem(admitted.session.driverKind)).toBe(false);
|
||||
expect(nativeExecutionUsesTaskWorkspace(admitted)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not turn an admitted local provider into an external bridge after configuration changes", () => {
|
||||
expect(heartbeatExternalProvider({ agent: { adapterType: "paperclip_runner", adapterConfig: { provider: "muse" } },
|
||||
run: { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: execution("codex") } } })).toBeNull();
|
||||
expect(nativeRuntimeHasInstructionFilesystem("codex_app_server")).toBe(true);
|
||||
expect(nativeExecutionUsesTaskWorkspace(execution("codex"))).toBe(true);
|
||||
expect(heartbeatExternalProvider({ agent: { adapterType: "process", adapterConfig: { provider: "muse" } },
|
||||
run: { runtimeMode: "legacy", runnerProfileJson: null } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { parseNativeExecutionInput, type NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
/** Provider placement is distinct from access to a task's workspace. */
|
||||
export function heartbeatExternalProvider(input: {
|
||||
run: { runtimeMode: string | null; runnerProfileJson: unknown };
|
||||
agent: { adapterType: string; adapterConfig: unknown };
|
||||
}): "openai_dot" | "grok_bot" | "muse" | null {
|
||||
const persisted = input.run.runtimeMode === "native"
|
||||
? parseObject(input.run.runnerProfileJson).nativeExecutionInput : undefined;
|
||||
const provider = persisted !== undefined
|
||||
? parseNativeExecutionInput(persisted).provider.kind
|
||||
: input.agent.adapterType === "paperclip_runner" ? parseObject(input.agent.adapterConfig).provider : null;
|
||||
return provider === "openai_dot" || provider === "grok_bot" || provider === "muse" ? provider : null;
|
||||
}
|
||||
|
||||
export function nativeRuntimeHasInstructionFilesystem(backend: string): boolean {
|
||||
return !["claude_managed_agents_api", "aws_agentcore_harness_api", "openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(backend);
|
||||
}
|
||||
|
||||
type TaskWorkspaceExecution = Exclude<NativeExecutionInput, {
|
||||
schema: "paperclip.native-execution-input.v6" | "paperclip.native-execution-input.v8";
|
||||
}>;
|
||||
|
||||
export function nativeExecutionUsesTaskWorkspace(execution: NativeExecutionInput | null): execution is TaskWorkspaceExecution {
|
||||
return execution !== null && execution.schema !== "paperclip.native-execution-input.v6"
|
||||
&& execution.schema !== "paperclip.native-execution-input.v8";
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { workspaceOperationService } from "../workspace-operations.js";
|
||||
import { nativeWorkspaceSyncInternals, prepareNativeWorkspaceSync } from "../native-runtime/native-workspace-sync.js";
|
||||
import { createHeartbeatWorkspaceResolver, prepareProjectRepositoryWorkspaces, resolveExecutionWorkspaceReuseRequestForIssue } from "./workspaces.js";
|
||||
import { prepareHeartbeatWorkspace, type HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
|
||||
import { buildNativeExecutionInput } from "../native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
|
||||
import * as managedRepositoryCheckout from "../managed-repository-checkout.js";
|
||||
import { isolatedRemoteSourcePath } from "../isolated-remote-workspace-source.js";
|
||||
import { cleanupExecutionWorkspaceArtifacts } from "../workspace-runtime.js";
|
||||
@@ -105,6 +107,47 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
input.run = run;
|
||||
}
|
||||
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("uses private %s controller storage without rebinding task files or preparing repositories", async provider => {
|
||||
const { input, issue } = await fixture();
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
input.config.mergedConfig = { provider, cwd: "/task/source/must-not-be-read", workspaceStrategy: { type: "git_worktree" } };
|
||||
input.reuse.isDotRun = true;
|
||||
const resolve = vi.spyOn(input.services, "resolveWorkspaceForRun").mockRejectedValue(new Error("Task files must not be adopted"));
|
||||
const bind = vi.spyOn(input.services.executionWorkspacesSvc, "bindTaskWorkspace").mockRejectedValue(new Error("Task binding must not change"));
|
||||
const repos = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission").mockRejectedValue(new Error("Repositories must not be prepared"));
|
||||
try {
|
||||
const result = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(result.executionWorkspace.cwd).toContain(provider === "muse" ? "muse-controllers" : "dot-controllers");
|
||||
expect(result.persistedExecutionWorkspace).toBeNull();
|
||||
expect(result.projectRepositoryPaths).toEqual([]);
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(bind).not.toHaveBeenCalled();
|
||||
expect(repos).not.toHaveBeenCalled();
|
||||
const binding = { companyId: input.agent.companyId, agentId: input.agent.id, bindingId: input.run.id,
|
||||
bindingGeneration: 1, acceptByUnixMs: Date.now() + 60_000, expiresAtUnixMs: Date.now() + 600_000 };
|
||||
const admitted = buildNativeExecutionInput({ companyId: input.agent.companyId, agentId: input.agent.id,
|
||||
runId: input.run.id, provider, ...(provider === "muse" ? { museBinding: binding } : { dotBinding: binding }),
|
||||
issue: { id: issue.id, identifier: null, title: issue.title, description: null, workMode: "standard" },
|
||||
taskPrompt: "Preserve the admitted controller", normalizedSessionId: input.run.id,
|
||||
workspace: { id: input.run.id, cwd: result.executionWorkspace.cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: input.run.id, sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Preserve the controller", criteria: [{ id: "objective", requirement: "Preserve the original checkpoint path" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
input.reuse.persistedNativeExecutionInput = admitted;
|
||||
input.run.runtimeMode = "native";
|
||||
input.run.runnerProfileJson = { nativeExecutionInput: admitted };
|
||||
input.config.mergedConfig = { provider: "codex" };
|
||||
const recovered = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(recovered.executionWorkspace.cwd).toBe(result.executionWorkspace.cwd);
|
||||
expect(recovered.persistedExecutionWorkspace).toBeNull();
|
||||
expect(bind).not.toHaveBeenCalled();
|
||||
expect(repos).not.toHaveBeenCalled();
|
||||
|
||||
} finally { resolve.mockRestore(); bind.mockRestore(); repos.mockRestore(); }
|
||||
});
|
||||
|
||||
it("persists the workspace and issue binding before acquiring its environment", async () => {
|
||||
const { input, source, issue, envOrchestrator } = await fixture();
|
||||
const acquire = envOrchestrator.acquireForRun;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
import { createHeartbeatPreparationTiming } from "./preparation-timing.js";
|
||||
import {
|
||||
resolveWorkspaceAfterLowTrustPreflight,
|
||||
@@ -247,8 +248,8 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
resolveWorkspace: async () => {
|
||||
if (isDotRun) {
|
||||
// This is private controller storage, never a provider filesystem.
|
||||
// v6 projects workspace.access=none and cwd=null to Dot.
|
||||
const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "dot-controllers", agent.companyId, run.id);
|
||||
// External providers receive workspace.access=none and cwd=null.
|
||||
const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", (persistedNativeExecutionInput?.provider.kind ?? mergedConfig.provider) === "muse" ? "muse-controllers" : "dot-controllers", agent.companyId, run.id);
|
||||
await fs.mkdir(cwd, { recursive: true, mode: 0o700 });
|
||||
return { cwd, source: "agent_home" as const, projectId: null, workspaceId: null, repoUrl: null, repoRef: null,
|
||||
workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [], additionalWorkspaces: [], referencedProjectFailures: [] };
|
||||
@@ -682,7 +683,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
const resolvedProjectWorkspaceId =
|
||||
resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
|
||||
? reusableExistingExecutionWorkspace.projectWorkspaceId
|
||||
: issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
: isDotRun ? null : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
let persistedExecutionWorkspace: ExecutionWorkspace | null = null;
|
||||
let issueExecutionWorkspaceIdForRun =
|
||||
issueRef?.executionWorkspaceId ?? null;
|
||||
@@ -703,7 +704,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
const bindIssueToPersistedExecutionWorkspace = async (
|
||||
workspace: ExecutionWorkspace | null,
|
||||
) => {
|
||||
if (!issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
|
||||
if (isDotRun || !issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
const nextIssueWorkspaceMode =
|
||||
@@ -968,7 +969,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
}
|
||||
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
|
||||
const projectRepositoryPaths: string[] = [];
|
||||
if (persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
if (!isDotRun && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const taskRepositories = await timeWorkspace("workspace_repository_admission", () => executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
|
||||
issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd,
|
||||
agentId: agent.id, runId: run.id, responsibleUserId }));
|
||||
@@ -1178,7 +1179,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
}
|
||||
const cwd = reference
|
||||
? await readNativeWorkspaceSyncLocalCwd({ companyId: agent.companyId, runId: run.id, workspaceId, reference })
|
||||
: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6"
|
||||
: nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput)
|
||||
&& executionTarget?.kind !== "remote"
|
||||
? persistedNativeExecutionInput.workspace.cwd
|
||||
: executionWorkspace.cwd;
|
||||
|
||||
@@ -235,6 +235,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
|
||||
enableOpenAiDot: parsed.data.enableOpenAiDot ?? false,
|
||||
enableMuse: parsed.data.enableMuse ?? false,
|
||||
enableGrokBot: parsed.data.enableGrokBot ?? false,
|
||||
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
|
||||
enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false,
|
||||
@@ -285,6 +286,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
NativeCodexApprovalPolicy,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV8,
|
||||
MuseBindingSnapshot,
|
||||
DotBindingSnapshot,
|
||||
NativeExecutionInputV7,
|
||||
NativeCompletionSource,
|
||||
@@ -72,7 +74,8 @@ export interface BuildNativeExecutionInput {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot";
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot" | "muse";
|
||||
museBinding?: MuseBindingSnapshot;
|
||||
dotBinding?: DotBindingSnapshot;
|
||||
acpxAgent?: NativeAcpxAgent;
|
||||
homeMode?: "isolated" | "preconfigured";
|
||||
@@ -110,10 +113,11 @@ export interface BuildNativeExecutionInput {
|
||||
};
|
||||
runtimeContext: NativeRuntimeContextSnapshot;
|
||||
}
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "muse"; museBinding: MuseBindingSnapshot }): NativeExecutionInputV8;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 {
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 {
|
||||
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
|
||||
}
|
||||
@@ -333,11 +337,15 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
credentialBindings: [],
|
||||
runtimeContext: input.runtimeContext,
|
||||
};
|
||||
return parseNativeExecutionInput(["openai_dot", "grok_bot"].includes(input.provider ?? "") ? { ...prepared,
|
||||
return parseNativeExecutionInput(input.provider === "muse" ? { ...prepared,
|
||||
schema: "paperclip.native-execution-input.v8", provider: { kind: "muse", model: null, binding: input.museBinding, bridgeRevision: "muse-v1" },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: "muse_external", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : ["openai_dot", "grok_bot"].includes(input.provider ?? "") ? { ...prepared,
|
||||
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, createDb, environmentLeases, environments, externalAgentHolds, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { getExecutionBlocker } from "../execution-blocker.js";
|
||||
import { markExecutionReconciliation, validateExecutionReconciliation } from "../execution-recovery-resolution.js";
|
||||
@@ -76,23 +76,27 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
eq(activityLog.action, "issue.workspace_recovery_required")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
async function bindExternalInput(f: Awaited<ReturnType<typeof seed>>, provider: "grok_bot" | "openai_dot", change?: (input: { schema: string; binding: { runId: string; companyId: string; agentId: string }; workspace: { access: string } }) => void) {
|
||||
async function bindExternalInput(f: Awaited<ReturnType<typeof seed>>, provider: "grok_bot" | "openai_dot" | "muse", change?: (input: { schema: string; binding: { runId: string; companyId: string; agentId: string; issueId: string }; provider: { kind: string; bridgeRevision?: string }; workspace: { access: string; cwd: string | null } }) => void) {
|
||||
const run = await readRun(f.runId);
|
||||
const input = { schema: "paperclip.native-execution-input.v6",
|
||||
const input = { schema: provider === "muse" ? "paperclip.native-execution-input.v8" : "paperclip.native-execution-input.v6",
|
||||
binding: { runId: f.runId, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId },
|
||||
provider: { kind: provider }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null } };
|
||||
provider: { kind: provider, ...(provider === "muse" ? { bridgeRevision: "muse-v1" } : {}) }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null } };
|
||||
change?.(input);
|
||||
const [updated] = await db.update(heartbeatRuns).set({ driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp",
|
||||
const [updated] = await db.update(heartbeatRuns).set({ driverKind: provider === "muse" ? "muse_external" : provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp",
|
||||
runnerProfileJson: { ...run.runnerProfileJson, nativeExecutionInput: input } }).where(eq(heartbeatRuns.id, f.runId)).returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
it.each(["grok_bot", "openai_dot"] as const)("does not invent file recovery for %s admitted without workspace access", async provider => {
|
||||
it.each(["grok_bot", "openai_dot", "muse"] as const)("does not invent file recovery for %s admitted without workspace access", async provider => {
|
||||
const f = await seed(), run = await bindExternalInput(f, provider);
|
||||
const [hold] = provider === "muse" ? await db.insert(externalAgentHolds).values({ companyId: f.companyId, agentId: f.agentId,
|
||||
provider: "muse", assignmentId: randomUUID(), bindingId: randomUUID(), bindingGeneration: 1, runId: f.runId,
|
||||
workerUnknown: true, nativeEffectsUnknown: true }).returning() : [];
|
||||
await preserveNativeFailedWorkspace(db, run);
|
||||
expect((await readRun(f.runId)).resultJson).not.toHaveProperty("workspaceRestoreFailure");
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" });
|
||||
expect(await actions(f.issueId)).toHaveLength(0);
|
||||
if (hold) expect((await db.select().from(externalAgentHolds).where(eq(externalAgentHolds.id, hold.id)))[0]).toEqual(hold);
|
||||
});
|
||||
|
||||
it.each(["run", "company", "agent", "schema", "write_access"])("preserves file safeguards when no-workspace evidence has an invalid %s", async mismatch => {
|
||||
@@ -109,6 +113,27 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
|
||||
});
|
||||
|
||||
it.each(["run", "company", "agent", "issue", "schema", "provider", "bridge", "missing_bridge", "driver", "write_access", "cwd"] as const)("retains file safeguards for a mismatched Muse %s contract", async mismatch => {
|
||||
const f = await seed();
|
||||
await bindExternalInput(f, "muse", input => {
|
||||
if (mismatch === "run") input.binding.runId = randomUUID();
|
||||
if (mismatch === "company") input.binding.companyId = randomUUID();
|
||||
if (mismatch === "agent") input.binding.agentId = randomUUID();
|
||||
if (mismatch === "issue") input.binding.issueId = randomUUID();
|
||||
if (mismatch === "schema") input.schema = "paperclip.native-execution-input.v6";
|
||||
if (mismatch === "provider") input.provider.kind = "grok_bot";
|
||||
if (mismatch === "bridge") input.provider.bridgeRevision = "unknown";
|
||||
if (mismatch === "missing_bridge") delete input.provider.bridgeRevision;
|
||||
if (mismatch === "write_access") input.workspace.access = "read_write";
|
||||
if (mismatch === "cwd") input.workspace.cwd = "/untrusted-task-workspace";
|
||||
});
|
||||
if (mismatch === "driver") await db.update(heartbeatRuns).set({ driverKind: "openai_dot_mcp" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await preserveNativeFailedWorkspace(db, await readRun(f.runId));
|
||||
expect((await readRun(f.runId)).resultJson).toHaveProperty("workspaceRestoreFailure", "restore_failed");
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
|
||||
expect(await actions(f.issueId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["grok_bot", "openai_dot"] as const)("keeps historical %s inspection holds with an available recovery path", async provider => {
|
||||
const f = await seed();
|
||||
// Reproduce a hold persisted by an older cancellation path, then attach its admitted contract.
|
||||
|
||||
@@ -1689,6 +1689,7 @@ export function nativeSessionWorkspaceScope(execution: { binding: Pick<NativeExe
|
||||
|
||||
function nativeProviderSessionScope(execution: NativeExecutionInput) {
|
||||
switch (execution.provider.kind) {
|
||||
case "muse":
|
||||
case "grok_bot":
|
||||
case "openai_dot": return { kind: execution.provider.kind, bindingId: execution.provider.binding.bindingId, generation: execution.provider.binding.bindingGeneration };
|
||||
case "claude_managed":
|
||||
@@ -5549,6 +5550,7 @@ function canonicalJson(value: unknown): string {
|
||||
|
||||
function runnerProviderStateFilename(execution: NativeExecutionInput): string {
|
||||
switch (execution.provider.kind) {
|
||||
case "muse": return "muse-provider-state.json";
|
||||
case "grok_bot":
|
||||
case "openai_dot":
|
||||
return "dot-provider-state.json";
|
||||
@@ -5584,9 +5586,10 @@ export function providerSessionIdentityFromDurableProviderState(input: {
|
||||
providerSessionIdentity: null,
|
||||
});
|
||||
switch (input.execution.provider.kind) {
|
||||
case "muse":
|
||||
case "grok_bot":
|
||||
case "openai_dot":
|
||||
// Dot checkpoints recover the bridge only; there is no native provider thread.
|
||||
// External checkpoints recover the bridge only; there is no native provider thread.
|
||||
return emptyIdentity();
|
||||
case "acpx": {
|
||||
const descriptor = record(state.descriptor);
|
||||
@@ -8658,7 +8661,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
: null;
|
||||
}
|
||||
const runnerExecution: NativeExecutionInput =
|
||||
input.execution.schema !== "paperclip.native-execution-input.v6" && input.useRunnerd && input.runnerExecutionTarget?.kind === "remote"
|
||||
input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8" && input.useRunnerd && input.runnerExecutionTarget?.kind === "remote"
|
||||
? {
|
||||
...input.execution,
|
||||
workspace: {
|
||||
@@ -11431,6 +11434,9 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
retainedTransition?: VerifiedWarmTransitionBinding,
|
||||
): Promise<NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }> {
|
||||
const timeController = createRunPreparationTiming(input.execution.binding.runId, "native.controller");
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v8") {
|
||||
throw new Error("paperclip_runner_provider_unsupported");
|
||||
}
|
||||
let recoveryPending = retainedTransition !== undefined;
|
||||
const target = input.runnerExecutionTarget ?? { kind: "local" as const };
|
||||
const remoteTarget = target.kind === "remote" ? target : null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { connectorToolDefinitionsForSkills } from "../connector-runtime.js";
|
||||
import * as cloudIdentity from "../cloud-runtime-identity.js";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { upsertIssueDocumentSchema } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, upsertIssueDocumentSchema } from "@paperclipai/shared";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -24,9 +24,11 @@ import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity
|
||||
import { documentService } from "../documents.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { PaperclipRunnerSemanticAuthority } from "./runner-semantic-authority.js";
|
||||
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
|
||||
import type { ToolGatewayService } from "../tool-gateway.js";
|
||||
import { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js";
|
||||
import { buildLowTrustSourceTrust, buildPromotedSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../source-trust.js";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
describe("PaperclipRunnerToolAuthority", () => {
|
||||
@@ -196,6 +198,109 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
).rejects.toThrow("paperclip_runner_tool_not_advertised");
|
||||
});
|
||||
|
||||
it("preserves platform receipt provenance in both mixed task history readers", async () => {
|
||||
const at = new Date("2026-10-10T12:00:00Z");
|
||||
const commentIds = Array.from({ length: 4 }, () => randomUUID());
|
||||
const requestId = randomUUID();
|
||||
await db.insert(issueComments).values([
|
||||
{ id: commentIds[0], companyId, issueId, authorUserId: "board-user", authorType: "user",
|
||||
body: "Check the configuration.", createdAt: at },
|
||||
{ id: commentIds[1], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
origin: "fast_response", fastResponseRequestId: requestId, body: "I will check the configuration.",
|
||||
createdAt: new Date(at.getTime() + 1000) },
|
||||
{ id: commentIds[2], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
body: "Verified the configuration: two settings need updates.", createdAt: new Date(at.getTime() + 2000) },
|
||||
{ id: commentIds[3], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
origin: "fast_response", body: "Deleted acknowledgement", createdAt: new Date(at.getTime() + 3000),
|
||||
deletedAt: new Date(at.getTime() + 4000) },
|
||||
]);
|
||||
try {
|
||||
const tool = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const semantic = new PaperclipRunnerSemanticAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const histories = [
|
||||
async (limit: number) => tool.execute({ tool: "get_task_history", callId: `mixed-tool-history-${limit}`, arguments: { limit } }),
|
||||
async (limit: number) => {
|
||||
const result = await semantic.dispatch({ callId: `mixed-semantic-history-${limit}`, operationId: "get_task_history", input: { limit },
|
||||
correlation: { runId, normalizedSessionId: randomUUID(), turnId: "history", itemId: "history" } });
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
return result.ok ? result.value : null;
|
||||
},
|
||||
];
|
||||
const expected = [
|
||||
{ id: commentIds[0], authorAgentId: null, authorUserId: "board-user", origin: "comment", body: "Check the configuration." },
|
||||
{ id: commentIds[1], authorAgentId: agentId, authorUserId: null, origin: "fast_response",
|
||||
body: "[Paperclip acknowledgement; not agent work] I will check the configuration." },
|
||||
{ id: commentIds[2], authorAgentId: agentId, authorUserId: null, origin: "comment",
|
||||
body: "Verified the configuration: two settings need updates." },
|
||||
];
|
||||
for (const history of histories) {
|
||||
expect(await history(3)).toMatchObject({ comments: expected });
|
||||
expect(await history(2)).toMatchObject({ comments: expected.slice(1) });
|
||||
}
|
||||
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentIds[1])))[0])
|
||||
.toMatchObject({ id: commentIds[1], origin: "fast_response", fastResponseRequestId: requestId,
|
||||
authorAgentId: agentId, body: "I will check the configuration." });
|
||||
} finally {
|
||||
await db.delete(issueComments).where(inArray(issueComments.id, commentIds));
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("legacy history preserves source trust for a current reader run (low trust: %s)", async (lowTrustReader) => {
|
||||
const commentIds = Array.from({ length: 4 }, () => randomUUID());
|
||||
const [originalRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
const [originalIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const quarantine = buildLowTrustSourceTrust({ issueId, runId, agentId });
|
||||
const promotion = buildPromotedSourceTrust({
|
||||
sourceIssueId: issueId, sourceArtifactKind: "comment", sourceArtifactId: commentIds[2],
|
||||
promotedByActorType: "user", promotedByActorId: "board-user",
|
||||
});
|
||||
const at = new Date("2026-10-13T12:00:00Z");
|
||||
await db.insert(issueComments).values([
|
||||
{ id: commentIds[0], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
body: "QUARANTINED legacy history canary", sourceTrust: quarantine, createdAt: at },
|
||||
{ id: commentIds[1], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
origin: "fast_response", body: "QUARANTINED acknowledgement canary", sourceTrust: quarantine,
|
||||
createdAt: new Date(at.getTime() + 1000) },
|
||||
{ id: commentIds[2], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
body: "Reviewed and promoted summary", sourceTrust: promotion, createdAt: new Date(at.getTime() + 2000) },
|
||||
{ id: commentIds[3], companyId, issueId, authorUserId: "board-user", authorType: "user",
|
||||
body: "Trusted coordination message", createdAt: new Date(at.getTime() + 3000) },
|
||||
]);
|
||||
try {
|
||||
if (lowTrustReader) {
|
||||
const executionPolicy = { authorizationPolicy: { trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET, companyId, rootIssueId: issueId,
|
||||
} } };
|
||||
await db.update(issues).set({ executionPolicy }).where(eq(issues.id, issueId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionPolicy } }).where(eq(heartbeatRuns.id, runId));
|
||||
// Relaxing the live task must not relabel the already-admitted reader run.
|
||||
await db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, issueId));
|
||||
}
|
||||
const semantic = new PaperclipRunnerSemanticAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const result = await semantic.dispatch({
|
||||
callId: `legacy-trust-history-${lowTrustReader}`, operationId: "get_task_history", input: { limit: 4 },
|
||||
correlation: { runId, normalizedSessionId: randomUUID(), turnId: "history", itemId: "history" },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.ok ? result.value : null).toMatchObject({ comments: [
|
||||
{ id: commentIds[0], sourceTrust: quarantine,
|
||||
body: lowTrustReader ? "QUARANTINED legacy history canary" : LOW_TRUST_QUARANTINED_BODY },
|
||||
{ id: commentIds[1], origin: "fast_response", sourceTrust: quarantine,
|
||||
body: `[Paperclip acknowledgement; not agent work] ${lowTrustReader ? "QUARANTINED acknowledgement canary" : LOW_TRUST_QUARANTINED_BODY}` },
|
||||
{ id: commentIds[2], sourceTrust: promotion, body: "Reviewed and promoted summary" },
|
||||
{ id: commentIds[3], sourceTrust: null, body: "Trusted coordination message" },
|
||||
] });
|
||||
if (!lowTrustReader) expect(JSON.stringify(result)).not.toContain("QUARANTINED legacy history canary");
|
||||
if (!lowTrustReader) expect(JSON.stringify(result)).not.toContain("QUARANTINED acknowledgement canary");
|
||||
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentIds[0])))[0])
|
||||
.toMatchObject({ body: "QUARANTINED legacy history canary", sourceTrust: quarantine });
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: originalRun!.contextSnapshot }).where(eq(heartbeatRuns.id, runId));
|
||||
await db.update(issues).set({ executionPolicy: originalIssue!.executionPolicy }).where(eq(issues.id, issueId));
|
||||
await db.delete(issueComments).where(inArray(issueComments.id, commentIds));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns public task URLs from the current claimed origin in context and search results", async () => {
|
||||
const origin = vi.spyOn(cloudIdentity, "runtimeCanonicalOrigin").mockReturnValue("https://board.example");
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isConversation } from "../agent-conversations.js";
|
||||
import { setIssueTitle } from "../issue-title.js";
|
||||
import { externalObjectService } from "../external-objects.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { fastResponseHistoryBody, setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { authorizeInstructionCommit } from "../agent-instruction-authorization.js";
|
||||
import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
@@ -625,6 +625,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
const comments = await this.db.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
origin: issueComments.origin,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
@@ -636,7 +637,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
))
|
||||
.orderBy(desc(issueComments.createdAt))
|
||||
.limit(limit);
|
||||
return { comments: comments.reverse() };
|
||||
return { comments: comments.reverse().map(comment => ({ ...comment, body: fastResponseHistoryBody(comment) })) };
|
||||
}
|
||||
case "search_tasks": {
|
||||
const tasks = await issueService(this.db).list(this.binding.companyId, { readCondition: await issueReadSqlCondition(this.db, this.#privacyActor(context.run)) });
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { fastResponseHistoryBody } from "@paperclipai/shared";
|
||||
import { readTaskQuestionContext } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import {
|
||||
isLowTrustQuarantined,
|
||||
resolveActorSourceTrustForIssue,
|
||||
sanitizeQuarantinedCommentForHigherTrust,
|
||||
} from "../source-trust.js";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -155,6 +161,8 @@ export class PaperclipRunnerSemanticAuthority {
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
origin: issueComments.origin,
|
||||
sourceTrust: issueComments.sourceTrust,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
@@ -169,8 +177,19 @@ export class PaperclipRunnerSemanticAuthority {
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt))
|
||||
.limit(boundedLimit(input.limit));
|
||||
const readerSourceTrust = await resolveActorSourceTrustForIssue({
|
||||
db: this.#db,
|
||||
issue: context.issue,
|
||||
actor: { actorType: "agent", actorId: context.agent.id, agentId: context.agent.id, runId: context.run.id },
|
||||
});
|
||||
const comments = rows.reverse().map(row => {
|
||||
const comment = isLowTrustQuarantined(readerSourceTrust)
|
||||
? row
|
||||
: sanitizeQuarantinedCommentForHigherTrust(row);
|
||||
return { ...comment, body: fastResponseHistoryBody(comment) };
|
||||
});
|
||||
return {
|
||||
value: jsonValue({ comments: rows.reverse() }),
|
||||
value: jsonValue({ comments }),
|
||||
references: [{ kind: "task", id: context.issue.id }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export const LEGACY_WORKSPACE_RECOVERY_SCHEMA = "paperclip.workspace-restore-rec
|
||||
export function nativeRunHasNoWorkspaceAccess(run: Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "companyId" | "agentId" | "issueId" | "nativeIssueId" | "runtimeMode" | "driverKind" | "runnerProfileJson">): boolean {
|
||||
const input = run.runnerProfileJson?.nativeExecutionInput as Record<string, unknown> | undefined;
|
||||
if (run.runtimeMode !== "native" || input?.schema !== "paperclip.native-execution-input.v6") return false;
|
||||
if (run.runtimeMode !== "native" || !input || (input.schema !== "paperclip.native-execution-input.v6" && input.schema !== "paperclip.native-execution-input.v8")) return false;
|
||||
const binding = input.binding as Record<string, unknown> | undefined;
|
||||
const workspace = input.workspace as Record<string, unknown> | undefined;
|
||||
const provider = input.provider as Record<string, unknown> | undefined;
|
||||
const driver = provider?.kind === "grok_bot" ? "grok_bot_mcp"
|
||||
: provider?.kind === "openai_dot" ? "openai_dot_mcp" : null;
|
||||
const driver = input?.schema === "paperclip.native-execution-input.v8"
|
||||
? provider?.kind === "muse" && provider.bridgeRevision === "muse-v1" ? "muse_external" : null
|
||||
: provider?.kind === "grok_bot" ? "grok_bot_mcp"
|
||||
: provider?.kind === "openai_dot" ? "openai_dot_mcp" : null;
|
||||
return driver !== null && run.driverKind === driver && workspace?.access === "none" && workspace.cwd === null
|
||||
&& binding?.runId === run.id && binding.companyId === run.companyId && binding.agentId === run.agentId
|
||||
&& binding.issueId === (run.nativeIssueId ?? run.issueId);
|
||||
|
||||
+3
@@ -36,6 +36,8 @@ export type {
|
||||
NativeExecutionInputV4,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV8,
|
||||
MuseBindingSnapshot,
|
||||
DotBindingSnapshot,
|
||||
ExternalProviderPort,
|
||||
ExternalProviderOperation,
|
||||
@@ -158,6 +160,7 @@ export const bundledRemoteProviderPackManifestPath = runner.bundledRemoteProvide
|
||||
export const bundledRemoteRunnerBinary = runner.bundledRemoteRunnerBinary;
|
||||
|
||||
export const externalOperationDigest = runner.externalOperationDigest;
|
||||
export const digestPaperclipSemanticContent = runner.digestPaperclipSemanticContent;
|
||||
export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS;
|
||||
export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS;
|
||||
export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection;
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("adapter display registry", () => {
|
||||
it.each([
|
||||
["openai_dot", "OpenAI Dot"],
|
||||
["grok_bot", "Grok Bot"],
|
||||
["muse", "Muse — Personal agent"],
|
||||
])("keeps saved %s external runners branded by provider", (provider, label) => {
|
||||
const type = agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider } });
|
||||
expect(getAdapterDisplay(type).label).toBe(label);
|
||||
|
||||
@@ -136,6 +136,12 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||
icon: Bot,
|
||||
experimental: true,
|
||||
},
|
||||
muse: {
|
||||
label: "Muse — Personal agent",
|
||||
description: "Your personal Muse at muse.ai",
|
||||
icon: Bot,
|
||||
experimental: true,
|
||||
},
|
||||
cursor_cloud: {
|
||||
label: "Cursor Cloud",
|
||||
description: "Managed remote Cursor agent",
|
||||
@@ -211,7 +217,7 @@ export function getAdapterDisplay(type: string): AdapterDisplayInfo {
|
||||
|
||||
export function agentDisplayAdapterType(agent: { adapterType: string; adapterConfig: Record<string, unknown> }): string {
|
||||
if (agent.adapterType !== "paperclip_runner") return agent.adapterType;
|
||||
if (agent.adapterConfig.provider === "openai_dot" || agent.adapterConfig.provider === "grok_bot") {
|
||||
if (agent.adapterConfig.provider === "openai_dot" || agent.adapterConfig.provider === "grok_bot" || agent.adapterConfig.provider === "muse") {
|
||||
return agent.adapterConfig.provider;
|
||||
}
|
||||
return agentHarnessType(agent.adapterType, agent.adapterConfig) === "codex_local" ? "codex_local" : agent.adapterType;
|
||||
|
||||
@@ -88,6 +88,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableGrokBot: false,
|
||||
enableMuse: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
enableMemoryConnectors: false,
|
||||
|
||||
Reference in new issue
Block a user