mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(build): typecheck server/src/__tests__ and fix every type error (#18739)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server has about 1,100 test and helper files in `server/src/__tests__` > - `server/tsconfig.json` excludes that directory, Vitest does not check types, and no script runs the tsconfig in that directory > - So a test can call a function with the wrong arguments and still pass, and no tool reports it > - This gap hid 2,168 real type errors, and some of them hid tests that did not test what they claim > - This pull request fixes all of these errors and adds the directory to the server layers from paperclipai/paperclip#18737 > - The benefit is that a type error in a server test now fails the typecheck, with no baseline and no backlog ## Linked Issues or Issue Description Refs paperclipai/paperclip#18737, which is merged and added the server layers. Refs paperclipai/paperclip#18738, which is merged and checks the test projects outside `pnpm -r`, so it is not a duplicate. Refs paperclipai/paperclip#18137, which records that this directory has no check. **What happened?** No tool checked the types of 1,074 of the 1,101 files in `server/src/__tests__`. I searched `package.json`, the workflows, and all tsconfig files. 27 files are in the program of another tsconfig, and these 27 have 0 errors. 11 of the 27 are in `helpers/maintenance/tsconfig.json`. No script ran that project when I measured this. paperclipai/paperclip#18738 now runs it. **Expected behavior** The server typecheck checks these files, and a type error fails the check. **Steps to reproduce** 1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json --noEmit`. It reports 6,536 errors and needs 12.3 GiB. 2. Add a type error to any file in `server/src/__tests__`. 3. Run `pnpm --filter @paperclipai/server typecheck`. It passes. **Paperclip version or commit** I measured the error breakdown below on commit `8dfd632452`, with TypeScript 7.0.2. The branch now includes master up to paperclipai/paperclip#18865. **Deployment mode** Not applicable. This issue applies to development and CI. No production source file in the server changes. ### Error breakdown Each row adds one change to the row above. | Step | Total errors | Change | Cause | | --- | --- | --- | --- | | Config as committed | 6,536 | | | | Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request types were missing. | | Set `rootDir` to the repository root | 2,436 | -2,011 | Config. | | Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest accepts `.ts` imports. | | Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has `Promise.withResolvers`. | | Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8 `TS7016` errors and shows new real errors. | | Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing. | | Count only files in `server/src` | 2,058 | -84 | Other packages: 53 Daytona, 29 Runner test, 2 UI. | So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058 are real type errors in 314 files. The 29 Runner test errors are also real, which gives 2,087 errors. Tests that master added after this measurement had 81 more errors, so this pull request fixes 2,168 errors. This table classifies the first 2,058 errors. | Kind of real error | Errors | | --- | --- | | Wrong argument or assignment type | 1,023 | | Stale or partial fixture objects | 525 | | Property that the type does not have | 317 | | Missing narrowing of null or unknown | 101 | | Implicit any | 60 | | Missing name or module | 16 | | Other | 16 | ### What the type errors hid Most errors were stale fixtures and loose mocks. These errors hid real test problems, and this pull request fixes them: - **A test that never ran.** `instrumentation.test.ts` required `sdk-trace-base`, which is not installed. So 3 span tests were skipped. They now run and pass. - **Assertions that could not fail.** - `issue-thread-interactions-service` used a company ID as an issue ID. - `status-card-evaluation-work` compared `[]` with `[]` in a rollback check. - `slack-conversation-lifecycle` read `item.issueId`, which does not exist. - `email-channels` used a permission key that does not exist. - `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is not exported. The value was undefined. - **Postgres processes that did not stop.** 11 test files called `stop` on the test database helper. That method does not exist, and the helper method is `cleanup`. - **Arguments that the service ignored.** - `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft, 1Password, and Railway tests. - 108 calls passed `"owner-user"` to a parameter that expects an options object. - 9 chat wizard fixtures passed `managementContext` as `source`, so they ran `existing_app` and not `manifest`. - 2 tests passed a `{ userId }` actor that the function ignores. - Inserts into `environments` passed a `companyId` column that the table does not have. - **Problems in tests that master added during this work.** - `sandbox-creation-operations` read `database.connectionString` when an external test database was set. In that mode `database` is undefined, so the fixture would stop with a TypeError. It now uses the external URL. - `idle-completed-history` set a Dot operation to `dispatched`. That is a Muse status, and the Dot type does not have it. The fixture now uses `admitted`, which is a real in-flight Dot status. ### Possible product problems, not changed These may be real problems in production code or types. I did not change them, because this pull request does not change production behavior: - `decisions-service` `service.create` does not validate the lenient `cancel_issue_tree` input. - `createConnection` ignores `input.connectionPurpose`. - The `createConnection` route schema default stores `{}` for `transportConfig`. - `prepareManagedAiRuntime` accepted the invalid value `mode: "connection"`. - `createBinding` accepts invalid `projectionClass` strings outside class 3. - `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by spread. - `routineVariableSchema.label` is optional, but `RoutineVariable.label` is required. - `ToolRunContext.projectId` may be too strict. - `withdrawInteraction` reads `issue.status`, which its type omits. - `issueService.list` and `reconcileNativeFinalizations` omit fields that they return at runtime. - `createGitHubExternalObjectProvider` handles `tokenProvider: null`, but its type forbids this value. ## What Changed - Fixed the 2,168 type errors. The diff changes 322 test and helper files. - Corrected `server/src/__tests__/tsconfig.json`. Editors and the typecheck now read the same options and report the same errors. - Added three `test-tree` layers to `server/scripts/typecheck-layers.mjs`. They read the server layers as declarations, and any error fails them. A hash of the path selects the shard, so a new file does not move other files. - Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests reach Daytona plugin source, and the root install does not have `@daytonaio/sdk`. - Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use `.js` extensions, because server tests import this file. - Made `declarationsConfig` and `--declarations-only` skip the `test-tree` layers, because these layers emit nothing. Other projects read only the source layers. Added a test for this. - Updated `doc/DEVELOPING.md`. ## Verification Run these commands: ```sh pnpm --filter @paperclipai/server typecheck pnpm exec vitest run server/scripts/typecheck-layers.test.mjs ``` Results on macOS (arm64), Node 24, 16 cores. Each check ran after the merge of paperclipai/paperclip#18738 unless the row says otherwise: | Check | Commit | Result | | --- | --- | --- | | Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers | `6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB | | `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e` | Exit 0, 0 errors, 42 seconds, peak 3.25 GiB | | The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0 errors | | `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` | `01db5634e4` | 90 passed. These include real compiler runs for a pass, a type error, and an invalid tsconfig. | | `tsconfig.declarations.json` after a full server typecheck | `0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No generated config names a `tsconfig.layers.json` file. | | Owned runtime test in `idle-completed-history` | `e8f2f448ac` | Passed, with the fix that master later replaced | | `idle-incoming-wake`, `plugin-idle-manager`, and the changed `chat-channels` test | `6e45a08573` | 30 passed | | Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed | | The 19 server test files that changed since `eab4dcc1f4`, and `typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The 2 failures are in `chat-channels` and are in the list of 7 below. | The peak is the largest single process, from `/usr/bin/time -l`. Peaks for the server typecheck were between 3.42 GiB and 3.56 GiB across 8 runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2 runs. I did not measure a cgroup. **Runtime check of the fixes.** Types do not prove that a test still works. So I ran all 309 changed server test files, 11,929 tests, before the rebase onto paperclipai/paperclip#18737: | Result | Tests | Detail | | --- | --- | --- | | Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests that passed in a later run, as described below. | | Failed | 7 | The same 7 tests also fail on the commit before the fixes. | | Skipped | 9 | These tests have their own skip conditions: `sentry` 5, and 1 each in `bundled-plugin-registration`, `customer-success-cloud`, `onepassword-connection`, and `workspace-runtime-https-live-exercise`. | The 7 tests that fail with and without this change, on my machine: - 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on a `SKILL.md` file. The path is in the parent checkout, above the git worktree that I used, so I expect that the worktree is the cause. I did not prove this. - `workspace-instance-cleanup` compares `/private/var/...` with `/var/...`. This is a macOS symlink difference. - 2 tests in `git-reproducible-managed-checkouts` get `unknown_base_git_files` and expect `unpushed_managed_checkout`. I did not find the cause. - 1 more test in `chat-channels`, `materializes direct external-chat finals once`, gets `undefined`. I did not find the cause. CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`. Failures during this work: - My fix to `agent-runtime-connector-access` changed an invalid permission key in the setup, but not in the assertion. The runtime check found this. I corrected the assertion, and the file passes. - `heartbeat-plugin-environment` failed one wait in the full run. It passed three times alone, so I think that load caused the failure. It can fail again under load. - `dot-runner` skipped all 54 tests in the full run because `cargo` was not on my `PATH`. It then passed 54 of 54 with the toolchain on the `PATH`. - My first `lib` value removed the default DOM types, and the error count went up by 375. I added the DOM libraries. - My Daytona stub with bare `any` left 48 errors, because generic helpers then inferred `unknown`. I added shapes that return promises. - One fix to `agent-secrets-routes` used `class_3_static_lease`, and the test failed. That class allows only specific paths. I reverted to `unclassified`. - `ci / General tests (server (8/12))` failed on `259737b700` because of the flaky port in `idle-completed-history`. paperclipai/paperclip#18853 fixed this test on master, so this pull request keeps that fix. **Audit of the diff.** I counted these patterns in added and removed lines, to find fixes that only hide an error: | Pattern | Added | Removed | | --- | --- | --- | | `@ts-ignore` | 0 | 0 | | `@ts-expect-error` | 0 | 1 | | `.skip` | 0 | 0 | | `as any` | 9 | 9 | | `: any` | 11 | 2 | | `as never` | 8 | 2 | | `as unknown as` | 35 | 4 | | `Promise<unknown>` | 16 | 5 | | `expect(` | 128 | 113 | No hunk removes more `expect(` calls than it adds. Three removals of test content were intentional, and I checked each one: - `muse-control` removed `signalToken`. Line 1012 already asserts that it is absent. - `merged-pr-confirmation-sweep` removed legacy aliases. Other tests cover them. - `heartbeat-workspace-branch-containment` removed a `finalize` branch that no test row can reach. Some partial fixtures stay unchecked through `as unknown as` and `Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes` and `issue-comment-reopen-routes`. **Accuracy of the check.** One program over the tree and the three shards reported the same errors at the same positions. Memory, as the peak of the largest process on one thread, was 3.93 GiB for one program and 2.43 GiB for the largest shard. I measured these two values before the fixes. Not run or not proven: - I did not run the full `pnpm test:run`, or a root `pnpm build`. I expect CI to run the full suites. - I did not run the 4 GiB container check, or the full `scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so they are not a Linux cgroup measurement. - I did not run the UI typecheck after the merges. - `workspace-runtime-https-live-exercise` is skipped, so its `cleanup` fix did not run. ## Risks - **A large diff in test files.** 328 files change. A pull request that edits the same tests can get merge conflicts. New tests on master can also add type errors before this merges. Any such error fails this check and needs a fix here. - **A higher cost for changes to source types.** A change to a type can add errors in test files that the author did not touch. The author must then fix them. This is the purpose of the check, but it is new work. - **Changed test behavior.** Some tests now run paths that they did not run before, such as the 9 chat wizard fixtures and the 3 span tests. They pass here, but they can be more sensitive to timing or to the environment. - **The Daytona stub can go stale.** The stub is written by hand. If the plugin uses a new SDK member, the typecheck reports an error until someone updates the stub. - **The release path.** `release-verify.yml` and `scripts/release.sh` run `pnpm -r typecheck`, so they now run this check. - **The tsconfig change.** The test transform reads `server/src/__tests__/tsconfig.json`. The options that I changed are for type checks, and the runs above passed. - **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and in most sandbox provider plugins exclude `*.test.ts`. This check does not read most of those test files, so they can still drift. That work is separate from this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with reasoning, tool use, code execution, and subagents. The runtime does not show the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass (7 tests fail locally, with and without this change. See Verification.) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1fb1bcb8a
commit
11ac90e31f
328 files changed
+2268
-1604
No files matched your search
+8
-1
@@ -1186,7 +1186,8 @@ process per layer. Each layer reads the earlier layers as declarations:
|
||||
| `below` | Files the largest import cycle depends on, and unrelated files |
|
||||
| `cycle` | The largest import cycle |
|
||||
| `above` | Files that depend on the cycle |
|
||||
| `tests` | Test files |
|
||||
| `tests` | Test files outside `src/__tests__` |
|
||||
| `test-tree-0` to `test-tree-2` | `src/__tests__`, in shards |
|
||||
|
||||
The generated configs and declarations go in
|
||||
`server/node_modules/.cache/paperclip-typecheck`. You do not maintain a layer list.
|
||||
@@ -1195,6 +1196,12 @@ The server typecheck builds only the Runner's TypeScript output. Use
|
||||
`pnpm --filter @paperclipai/paperclip-runner build` when a local check needs the
|
||||
Runner binary.
|
||||
|
||||
`src/__tests__/tsconfig.json` holds the options for the test-tree layers, so an
|
||||
editor and the typecheck report the same errors. Server tests reach Daytona plugin
|
||||
source, but the root install does not install that plugin's dependencies. So this
|
||||
config maps `@daytonaio/sdk` to `server/scripts/typecheck-stubs/daytona-sdk.d.ts`.
|
||||
The plugin's own project checks that source against the real package.
|
||||
|
||||
If the memory available to the task is limited, use this command:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
canonicalNativeRuntimeContextDigest,
|
||||
composeNativeSystemInstructions,
|
||||
nativeRuntimePromptDigest,
|
||||
type NativeRuntimeContextSnapshot,
|
||||
} from "./runtime-context.js";
|
||||
|
||||
const input: NativeExecutionInputV1 = {
|
||||
@@ -81,6 +82,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
||||
}));
|
||||
const recovered = parseNativeExecutionInput(persisted);
|
||||
if (!("runtimeContext" in recovered)) throw new Error("Expected a runtime context");
|
||||
expect(recovered.runtimeContext).toEqual(persisted.runtimeContext);
|
||||
expect(parseNativeExecutionInput(recovered)).toEqual(recovered);
|
||||
},
|
||||
@@ -88,7 +90,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
|
||||
it("parses v3 immutable runtime context without changing the model task envelope", () => {
|
||||
const digest = "0".repeat(64);
|
||||
const context = {
|
||||
const context: Omit<NativeRuntimeContextSnapshot, "aggregateDigest"> = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: {
|
||||
entryPath: "AGENTS.md",
|
||||
@@ -101,7 +103,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/skills/research", fileCount: 2, totalBytes: 42 },
|
||||
}],
|
||||
mcp: { assignmentSetId: "sha256:test", digest, bindingId: "native-mcp:run-1" },
|
||||
} as const;
|
||||
};
|
||||
const preconfigured = {
|
||||
...input, schema: "paperclip.native-execution-input.v7", homeMode: "preconfigured",
|
||||
provider: { kind: "codex", model: null, approvalPolicy: "never" },
|
||||
@@ -129,6 +131,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
||||
});
|
||||
expect(parsed.schema).toBe("paperclip.native-execution-input.v3");
|
||||
if (!("runtimeContext" in parsed)) throw new Error("Expected a runtime context");
|
||||
const envelope = buildNativeModelEnvelope(parsed);
|
||||
expect(envelope.task).toEqual(buildNativeModelEnvelope(input).task);
|
||||
expect(envelope.completionContract).toEqual(buildNativeModelEnvelope(input).completionContract);
|
||||
@@ -277,7 +280,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
});
|
||||
|
||||
it("deserializes pre-provider Codex state as Codex", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
const legacy: Record<string, unknown> = { ...structuredClone(input) };
|
||||
delete legacy.provider;
|
||||
expect(parseNativeExecutionInput(legacy).provider).toEqual({
|
||||
kind: "codex",
|
||||
@@ -448,9 +451,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => {
|
||||
const { qualificationModel: _qualificationModel, reportedModelId: _reportedModelId,
|
||||
permissionPolicy, qualificationStatus: _qualificationStatus, modelPolicy: _modelPolicy,
|
||||
...snapshot } = QUALIFIED_ACPX_PROFILES.copilot;
|
||||
const { permissionPolicy, qualificationStatus: _qualificationStatus, ...snapshot } = QUALIFIED_ACPX_PROFILES.copilot;
|
||||
const provider = {
|
||||
kind: "acpx", agent: "copilot", model: "gpt-5.6-luna", permissionPolicy,
|
||||
profile: { ...snapshot, agentProfileVersion },
|
||||
@@ -474,11 +475,10 @@ describe("NativeExecutionInputV1", () => {
|
||||
it.each(Object.values(QUALIFIED_ACPX_PROFILES))(
|
||||
"admits the current $agent profile through the native execution boundary",
|
||||
(declaration) => {
|
||||
const { qualificationModel, reportedModelId: _reported, permissionPolicy,
|
||||
modelPolicy: _modelPolicy, qualificationStatus: _status, ...profile } = declaration;
|
||||
const { permissionPolicy, qualificationStatus: _status, ...profile } = declaration;
|
||||
const provider = {
|
||||
kind: "acpx", agent: declaration.agent,
|
||||
model: qualificationModel || "explicit-provider-model",
|
||||
model: "explicit-provider-model",
|
||||
permissionPolicy, profile,
|
||||
...(declaration.agent === "pi" ? { piThinkingLevel: "low" } : {}),
|
||||
};
|
||||
@@ -493,7 +493,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
);
|
||||
|
||||
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
const legacy: Record<string, unknown> = { ...structuredClone(input) };
|
||||
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
|
||||
expect(parseNativeExecutionInput(legacy).session.lifecyclePolicy).toEqual({
|
||||
mode: "per_turn",
|
||||
@@ -566,11 +566,11 @@ describe("NativeExecutionInputV2 planning", () => {
|
||||
})).toMatchObject({ task: { workMode: "planning" }, executionMode: "default" });
|
||||
});
|
||||
|
||||
const cloudContext = {
|
||||
const cloudContext: Omit<NativeRuntimeContextSnapshot, "aggregateDigest"> = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: "0".repeat(64), manifestDigest: "0".repeat(64), rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
|
||||
skills: [], mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null },
|
||||
} as const;
|
||||
};
|
||||
const cloudPlanning = {
|
||||
...planning, schema: "paperclip.native-execution-input.v5",
|
||||
provider: { kind: "cursor_cloud", model: "explicit-cloud-model", cursorCloud: { schema: "paperclip.cursor-cloud.config.v1", repos: [{ url: "https://github.com/example/fixture" }] } },
|
||||
@@ -639,12 +639,12 @@ describe.each(["ask", "skill_test"])("NativeExecutionInputV2 %s mode", (workMode
|
||||
describe("native task context ownership", () => {
|
||||
function currentInput() {
|
||||
const digest = "0".repeat(64);
|
||||
const context = {
|
||||
const context: Omit<NativeRuntimeContextSnapshot, "aggregateDigest"> = {
|
||||
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: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
|
||||
skills: [],
|
||||
mcp: { assignmentSetId: "none", digest, bindingId: null },
|
||||
} as const;
|
||||
};
|
||||
return parseNativeExecutionInput({
|
||||
...input,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
|
||||
@@ -656,8 +656,7 @@ describe("native task context ownership", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const { qualificationModel: _copilotQualificationModel, reportedModelId: _copilotReportedModelId,
|
||||
permissionPolicy: _copilotPermissionPolicy, qualificationStatus: _copilotQualificationStatus, modelPolicy: _copilotModelPolicy,
|
||||
const { permissionPolicy: _copilotPermissionPolicy, qualificationStatus: _copilotQualificationStatus,
|
||||
...copilotSnapshot } = QUALIFIED_ACPX_PROFILES.copilot;
|
||||
|
||||
it.each([
|
||||
@@ -690,6 +689,7 @@ describe("native task context ownership", () => {
|
||||
} : {}),
|
||||
}));
|
||||
const recovered = parseNativeExecutionInput(persisted);
|
||||
if (!("runtimeContext" in recovered)) throw new Error("Expected a runtime context");
|
||||
expect(recovered.runtimeContext).toEqual(context);
|
||||
expect(recovered.provider).toEqual(provider);
|
||||
expect(recovered.session.driverKind).toBe(driverKind);
|
||||
@@ -759,6 +759,7 @@ describe("native task context ownership", () => {
|
||||
|
||||
const fullResume = buildNativeModelEnvelope(parsed, { resumedSession: true });
|
||||
expect(fullResume.schema).toBe(envelopeSchema);
|
||||
if (!("task" in fullResume)) throw new Error("Expected a full resume envelope");
|
||||
expect(fullResume.task.prompt).toBe(parsed.task.prompt);
|
||||
expect(fullResume.task.prompt).not.toContain(guidance);
|
||||
|
||||
@@ -796,6 +797,7 @@ describe("native task context ownership", () => {
|
||||
completionContract: { ...execution.completionContract, contract },
|
||||
completionSources: { promptSha256: createHash("sha256").update(execution.task.prompt).digest("hex"), contractRevision: contract.revision, criteria: [{ id: "objective", source }] },
|
||||
});
|
||||
if (completeInput.schema !== NATIVE_EXECUTION_INPUT_SCHEMA) throw new Error("Expected a v5 execution input");
|
||||
const before = structuredClone(completeInput.completionContract);
|
||||
const envelope = buildNativeModelEnvelope(completeInput);
|
||||
expect(envelope.completionContract.criteria).toEqual([
|
||||
@@ -815,7 +817,7 @@ describe("native task context ownership", () => {
|
||||
|
||||
|
||||
describe("Claude effort input v9", () => {
|
||||
const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.claude;
|
||||
const { permissionPolicy: _permission, ...profile } = QUALIFIED_ACPX_PROFILES.claude;
|
||||
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: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
|
||||
@@ -856,7 +858,7 @@ describe("Claude effort input v9", () => {
|
||||
});
|
||||
|
||||
describe("Grok effort input v10", () => {
|
||||
const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.grok;
|
||||
const { permissionPolicy: _permission, ...profile } = QUALIFIED_ACPX_PROFILES.grok;
|
||||
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: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// cycle the largest import cycle, reading `below` as declarations
|
||||
// above files that depend on the cycle, reading both as declarations
|
||||
// tests test files, reading all three as declarations
|
||||
// test-tree-N src/__tests__, in shards, reading the source layers as declarations
|
||||
//
|
||||
// The layers come from the import graph on every run, so nothing has to move
|
||||
// and no list can go stale. The import scan does not have to be perfect: if it
|
||||
@@ -22,21 +23,26 @@
|
||||
//
|
||||
// Projects outside the server read the source layers as declarations through a
|
||||
// `tsconfig.layers.json` that references tsconfig.declarations.json in the output
|
||||
// directory. --declarations-only builds those layers and skips the tests layer.
|
||||
// directory. --declarations-only builds those layers and skips the test layers.
|
||||
//
|
||||
// Usage: node scripts/typecheck-layers.mjs [--declarations-only]
|
||||
|
||||
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { crc32 } from "node:zlib";
|
||||
import { runTsc } from "../../scripts/run-tsc.mjs";
|
||||
|
||||
const IMPORT_SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.[^"']+)["']/g;
|
||||
const OUTPUT_DIRECTORY = path.join("node_modules", ".cache", "paperclip-typecheck");
|
||||
// One program over this tree peaks at 3.9 GiB on one thread. Three peak at 2.5 GiB.
|
||||
const TEST_TREE_SHARDS = 3;
|
||||
|
||||
export const isTestFile = (file) => /\.test\.tsx?$/.test(file) || file.split("/").includes("__tests__");
|
||||
// tsconfig.json excludes this tree. A test reaches its helpers through imports.
|
||||
const isExcludedFromProgram = (file) => file.startsWith("src/__tests__/");
|
||||
// tsconfig.json excludes this tree, so the test-tree layers check it.
|
||||
const isTestTreeFile = (file) => file.startsWith("src/__tests__/");
|
||||
// Test layers emit nothing, so other projects cannot read them as declarations.
|
||||
const emitsDeclarations = (layer) => layer.name !== "tests" && !layer.testTree;
|
||||
|
||||
export function listSourceFiles(serverRoot) {
|
||||
const files = [];
|
||||
@@ -174,7 +180,12 @@ export function planLayers(files) {
|
||||
const dependents = reachableFrom(cycle, reversed);
|
||||
const above = [...dependents].filter((file) => !cycle.has(file));
|
||||
const below = [...graph.keys()].filter((file) => !dependents.has(file));
|
||||
const tests = [...candidates.keys()].filter((file) => isTestFile(file) && !isExcludedFromProgram(file));
|
||||
const tests = [...candidates.keys()].filter((file) => isTestFile(file) && !isTestTreeFile(file));
|
||||
// A hash keeps each file in its shard when other files are added or removed.
|
||||
const testTree = Array.from({ length: TEST_TREE_SHARDS }, () => []);
|
||||
for (const file of candidates.keys()) {
|
||||
if (isTestTreeFile(file)) testTree[crc32(file) % TEST_TREE_SHARDS].push(file);
|
||||
}
|
||||
|
||||
const layer = (name, members, references) => {
|
||||
const sorted = [...members].sort();
|
||||
@@ -186,6 +197,10 @@ export function planLayers(files) {
|
||||
layer("cycle", cycle, ["below"]),
|
||||
layer("above", above, ["below", "cycle"]),
|
||||
layer("tests", tests, ["below", "cycle", "above"]),
|
||||
...testTree.map((shard, index) => ({
|
||||
...layer(`test-tree-${index}`, shard, ["below", "cycle", "above"]),
|
||||
testTree: true,
|
||||
})),
|
||||
].filter((candidate) => candidate.sourceFiles.length > 0);
|
||||
}
|
||||
|
||||
@@ -195,6 +210,15 @@ export function layerConfig(layer, { emptyLayers = new Set() } = {}) {
|
||||
const references = layer.references
|
||||
.filter((name) => !emptyLayers.has(name))
|
||||
.map((name) => ({ path: `./tsconfig.${name}.json` }));
|
||||
// Editors read the same options from this file.
|
||||
if (layer.testTree) {
|
||||
return {
|
||||
extends: fromOutput("src/__tests__/tsconfig.json"),
|
||||
files: layer.files.map(fromOutput),
|
||||
include: [],
|
||||
references,
|
||||
};
|
||||
}
|
||||
const output =
|
||||
layer.name === "tests"
|
||||
? { composite: false, declaration: false, declarationMap: false, sourceMap: false, noEmit: true }
|
||||
@@ -222,7 +246,7 @@ export function declarationsConfig(layers) {
|
||||
compilerOptions: { composite: true },
|
||||
files: [],
|
||||
references: layers
|
||||
.filter((layer) => layer.name !== "tests")
|
||||
.filter(emitsDeclarations)
|
||||
.map((layer) => ({ path: `./tsconfig.${layer.name}.json` })),
|
||||
};
|
||||
}
|
||||
@@ -259,7 +283,7 @@ export function typecheckLayers({
|
||||
|
||||
let exitCode = 0;
|
||||
for (const layer of layers) {
|
||||
if (declarationsOnly && layer.name === "tests") continue;
|
||||
if (declarationsOnly && !emitsDeclarations(layer)) continue;
|
||||
log(`typecheck:layers: ${layer.name} (${layer.sourceFiles.length} files)`);
|
||||
const config = path.join(OUTPUT_DIRECTORY, `tsconfig.${layer.name}.json`);
|
||||
const { status, signal } = run(["-p", config], { cwd: serverRoot });
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("planLayers", () => {
|
||||
for (const layer of layers) expect(layer.files).toContain("src/types/ambient.d.ts");
|
||||
});
|
||||
|
||||
it("keeps test support files in the test layer and leaves src/__tests__ to imports", () => {
|
||||
it("keeps test support files in the test layer and shards src/__tests__ by a stable hash", () => {
|
||||
const layers = planLayers(
|
||||
new Map([
|
||||
["src/a.ts", ""],
|
||||
@@ -88,6 +88,25 @@ describe("planLayers", () => {
|
||||
|
||||
expect(sourcesOf(layers, "below")).toEqual(["src/a.ts"]);
|
||||
expect(sourcesOf(layers, "tests")).toEqual(["src/services/__tests__/fixtures.ts"]);
|
||||
const shards = layers.filter((layer) => layer.testTree);
|
||||
expect(shards.flatMap((layer) => layer.sourceFiles).sort()).toEqual([
|
||||
"src/__tests__/big.test.ts",
|
||||
"src/__tests__/helpers/db.ts",
|
||||
]);
|
||||
for (const shard of shards) expect(shard.references).toEqual(["below", "cycle", "above"]);
|
||||
|
||||
const withMore = planLayers(
|
||||
new Map([
|
||||
["src/a.ts", ""],
|
||||
["src/__tests__/helpers/db.ts", ""],
|
||||
["src/__tests__/big.test.ts", ""],
|
||||
["src/__tests__/added-1.test.ts", ""],
|
||||
["src/__tests__/added-2.test.ts", ""],
|
||||
]),
|
||||
);
|
||||
for (const shard of shards) {
|
||||
for (const file of shard.sourceFiles) expect(sourcesOf(withMore, shard.name)).toContain(file);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists imported JSON in the layer that imports it", () => {
|
||||
@@ -120,14 +139,14 @@ describe("planLayers", () => {
|
||||
const planned = layers.flatMap((layer) => layer.sourceFiles);
|
||||
expect(new Set(planned).size).toBe(planned.length);
|
||||
expect(planned.sort()).toEqual(
|
||||
sourceFiles.filter((file) => !file.endsWith(".d.ts") && !file.startsWith("src/__tests__/")).sort(),
|
||||
sourceFiles.filter((file) => !file.endsWith(".d.ts")).sort(),
|
||||
);
|
||||
|
||||
const position = new Map(
|
||||
layers.flatMap((layer, index) => layer.sourceFiles.map((file) => [file, index])),
|
||||
);
|
||||
const imports = readImports(new Map([...files].filter(([file]) => !file.endsWith(".d.ts"))));
|
||||
for (const layer of layers.filter((candidate) => candidate.name !== "tests")) {
|
||||
for (const layer of layers.filter((candidate) => candidate.name !== "tests" && !candidate.testTree)) {
|
||||
for (const file of layer.sourceFiles) {
|
||||
for (const target of imports.get(file).modules) {
|
||||
if (position.has(target)) expect(position.get(target)).toBeLessThanOrEqual(position.get(file));
|
||||
@@ -160,9 +179,37 @@ describe("layerConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("layerConfig for the test tree", () => {
|
||||
it("reads the options that editors read and emits nothing", () => {
|
||||
const config = layerConfig({
|
||||
name: "test-tree-0",
|
||||
files: ["src/__tests__/a.test.ts"],
|
||||
references: ["below"],
|
||||
testTree: true,
|
||||
});
|
||||
|
||||
expect(config.extends).toBe("../../../src/__tests__/tsconfig.json");
|
||||
expect(config.compilerOptions).toBeUndefined();
|
||||
expect(config.files).toEqual(["../../../src/__tests__/a.test.ts"]);
|
||||
expect(config.references).toEqual([{ path: "./tsconfig.below.json" }]);
|
||||
});
|
||||
|
||||
it("has a real tests tsconfig that cannot emit and sees the ambient types", () => {
|
||||
const config = JSON.parse(readFileSync(path.join(serverRoot, "src/__tests__/tsconfig.json"), "utf8"));
|
||||
|
||||
expect(config.compilerOptions).toMatchObject({ noEmit: true, rootDir: "../../.." });
|
||||
expect(config.include).toContain("../types/*.d.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("declarationsConfig", () => {
|
||||
it("references the source layers that exist, and not tests", () => {
|
||||
const config = declarationsConfig([{ name: "below" }, { name: "above" }, { name: "tests" }]);
|
||||
it("references the source layers that exist, and no test layer", () => {
|
||||
const config = declarationsConfig([
|
||||
{ name: "below" },
|
||||
{ name: "above" },
|
||||
{ name: "tests" },
|
||||
{ name: "test-tree-0", testTree: true },
|
||||
]);
|
||||
|
||||
expect(config.compilerOptions.composite).toBe(true);
|
||||
expect(config.files).toEqual([]);
|
||||
@@ -213,8 +260,10 @@ describe("typecheckLayers", () => {
|
||||
expect(JSON.parse(readFileSync(path.join(output, "package.json"), "utf8"))).toEqual({ type: "module" });
|
||||
});
|
||||
|
||||
it("skips the tests layer when only declarations are needed", () => {
|
||||
it("skips the test layers when only declarations are needed", () => {
|
||||
const root = serverFixture();
|
||||
mkdirSync(path.join(root, "src", "__tests__"));
|
||||
writeFileSync(path.join(root, "src", "__tests__", "route.test.ts"), `import "../route.js";`);
|
||||
const configs = [];
|
||||
|
||||
const status = typecheckLayers({
|
||||
@@ -405,6 +454,53 @@ describe("typecheckLayers with the real compiler", () => {
|
||||
expect(output).toContain("leaf");
|
||||
}, 60_000);
|
||||
|
||||
function addTestTree(root, source) {
|
||||
mkdirSync(path.join(root, "src", "__tests__"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(root, "src", "__tests__", "tsconfig.json"),
|
||||
`${JSON.stringify({
|
||||
extends: "../../tsconfig.json",
|
||||
compilerOptions: { noEmit: true, rootDir: "../.." },
|
||||
include: ["."],
|
||||
})}\n`,
|
||||
);
|
||||
writeFileSync(path.join(root, "src", "__tests__", "tree.test.ts"), source);
|
||||
}
|
||||
|
||||
it("checks the test tree through declarations", () => {
|
||||
const root = compilerFixture();
|
||||
addTestTree(root, `import { route } from "../route.js";\nexport const checked: number = route();\n`);
|
||||
|
||||
const { status, output } = check(root);
|
||||
|
||||
expect(output).toBe("");
|
||||
expect(status).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it("fails on a type error in the test tree", () => {
|
||||
const root = compilerFixture();
|
||||
addTestTree(root, `import { route } from "../route.js";\nexport const checked: string = route();\n`);
|
||||
|
||||
const { status, output } = check(root);
|
||||
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toMatch(/tree\.test\.ts\(2,\d+\): error TS2322/);
|
||||
}, 60_000);
|
||||
|
||||
it("fails when the test-tree tsconfig is invalid", () => {
|
||||
const root = compilerFixture();
|
||||
addTestTree(root, `export const checked = 1;\n`);
|
||||
writeFileSync(
|
||||
path.join(root, "src", "__tests__", "tsconfig.json"),
|
||||
`${JSON.stringify({ extends: "../../tsconfig.json", compilerOptions: { noEmit: true, rootDir: "../..", target: "not-a-target" } })}\n`,
|
||||
);
|
||||
|
||||
const { status, output } = check(root);
|
||||
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toMatch(/error TS\d+/);
|
||||
}, 60_000);
|
||||
|
||||
it("reports a type change through declarations in the test layer", () => {
|
||||
const root = compilerFixture();
|
||||
writeFileSync(
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Server tests reach Daytona plugin source, but the root install does not install
|
||||
// that plugin's dependencies. Its own project checks that source against the real
|
||||
// package. The server tests only need these names to resolve. Async calls return a
|
||||
// Promise because a bare `any` makes the plugin's generic helpers infer `unknown`.
|
||||
type AsyncMembers = { [member: string]: (...args: any[]) => Promise<any> };
|
||||
|
||||
export declare class Daytona {
|
||||
constructor(config?: DaytonaConfig);
|
||||
snapshot: AsyncMembers;
|
||||
get(...args: any[]): Promise<any>;
|
||||
create(...args: any[]): Promise<any>;
|
||||
list(...args: any[]): Promise<any>;
|
||||
[member: string]: any;
|
||||
}
|
||||
export declare class DaytonaNotFoundError extends Error {}
|
||||
export declare class DaytonaTimeoutError extends Error {}
|
||||
export type CreateSandboxBaseParams = any;
|
||||
export type CreateSandboxFromImageParams = any;
|
||||
export type CreateSandboxFromSnapshotParams = any;
|
||||
export type DaytonaConfig = any;
|
||||
export type FileDownloadRequest = any;
|
||||
export type FileDownloadResponse = any;
|
||||
export type FileUpload = any;
|
||||
export type Resources = any;
|
||||
export interface Sandbox {
|
||||
fs: { downloadFiles(...args: any[]): Promise<any[]>; [member: string]: (...args: any[]) => Promise<any> };
|
||||
process: {
|
||||
createPty: (...args: any[]) => Promise<any>;
|
||||
executeCommand(...args: any[]): Promise<{ result?: string; artifacts?: { stdout?: string }; exitCode?: number }>;
|
||||
getSessionCommandLogs(
|
||||
sessionId: string,
|
||||
commandId: string,
|
||||
onStdout?: (chunk: string) => void,
|
||||
onStderr?: (chunk: string) => void,
|
||||
): Promise<any>;
|
||||
[member: string]: (...args: any[]) => Promise<any>;
|
||||
};
|
||||
[member: string]: any;
|
||||
}
|
||||
@@ -201,7 +201,7 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
|
||||
companyId: company.id,
|
||||
applicationKey: `route-app-${randomUUID()}`,
|
||||
name: "Route personal app",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
|
||||
@@ -272,7 +272,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `instance-access-${randomUUID()}`,
|
||||
name: "Instance access app",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
@@ -333,7 +333,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal mail",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
@@ -428,7 +428,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `shared-personal-${randomUUID()}`,
|
||||
name: "Shared personal app",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
@@ -505,7 +505,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `mixed-${randomUUID()}`,
|
||||
name: "Mixed credential app",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
@@ -595,7 +595,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `shared-org-${randomUUID()}`,
|
||||
name: "Shared organization app",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
@@ -692,7 +692,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal mail",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
@@ -754,7 +754,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal shared credentials",
|
||||
type: "mcp",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definitions = await db.insert(userSecretDefinitions).values([
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("prefers the heartbeat run responsible user", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[heartbeatRuns, [{ responsibleUserId: "run-user" }]],
|
||||
[issues, [{ responsibleUserId: "issue-user", createdByUserId: null }]],
|
||||
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
|
||||
@@ -89,7 +89,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("falls back to issue attribution when the run is unavailable", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[heartbeatRuns, []],
|
||||
[issues, [{ responsibleUserId: "issue-user", createdByUserId: "creator-user" }]],
|
||||
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
|
||||
@@ -103,7 +103,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("uses explicit issue context for non-issue activity", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[issues, [{ responsibleUserId: "issue-user", createdByUserId: null }]],
|
||||
[companies, [{ defaultResponsibleUserId: "default-user" }]],
|
||||
]));
|
||||
@@ -116,7 +116,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("uses the active agent API key responsible user for no-run actions", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
|
||||
[companies, [{ defaultResponsibleUserId: "default-user" }]],
|
||||
]));
|
||||
@@ -129,7 +129,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("falls back to the company default responsible user", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[agentApiKeys, []],
|
||||
[companies, [{ defaultResponsibleUserId: "default-user" }]],
|
||||
]));
|
||||
@@ -142,7 +142,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("uses issue creator attribution when responsibleUserId is absent", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[issues, [{ responsibleUserId: null, createdByUserId: "creator-user" }]],
|
||||
[companies, [{ defaultResponsibleUserId: "default-user" }]],
|
||||
]));
|
||||
@@ -151,7 +151,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
|
||||
});
|
||||
|
||||
it("ignores malformed UUID-backed identifiers", async () => {
|
||||
const db = createReader(new Map([
|
||||
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
|
||||
[heartbeatRuns, [{ responsibleUserId: "run-user" }]],
|
||||
[issues, [{ responsibleUserId: "issue-user", createdByUserId: null }]],
|
||||
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
|
||||
|
||||
@@ -4,6 +4,7 @@ import request from "supertest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import type { RunnerTarget } from "../services/agent-runner-selection.js";
|
||||
|
||||
const mockAdapterPluginStore = vi.hoisted(() => ({
|
||||
listAdapterPlugins: vi.fn(),
|
||||
@@ -221,7 +222,7 @@ describe("adapter routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
it.each<[RunnerTarget, string]>([
|
||||
[{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"],
|
||||
[{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"],
|
||||
[{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"],
|
||||
|
||||
@@ -177,12 +177,12 @@ describePostgres("agent action audit routes", () => {
|
||||
it("denies agents and board users without the audit permission", async () => {
|
||||
const { company, agent } = await seed();
|
||||
const agentResponse = await request(await createApp(db, {
|
||||
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
|
||||
type: "agent", agentId: agent.id, companyId: company.id, source: "agent_jwt",
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(agentResponse.status).toBe(403);
|
||||
|
||||
const agentAllActorsResponse = await request(await createApp(db, {
|
||||
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
|
||||
type: "agent", agentId: agent.id, companyId: company.id, source: "agent_jwt",
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all`);
|
||||
expect(agentAllActorsResponse.status, JSON.stringify(agentAllActorsResponse.body)).toBe(200);
|
||||
expect(agentAllActorsResponse.body.items).toHaveLength(3);
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("agent auth middleware", () => {
|
||||
it.each(["key", "jwt"])("rejects unfinished setup authentication with a %s", async kind => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const token = kind === "jwt" ? createLocalAgentJwt(agentId, companyId, "codex_local", randomUUID(), "user-1") : "pcp_setup-test-token";
|
||||
const token = kind === "jwt" ? createLocalAgentJwt(agentId, companyId, "codex_local", randomUUID(), "user-1")! : "pcp_setup-test-token";
|
||||
const { db } = createDbState({
|
||||
agent: { id: agentId, companyId, status: "paused", lifecycleHolds: ["setup"] },
|
||||
...(kind === "key" ? { agentKey: { id: randomUUID(), agentId, companyId, keyHash: hashToken(token), responsibleUserId: "user-1" } } : {}),
|
||||
|
||||
@@ -147,7 +147,7 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
|
||||
const locked = new Promise<number>(resolve => { ready = resolve; });
|
||||
const release = new Promise<void>(resolve => { unlock = resolve; });
|
||||
const gate = server.db.transaction(async tx => {
|
||||
const [{ pid }] = await tx.execute(sql<{ pid: number }>`select pg_backend_pid() as pid`);
|
||||
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, receipt.conversationId)).for("update");
|
||||
ready(pid); await release;
|
||||
});
|
||||
@@ -263,7 +263,7 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
|
||||
const locked = new Promise<number>(resolve => { ready = resolve; });
|
||||
const release = new Promise<void>(resolve => { unlock = resolve; });
|
||||
const gate = server.db.transaction(async tx => {
|
||||
const [{ pid }] = await tx.execute(sql<{ pid: number }>`select pg_backend_pid() as pid`);
|
||||
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.agentId)).for("update");
|
||||
ready(pid);
|
||||
await release;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("agent commentary browsing", () => {
|
||||
await db.insert(agents).values({ id: agentId, companyId: pagedCompanyId, name: "Writer", role: "engineer", adapterType: "codex_local" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: pagedCompanyId, agentId, status: "succeeded" });
|
||||
await db.insert(agentCommentary).values(Array.from({ length: 58 }, (_, index) => ({
|
||||
companyId: pagedCompanyId, agentId, runId, kind: "suggestion", body: `Message ${index}`,
|
||||
companyId: pagedCompanyId, agentId, runId, kind: "suggestion" as const, body: `Message ${index}`,
|
||||
idempotencyKey: String(index), payloadHash: "hash",
|
||||
})));
|
||||
const first = await reader.list(pagedCompanyId, query(), sql`true`);
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
undeliveredConversationComments,
|
||||
} from "../services/agent-conversations.js";
|
||||
import { classifyIssueGraphLiveness } from "../services/recovery/issue-graph-liveness.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "../services/native-runtime/status-arbiter.js";
|
||||
import { runningProcesses } from "../adapters/index.js";
|
||||
import {
|
||||
buildPaperclipTaskMarkdown,
|
||||
@@ -444,9 +445,9 @@ import {
|
||||
});
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({
|
||||
companyId, issueId: issue.id, kind: "request_confirmation", status: "rejected",
|
||||
payload: { version: 1, target: { type: "issue_document", key: "plan", documentId: document.id,
|
||||
payload: { version: 1, prompt: "Approve the plan?", target: { type: "issue_document", key: "plan", documentId: document.id,
|
||||
revisionId: document.latestRevisionId!, revisionNumber: document.latestRevisionNumber } },
|
||||
result: { outcome: "rejected", reason: "Include CHAT_REVIEW_MARKER in the revised plan." },
|
||||
result: { version: 1, outcome: "rejected", reason: "Include CHAT_REVIEW_MARKER in the revised plan." },
|
||||
}).returning();
|
||||
const input = { db, companyId, issueSummary: { ...issue, workMode: "planning" },
|
||||
contextSnapshot: { issueId: issue.id, conversationMode: true, interactionId: interaction!.id,
|
||||
@@ -467,7 +468,7 @@ import {
|
||||
contextSnapshot: { issueId: issue.id, conversationMode: true } });
|
||||
expect(later?.planReviewContext).toBeNull();
|
||||
// An unrelated confirmation must not cause old plan context to be replayed.
|
||||
await db.update(issueThreadInteractions).set({ payload: { version: 1 } })
|
||||
await db.update(issueThreadInteractions).set({ payload: { version: 1, prompt: "Proceed?" } })
|
||||
.where(eq(issueThreadInteractions.id, interaction!.id));
|
||||
expect((await buildPaperclipWakePayload(input))?.planReviewContext).toBeNull();
|
||||
});
|
||||
@@ -769,7 +770,7 @@ import {
|
||||
const succeeded = { ...run, contextSnapshot: prepared.context, status: "succeeded" };
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({
|
||||
companyId, issueId: chat.id, sourceRunId: run.id, createdByAgentId: agentId,
|
||||
kind: "request_confirmation", status: "answered", payload: { version: 1 },
|
||||
kind: "request_confirmation", status: "answered", payload: { version: 1, prompt: "Proceed?" },
|
||||
}).returning();
|
||||
expect(await settleConversationTurn(db, succeeded)).toBe(false);
|
||||
await db.update(issueThreadInteractions).set({ status: "pending", sourceRunId: null })
|
||||
@@ -831,8 +832,8 @@ import {
|
||||
blockedByIssueIds: [blocker.id],
|
||||
});
|
||||
for (const state of [
|
||||
{ status: "in_review", conversationState: "waiting" },
|
||||
{ status: "blocked", conversationState: "active" },
|
||||
{ status: "in_review", conversationState: "waiting" as const },
|
||||
{ status: "blocked", conversationState: "active" as const },
|
||||
]) {
|
||||
await db.update(issues).set(state).where(eq(issues.id, chat.id));
|
||||
for (const reason of ["issue_blockers_resolved", "issue_children_completed", "issue_unblock_requested"]) {
|
||||
@@ -914,19 +915,21 @@ import {
|
||||
const answerRun = await runFor(chat.id, first.id, { source: "issue.interaction.respond", interactionId: question.id,
|
||||
interactionKind: "ask_user_questions", interactionStatus: "answered" });
|
||||
const prepared = await prepareConversationTurn(db, answerRun);
|
||||
expect(prepared.context.wakeCommentId).toBe(first.id);
|
||||
expect(prepared.context.conversationReplyBoundaryCommentId).toBe(["answered", "new-message"].includes(scenario) ? newer.id : first.id);
|
||||
const preparedContext: Record<string, unknown> = prepared.context;
|
||||
expect(preparedContext.wakeCommentId).toBe(first.id);
|
||||
expect(preparedContext.conversationReplyBoundaryCommentId).toBe(["answered", "new-message"].includes(scenario) ? newer.id : first.id);
|
||||
// A retry reuses the frozen boundary, even if another message has arrived.
|
||||
if (scenario === "new-message") await svc.addComment(chat.id, "New instructions while you answer", { userId: "local-board" });
|
||||
const retry = await prepareConversationTurn(db, { ...answerRun, contextSnapshot: prepared.context });
|
||||
expect(retry.context.conversationReplyBoundaryCommentId).toBe(prepared.context.conversationReplyBoundaryCommentId);
|
||||
const retryContext: Record<string, unknown> = retry.context;
|
||||
expect(retryContext.conversationReplyBoundaryCommentId).toBe(preparedContext.conversationReplyBoundaryCommentId);
|
||||
await svc.addComment(chat.id, "You chose Blue.", { agentId, runId: answerRun.id });
|
||||
expect(await settleConversationTurn(db, { ...answerRun, status: "succeeded", contextSnapshot: prepared.context })).toBe(true);
|
||||
const [settled] = await db.select().from(issues).where(eq(issues.id, chat.id));
|
||||
expect(settled.conversationState).toBe(scenario === "answered" ? "waiting" : "active");
|
||||
expect(retry.context.conversationReplayThroughCommentId).toBe(prepared.context.conversationReplayThroughCommentId);
|
||||
expect(retryContext.conversationReplayThroughCommentId).toBe(preparedContext.conversationReplayThroughCommentId);
|
||||
const replay = await conversationReplay(db, companyId, chat.id, first.id,
|
||||
prepared.context.conversationReplayThroughCommentId as string);
|
||||
preparedContext.conversationReplayThroughCommentId as string);
|
||||
if (["answered", "new-message"].includes(scenario)) {
|
||||
expect(replay).toContain("What is the capital of France?");
|
||||
expect(replay).toContain("Paris.");
|
||||
@@ -1026,7 +1029,7 @@ describe("chat prompt policy", () => {
|
||||
|
||||
it.each([true, false])("preserves rejected-plan changes in task markdown (includeDescription=%s)", (includeDescription) => {
|
||||
const prompt = buildPaperclipTaskMarkdown({
|
||||
issue: { id: "chat", title: "Chat", workMode: "planning", conversationAgentId: "agent" },
|
||||
issue: { id: "chat", identifier: null, title: "Chat", workMode: "planning", conversationAgentId: "agent" },
|
||||
interaction: { kind: "request_confirmation", status: "rejected" },
|
||||
planReview: { status: "rejected", reason: "Add CHAT_REVIEW_MARKER and a validation step." },
|
||||
acceptedPlanContinuation: true,
|
||||
@@ -1070,7 +1073,7 @@ describe("chat prompt policy", () => {
|
||||
describe("native conversation finalization", () => {
|
||||
it("does not require execution completion or schedule a continuation after a successful chat turn", () => {
|
||||
const decision = {
|
||||
policyVersion: "paperclip.native-status-arbiter.v1",
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "in_progress",
|
||||
toStatus: "in_progress",
|
||||
reasonCode: "completion_evidence_incomplete",
|
||||
|
||||
@@ -47,6 +47,7 @@ let currentKeyAgentId = agentId;
|
||||
let currentAccessCanUser = false;
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
retryLifecycle: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
|
||||
@@ -12,7 +12,7 @@ import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
const expectedNewAgentGrantKeys: PermissionKey[] = [
|
||||
const expectedNewAgentGrantKeys: PermissionKey[] = ([
|
||||
"agents:read",
|
||||
"agents:configure",
|
||||
"agents:suggest-changes",
|
||||
@@ -28,7 +28,7 @@ const expectedNewAgentGrantKeys: PermissionKey[] = [
|
||||
"tasks:assign",
|
||||
"tasks:assign_scope",
|
||||
"tasks:manage_active_checkouts",
|
||||
].sort();
|
||||
] satisfies PermissionKey[]).sort();
|
||||
|
||||
describeDatabase("new agent configuration defaults", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
@@ -51,6 +51,7 @@ const GROK_PROMPT_OUTPUT = [
|
||||
const CREDENTIAL_BYTES = '{"tokens":{"access":"SECRET-ACCESS-TOKEN"}}';
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getChainOfCommand: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("stable live agent directory observation", () => {
|
||||
traversal: `${reserved}/../../scratch`, "trailing-slash": `${reserved}/scratch/`, nul: `${reserved}/scratch\0`,
|
||||
}[kind]!;
|
||||
await expect(prepareSandboxManagedRuntime({
|
||||
spec: { transport: "sandbox", provider: "fixture", sandboxId: "owned", remoteCwd: root, apiKey: null },
|
||||
spec: { transport: "sandbox", provider: "fixture", sandboxId: "owned", remoteCwd: root, timeoutMs: 30_000, apiKey: null },
|
||||
adapterKey: "agent-files", client: {} as never, workspaceLocalDir: root, runtimeRootDir: invalid,
|
||||
})).rejects.toThrow("Transfer scratch must remain within the lease runtime tree");
|
||||
expect(fs.existsSync(reserved)).toBe(false);
|
||||
|
||||
@@ -908,10 +908,10 @@ describe("persistent agent directories", () => {
|
||||
it("cleans up a staging failure before any provider starts", async () => {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure" });
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure", pid: null, startedAt: null });
|
||||
try {
|
||||
await expect(copies.prepare({ ...target(), runId, cwd: home, target: { kind: "remote", transport: "ssh", environmentId: randomUUID(), remoteCwd: "/fixture/task",
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task" } } })).rejects.toThrow("Could not exclude");
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task", remoteWorkspacePath: "/fixture/task", privateKey: null, knownHosts: null, strictHostKeyChecking: true } } })).rejects.toThrow("Could not exclude");
|
||||
const row = (await copies.get(companyId, runId))!;
|
||||
expect(row.state).toBe("unavailable");
|
||||
expect(row.receipt?.baseline).toBeUndefined();
|
||||
@@ -1424,7 +1424,7 @@ describe("persistent agent directories", () => {
|
||||
|
||||
it("stages SSH agent files at the registered root without a nested task workspace", async () => {
|
||||
const remoteCwd = path.join(home, "ssh-task");
|
||||
const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
||||
const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null });
|
||||
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockImplementation(async input => {
|
||||
await fs.mkdir(path.dirname(input.remoteDir), { recursive: true });
|
||||
await fs.cp(input.localDir, input.remoteDir, { recursive: true });
|
||||
@@ -1436,7 +1436,7 @@ describe("persistent agent directories", () => {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
||||
const target = { kind: "remote" as const, transport: "ssh" as const, environmentId: randomUUID(), remoteCwd,
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd, remoteWorkspacePath: remoteCwd, privateKey: null, knownHosts: null, strictHostKeyChecking: true } };
|
||||
await db.insert(environments).values({ id: target.environmentId, name: "Fixture SSH", driver: "ssh" });
|
||||
// Legacy no-ID receipt: only the unique run/environment lease authorizes retrieval.
|
||||
await db.insert(environmentLeases).values({ companyId, environmentId: target.environmentId, heartbeatRunId: runId, provider: "ssh", providerLeaseId: "fixture-host" });
|
||||
@@ -1606,8 +1606,8 @@ describe("persistent agent directories", () => {
|
||||
const { copy, lease } = await unavailableRemote({ state: "destroyed" });
|
||||
const remoteCwd = "/fixture/cleanup-task";
|
||||
const executionTarget = { kind: "remote" as const, transport: "ssh" as const, environmentId: lease.environmentId, leaseId: lease.id, remoteCwd,
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
||||
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd, remoteWorkspacePath: remoteCwd, privateKey: null, knownHosts: null, strictHostKeyChecking: true } };
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null });
|
||||
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockResolvedValue(undefined);
|
||||
let stopCommitted!: () => void, finishCleanup!: () => void;
|
||||
const committed = new Promise<void>(resolve => { stopCommitted = resolve; });
|
||||
@@ -1728,7 +1728,7 @@ describe("persistent agent directories", () => {
|
||||
await db.insert(environmentLeases).values(lease);
|
||||
const execute = vi.fn(), restoreWorkspace = vi.fn(), cleanupWorkspaceSnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId, leaseId, remoteCwd, runner: { execute } };
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
||||
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null });
|
||||
const stage = vi.spyOn(executionTargetTools, "prepareAdapterExecutionTargetRuntime").mockImplementation(async options => ({ target: executionTarget,
|
||||
workspaceRemoteDir: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, runId), runtimeRootDir: options.runtimeRootDir ?? null,
|
||||
assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace, cleanupWorkspaceSnapshot }));
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("incremental managed file checkpoints", () => {
|
||||
if (first) { first = false; await fs.appendFile(filename, Buffer.alloc(1024 * 1024, 2)); }
|
||||
yield chunk;
|
||||
}
|
||||
})()) as typeof handle.createReadStream;
|
||||
})()) as unknown as typeof handle.createReadStream;
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
const otherOwner = `other-${f.companyId}`;
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: otherOwner, membershipRole: "member", status: "active" });
|
||||
const account = await aiConnectionService(db).save(f.companyId, otherOwner, { provider: "openai", method: "api_key", ownership: "shared", name: "Shared credential, restricted installation", apiKey: "fixture", agentIds: [f.agentId], allAgents: false }, "fixture");
|
||||
await toolAccessService(db).replaceConnectionGrantMembers(account.connectionId, account.grantId, [f.userId], { userId: otherOwner });
|
||||
await toolAccessService(db).replaceConnectionGrantMembers(account.connectionId, account.grantId, [f.userId], { actorType: "user", actorId: otherOwner });
|
||||
const member: AiConnectionPoolMember = { id: randomUUID(), binding: { ...account, provider: "openai", method: "api_key", mode: "shared" }, profile: { provider: "codex", model: "gpt-5.6-sol" } };
|
||||
const p = await poolFixture(f, [member]);
|
||||
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agents`).send({ name: "Restricted pool teammate", role: "engineer", adapterType: f.adapterType, runtimeConfig: { aiConnection: p.binding } }));
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("agent cryptographic identity", () => {
|
||||
await execute({
|
||||
runId: randomUUID(), agent, agentIdentity: identity,
|
||||
config: { command: process.execPath, cwd: directory, args: ["-e", `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.env.CHALLENGE,'base64'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`], env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "configured override" } },
|
||||
context: {}, runtime: { sessionId: null, sessionParams: null, taskKey: null },
|
||||
context: {}, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
onLog: async (_stream, chunk) => { logs.push(chunk); }, onMeta: async meta => { metas.push(meta); },
|
||||
});
|
||||
expect(verify(null, Buffer.from(challenge, "base64"), publicPem, Buffer.from(logs.join(""), "base64"))).toBe(true);
|
||||
|
||||
@@ -43,7 +43,7 @@ describe.skipIf(!support.supported)("agent-scoped runner qualification", () => {
|
||||
await db.insert(authUsers).values({ id: userId, name: "Company owner", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(principalPermissionGrants).values(grantsForHumanRole("owner").map(grant => ({ ...grant, companyId, principalType: "user" as const, principalId: userId })));
|
||||
await db.insert(environments).values({ id: environmentId, name: `Paperclip Computer ${environmentId}`, driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } });
|
||||
await db.insert(environments).values({ id: environmentId, name: `Paperclip Computer ${environmentId}`, driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false } });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Amy fixture", adapterType: "claude_local", defaultEnvironmentId: environmentId,
|
||||
adapterConfig: { model: "claude-opus-5-5", effort: "high", maxTurnsPerRun: 1000, dangerouslySkipPermissions: true } });
|
||||
app = express(); app.use(express.json());
|
||||
@@ -89,7 +89,7 @@ describe.skipIf(!support.supported)("agent-scoped runner qualification", () => {
|
||||
|
||||
it("reuses a current native snapshot proof without invalidating it or allocating another probe", async () => {
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode" } }).where(eq(agents.id, agentId));
|
||||
const config = { provider: "daytona", snapshot: "managed-snapshot" };
|
||||
const config = { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false };
|
||||
const fingerprint = fingerprintEnvironmentSandboxProviderConfig(config);
|
||||
const saved = (await environmentService(db).getById(environmentId))!;
|
||||
const runtime = await resolveEnvironmentDriverConfigForRuntime(db, companyId, saved, { applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true });
|
||||
|
||||
@@ -98,14 +98,14 @@ describeDatabase("connection assignment synchronization", () => {
|
||||
const [project] = await db.insert(projects).values({ companyId, name: "Project binding fixture" }).returning();
|
||||
for (let round = 0; round < 2; round++) {
|
||||
let before = (await events()).length;
|
||||
await service.bindProfile(profile.id, { targetType: "agent", targetId: agentId });
|
||||
await service.bindProfile(profile.id, { targetType: "agent", targetId: agentId, priority: 100 });
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
|
||||
before = (await events()).length;
|
||||
await service.unbindProfile(profile.id, { targetType: "agent", targetId: agentId });
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
|
||||
}
|
||||
const beforeProject = await events();
|
||||
await service.bindProfile(profile.id, { targetType: "project", targetId: project.id });
|
||||
await service.bindProfile(profile.id, { targetType: "project", targetId: project.id, priority: 100 });
|
||||
await service.unbindProfile(profile.id, { targetType: "project", targetId: project.id });
|
||||
expect(await events()).toEqual(beforeProject);
|
||||
let before = (await events()).length;
|
||||
@@ -121,7 +121,7 @@ describeDatabase("connection assignment synchronization", () => {
|
||||
await db.update(toolConnectionInstalls).set({ targetId: peerId }).where(eq(toolConnectionInstalls.id, install.id));
|
||||
expect((await events()).slice(before).map(event => event.resourceId).sort()).toEqual([agentId, peerId].sort());
|
||||
before = (await events()).length;
|
||||
await service.bindProfile(profile.id, { targetType: "company", targetId: companyId });
|
||||
await service.bindProfile(profile.id, { targetType: "company", targetId: companyId, priority: 100 });
|
||||
expect((await events()).slice(before).map(event => event.resourceId).sort()).toEqual([agentId, peerId].sort());
|
||||
});
|
||||
it("keeps connector changes and their events atomic and company-scoped", async () => {
|
||||
@@ -217,9 +217,9 @@ describeDatabase("connection assignment synchronization", () => {
|
||||
const single = createDb(database.connectionString, { maxConnections: 1 });
|
||||
const service = toolAccessService(single);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const work = change === "bind" ? service.bindProfile(source.id, { targetType: "agent", targetId: agentId })
|
||||
const work = change === "bind" ? service.bindProfile(source.id, { targetType: "agent", targetId: agentId, priority: 100 })
|
||||
: change === "unbind" ? service.unbindProfile(source.id, { targetType: "agent", targetId: agentId })
|
||||
: service.deleteProfile(source.id, change === "reassign" ? { reassignToProfileId: target.id } : { force: true });
|
||||
: service.deleteProfile(source.id, change === "reassign" ? { force: false, reassignToProfileId: target.id } : { force: true });
|
||||
const settled = Promise.allSettled([work]);
|
||||
try {
|
||||
await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("Profile operation waited for another pooled connection")), 3000); })]);
|
||||
@@ -261,9 +261,9 @@ describeDatabase("connection assignment synchronization", () => {
|
||||
expect(activity.wait_event_type).toBe("Lock");
|
||||
}, { timeout: 5000 });
|
||||
const mutation = run(writer => change === "bind"
|
||||
? writer.bindProfile(profile.id, { targetType: "agent", targetId: targetAgent })
|
||||
? writer.bindProfile(profile.id, { targetType: "agent", targetId: targetAgent, priority: 100 })
|
||||
: change === "unbind" ? writer.unbindProfile(profile.id, { targetType: "agent", targetId: targetAgent })
|
||||
: writer.deleteProfile(source!.id, { reassignToProfileId: profile.id }));
|
||||
: writer.deleteProfile(source!.id, { force: false, reassignToProfileId: profile.id }));
|
||||
const mutationResult = Promise.allSettled([mutation]);
|
||||
let finishResult: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
||||
try {
|
||||
@@ -324,7 +324,7 @@ describeDatabase("connection assignment synchronization", () => {
|
||||
const firstResult = Promise.allSettled([first]);
|
||||
try {
|
||||
await waitForLock(0);
|
||||
second = Promise.allSettled([run(change === "connection settings" ? adopt : client => toolAccessService(client).bindProfile(bindingProfile!.id, { targetType: "agent", targetId: lower }, { actorType: "agent", actorId: higher }))]);
|
||||
second = Promise.allSettled([run(change === "connection settings" ? adopt : client => toolAccessService(client).bindProfile(bindingProfile!.id, { targetType: "agent", targetId: lower, priority: 100 }, { actorType: "agent", actorId: higher }))]);
|
||||
await waitForLock(1);
|
||||
} finally { release(); await blocker; }
|
||||
for (const result of [...await firstResult, ...await second!]) if (result.status === "rejected") throw result.reason;
|
||||
@@ -373,7 +373,7 @@ describeDatabase("connection assignment synchronization", () => {
|
||||
await waitForLock(0);
|
||||
replacement = Promise.allSettled([run(service => change === "installs"
|
||||
? service.putConnectionInstalls(connection.id, { installs: [{ targetType: "agent", targetId: lower }] })
|
||||
: change === "reassign" ? service.deleteProfile(source.id, { reassignToProfileId: target.id })
|
||||
: change === "reassign" ? service.deleteProfile(source.id, { force: false, reassignToProfileId: target.id })
|
||||
: service.finishGalleryAppConnection(companyId, connection.id, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [change === "finish" ? lower : higher] } }))]);
|
||||
await waitForLock(1);
|
||||
} finally { release(); await blocker; }
|
||||
|
||||
@@ -72,7 +72,7 @@ describeDatabase("connector access runtime synchronization", () => {
|
||||
let memberId: string | undefined;
|
||||
if (change === "uninstall") {
|
||||
await toolAccessService(db).putConnectionInstalls(connectionId, { installs: [{ targetType: "agent", targetId: agentId }] });
|
||||
expect((await toolAccessService(db).getConnection(connectionId, companyId)).config.mcpAgentAccessConfigured).toBe(true);
|
||||
expect((await toolAccessService(db).getConnection(connectionId, companyId)).config?.mcpAgentAccessConfigured).toBe(true);
|
||||
first = client => toolAccessService(client).putConnectionInstalls(connectionId, { installs: [] });
|
||||
second = client => toolAccessService(client).finishGalleryAppConnection(companyId, connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" });
|
||||
} else {
|
||||
@@ -80,7 +80,7 @@ describeDatabase("connector access runtime synchronization", () => {
|
||||
memberId = member.id;
|
||||
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: "alice", permissionKey: "agents:create" });
|
||||
first = client => accessService(client).updateMemberAndPermissions(companyId, member.id, { membershipRole: "admin", grants: [{ permissionKey: "agents:create" }] }, null);
|
||||
second = client => accessService(client).setMemberPermissions(companyId, member.id, [{ permissionKey: "issues:create" }], null);
|
||||
second = client => accessService(client).setMemberPermissions(companyId, member.id, [{ permissionKey: "tasks:assign" }], null);
|
||||
}
|
||||
let locked!: () => void; let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { locked = resolve; });
|
||||
@@ -118,7 +118,7 @@ describeDatabase("connector access runtime synchronization", () => {
|
||||
expect(bindings.some(binding => binding.metadata?.source === "tool_connection_install")).toBe(false);
|
||||
} else {
|
||||
expect(await accessService(db).getMemberById(companyId, memberId!)).toMatchObject({ membershipRole: "admin" });
|
||||
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toEqual([expect.objectContaining({ permissionKey: "issues:create" })]);
|
||||
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toEqual([expect.objectContaining({ permissionKey: "tasks:assign" })]);
|
||||
}
|
||||
}, 15000);
|
||||
it.each(["email allowAgent", "connection intent"] as const)("orders outer %s with direct install saves", async caller => {
|
||||
@@ -132,7 +132,7 @@ describeDatabase("connector access runtime synchronization", () => {
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Use Notion", status: "in_progress", assigneeAgentId: agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, contextSnapshot: { issueId } });
|
||||
const pending = await connectionIntentService(db).request({ sub: agentId, company_id: companyId, run_id: runId, responsible_user_id: userId, scope: "connection_intents", iat: 1, exp: 2, instance_id: "test" }, "notion");
|
||||
const pending = await connectionIntentService(db).request({ sub: agentId, company_id: companyId, run_id: runId, responsible_user_id: userId }, "notion");
|
||||
expect(pending.interactionId).toEqual(expect.any(String));
|
||||
await db.update(toolConnections).set({ healthStatus: "ok", config: { sourceTemplateKey: "notion", mcpAgentAccessConfigured: true } }).where(eq(toolConnections.id, connectionId));
|
||||
await db.insert(connectionGrants).values({ companyId, connectionId, kind: "organization", status: "active", isDefault: true });
|
||||
@@ -316,7 +316,7 @@ describeDatabase("connector access runtime synchronization", () => {
|
||||
});
|
||||
it("limits delegation creation to its target without waiting on unrelated agents", async () => {
|
||||
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalType: "user", principalId, status: "active" })));
|
||||
const grants = await db.insert(connectionGrants).values(["alice", "bob"].map(subjectUserId => ({ companyId, connectionId, kind: "user", subjectUserId }))).returning();
|
||||
const grants = await db.insert(connectionGrants).values(["alice", "bob"].map(subjectUserId => ({ companyId, connectionId, kind: "user" as const, subjectUserId }))).returning();
|
||||
const baseline = await events();
|
||||
const [lower, higher] = [agentId, peerId].sort();
|
||||
let locked!: () => void; let release!: () => void;
|
||||
|
||||
@@ -136,7 +136,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
await db.update(toolConnections).set({ config: { mcpAgentAccessConfigured: true } }).where(eq(toolConnections.id, connectionId));
|
||||
await db.insert(toolProfiles).values({ companyId, profileKey: `app:${connectionId}`, name: "Install profile" });
|
||||
const [lower, higher] = [randomUUID(), randomUUID()].sort();
|
||||
await db.insert(toolPolicies).values([higher, lower].map((id, index) => ({ id, companyId, name: `Managed policy ${index}`, policyType: "require_approval", priority: (index + 1) * 100, config: { source: "app_gallery_finish", connectionId } })));
|
||||
await db.insert(toolPolicies).values([higher, lower].map((id, index) => ({ id, companyId, name: `Managed policy ${index}`, policyType: "require_approval" as const, priority: (index + 1) * 100, config: { source: "app_gallery_finish", connectionId } })));
|
||||
let locked!: () => void; let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { locked = resolve; });
|
||||
const resume = new Promise<void>(resolve => { release = resolve; });
|
||||
@@ -222,10 +222,10 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
expect(row.wait_event_type).toBe("Lock");
|
||||
}, { timeout: 5000 });
|
||||
const mutation = run(writer => change === "profile entry add"
|
||||
? writer.addProfileEntry(profile.id, { selectorType: "catalog_entry", catalogEntryId: entry.id, connectionId, applicationId })
|
||||
? writer.addProfileEntry(profile.id, { selectorType: "catalog_entry", effect: "include", catalogEntryId: entry.id, connectionId, applicationId })
|
||||
: change === "profile entry edit" ? writer.updateProfileEntry(profileEntry.id, { effect: "exclude" })
|
||||
: change === "profile entry delete" ? writer.deleteProfileEntry(profileEntry.id)
|
||||
: writer.updateProfile(profile.id, { entries: [{ selectorType: "catalog_entry", catalogEntryId: entry.id, connectionId, applicationId }] }));
|
||||
: writer.updateProfile(profile.id, { entries: [{ selectorType: "catalog_entry", effect: "include", catalogEntryId: entry.id, connectionId, applicationId }] }));
|
||||
const mutationResult = Promise.allSettled([mutation]);
|
||||
let finishResult: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
||||
try {
|
||||
@@ -279,7 +279,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
|
||||
it.each(["add", "edit", "delete", "replace"] as const)("completes profile entry %s with a single database connection", async change => {
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "single-entry", name: "Single entry" }).returning();
|
||||
const input = { selectorType: "connection" as const, applicationId, connectionId };
|
||||
const input = { selectorType: "connection" as const, effect: "include" as const, applicationId, connectionId };
|
||||
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, ...input }).returning();
|
||||
const single = createDb(database.connectionString, { maxConnections: 1 });
|
||||
const service = toolAccessService(single);
|
||||
@@ -301,7 +301,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "scoped", name: "Scoped" }).returning();
|
||||
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId });
|
||||
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, selectorType: "connection", connectionId }).returning();
|
||||
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Scoped", policyType: "deny", selectors: { agentId } }).returning();
|
||||
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Scoped", policyType: "block", selectors: { agentId } }).returning();
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const before = (await events()).length;
|
||||
if (change === "entry") await db.update(toolProfileEntries).set({ effect: index % 2 ? "include" : "exclude" }).where(eq(toolProfileEntries.id, entry.id));
|
||||
@@ -318,7 +318,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
});
|
||||
|
||||
it("uses old and new policy selectors with the execution match semantics", async () => {
|
||||
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Retarget", policyType: "deny", selectors: { agentId } }).returning();
|
||||
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Retarget", policyType: "block", selectors: { agentId } }).returning();
|
||||
const write = async (selectors: Record<string, unknown>, expected: string[]) => {
|
||||
const before = (await events()).length;
|
||||
await db.update(toolPolicies).set({ selectors }).where(eq(toolPolicies.id, policy.id));
|
||||
@@ -365,7 +365,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "coalesced", name: "Coalesced" }).returning();
|
||||
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId });
|
||||
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, selectorType: "connection", connectionId }).returning();
|
||||
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Coalesced", policyType: "deny", selectors: { agentId } }).returning();
|
||||
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Coalesced", policyType: "block", selectors: { agentId } }).returning();
|
||||
let before = (await events()).length;
|
||||
await db.transaction(async tx => {
|
||||
await tx.update(toolProfileEntries).set({ effect: "exclude" }).where(eq(toolProfileEntries.id, entry.id));
|
||||
@@ -383,7 +383,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
|
||||
it("orders scoped policy reorder before company agent fanout", async () => {
|
||||
const [lower, higher] = [agentId, peerId].sort();
|
||||
const policies = await db.insert(toolPolicies).values([higher, lower].map((target, index) => ({ companyId, name: `Scoped ${index}`, policyType: "deny", priority: (index + 5) * 100, selectors: { agentId: target } }))).returning();
|
||||
const policies = await db.insert(toolPolicies).values([higher, lower].map((target, index) => ({ companyId, name: `Scoped ${index}`, policyType: "block" as const, priority: (index + 5) * 100, selectors: { agentId: target } }))).returning();
|
||||
let locked!: () => void; let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { locked = resolve; });
|
||||
const resume = new Promise<void>(resolve => { release = resolve; });
|
||||
@@ -454,7 +454,7 @@ describeDatabase("tool definition runtime synchronization", () => {
|
||||
}, 15000);
|
||||
|
||||
it("retries changed old references before taking profile runtime locks", async () => {
|
||||
const [oldApp, newApp] = await db.insert(toolApplications).values(["Old retry app", "New retry app"].map(name => ({ companyId, name, type: "mcp_http" }))).returning();
|
||||
const [oldApp, newApp] = await db.insert(toolApplications).values(["Old retry app", "New retry app"].map(name => ({ companyId, name, type: "mcp_http" as const }))).returning();
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "retry-references", name: "Retry references" }).returning();
|
||||
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId });
|
||||
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, selectorType: "application", applicationId: oldApp.id }).returning();
|
||||
|
||||
@@ -160,7 +160,7 @@ describeEmbeddedPostgres("agent secret routes", () => {
|
||||
targetType: "agent",
|
||||
targetId: fixture.agentId,
|
||||
configPath: "access.API_ONLY_KEY",
|
||||
projectionClass: "class_2_runtime_only",
|
||||
projectionClass: "unclassified",
|
||||
});
|
||||
const projectBinding = await svc.createBinding({
|
||||
companyId: fixture.companyId,
|
||||
@@ -184,7 +184,7 @@ describeEmbeddedPostgres("agent secret routes", () => {
|
||||
const list = await request(createApp(fixture)).get("/api/agents/me/secrets");
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.secrets).toEqual([
|
||||
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "class_2_runtime_only" }),
|
||||
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "unclassified" }),
|
||||
expect.objectContaining({ key: "env_only_key", delivery: "env" }),
|
||||
expect.objectContaining({ key: "project_key", delivery: "env" }),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
@@ -837,7 +838,7 @@ describe("agent test-environment route", () => {
|
||||
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
const previous = getServerAdapter("claude_local");
|
||||
unregisterServerAdapter("claude_local");
|
||||
const cliProbeSpy = vi.fn(async () => ({
|
||||
const cliProbeSpy = vi.fn(async (_context: AdapterEnvironmentTestContext) => ({
|
||||
adapterType: "claude_local",
|
||||
status: "fail" as const,
|
||||
checks: [{ code: "adapter_command_missing", level: "error" as const, message: 'Command not found in PATH: "claude"' }],
|
||||
|
||||
@@ -57,7 +57,7 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
|
||||
}));
|
||||
const mockRunClaudeLogin = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
|
||||
const mockRunClaudeLogin = vi.hoisted(() => vi.fn(async (_input: { config: { env: Record<string, string> } }) => ({ ok: true })));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
|
||||
@@ -1103,7 +1103,6 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
adapterType: "claude_local",
|
||||
environmentId: scope.environmentId,
|
||||
});
|
||||
const writer = createSetupTokenSecretWriter({ db });
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ if (!support.supported) console.warn(`Managed-account database tests unavailable
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: `${provider} gateway`, transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: provider, url: provider === "arcade" ? "https://api.arcade.dev/mcp/test" : "https://executor.example/mcp", managementUrl: "https://executor.example/team/integrations" } }).returning();
|
||||
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active", isDefault: true });
|
||||
const names = provider === "executor" ? ["integrations"] : ["Notion.ListPages"];
|
||||
await db.insert(toolCatalogEntries).values(names.map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active", riskLevel: "read" as const })));
|
||||
await db.insert(toolCatalogEntries).values(names.map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active" as const, riskLevel: "read" as const })));
|
||||
let accounts = [{ integration: "notion", owner: "user", connection: "Work", lastHealth: { status: "healthy", checkedAt: Date.now() } }];
|
||||
let fail = false;
|
||||
let partial = false;
|
||||
@@ -88,7 +88,7 @@ if (!support.supported) console.warn(`Managed-account database tests unavailable
|
||||
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps).toHaveLength(0);
|
||||
});
|
||||
it("reports unsupported Executor inventory without trying arbitrary tools", async () => {
|
||||
const f = await fixture(); await sync(f); f.calls.length = 0; await db.update(toolCatalogEntries).set({ status: "inactive" }).where(eq(toolCatalogEntries.connectionId, f.connection.id));
|
||||
const f = await fixture(); await sync(f); f.calls.length = 0; await db.update(toolCatalogEntries).set({ status: "disabled" }).where(eq(toolCatalogEntries.connectionId, f.connection.id));
|
||||
expect((await f.access.syncAggregatorApps(f.connection.id, true, f.actor)).discovery.availability).toBe("unsupported");
|
||||
expect(f.calls).toHaveLength(0);
|
||||
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps[0].freshness).toBe("stale");
|
||||
|
||||
@@ -24,12 +24,12 @@ describe("personal AI credential preflight recovery", () => {
|
||||
expect(isAiAuthenticationBlocked({ ...run, livenessState: "blocked" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
it.each<[string, unknown]>([
|
||||
["tool credential", [{ ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]],
|
||||
["company secret", [{ ...missingClaudeKey, bindingType: "secret_ref" }]],
|
||||
["unknown error", [{ ...missingClaudeKey, errorCode: "user_secret_definition_missing" }]],
|
||||
["adapter field", [{ ...missingClaudeKey, configPath: "apiKey" }]],
|
||||
...["project", "environment", "routine", "plugin"].map(consumerType => [
|
||||
...["project", "environment", "routine", "plugin"].map((consumerType): [string, unknown] => [
|
||||
`${consumerType} binding`, [{ ...missingClaudeKey, consumerType }],
|
||||
]),
|
||||
["different agent", [{ ...missingClaudeKey, consumerId: "another-agent" }]],
|
||||
@@ -44,7 +44,7 @@ describe("personal AI credential preflight recovery", () => {
|
||||
});
|
||||
|
||||
it("only classifies a failed preflight as an inline wait after the card is persisted", () => {
|
||||
const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked" },
|
||||
const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked", title: "Blocked task", description: null },
|
||||
stdoutExcerpt: null, stderrExcerpt: null, error: null, continuationAttempt: 0, evidence: null };
|
||||
expect(classifyRunLiveness(input).livenessState).toBe("failed");
|
||||
expect(classifyRunLiveness({ ...input, authenticationRepairRequested: true })).toMatchObject({
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("managed AI connections", () => {
|
||||
displayName: "Bootstrap", description: "Bootstrap fixture", author: "Test", categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" },
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }] } });
|
||||
const [environment] = await db.insert(environments).values({ companyId, name: "Agent computer", driver: "plugin",
|
||||
const [environment] = await db.insert(environments).values({ name: "Agent computer", driver: "plugin",
|
||||
config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
|
||||
const token = "github_pat_fixture_plugin_bootstrap";
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Bootstrap", apiKey: token, agentIds: [], allAgents: false }, token);
|
||||
@@ -300,7 +300,7 @@ describe("managed AI connections", () => {
|
||||
await db.insert(principalPermissionGrants).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", permissionKey: "agents:create" })));
|
||||
const token = "github_pat_fixture_shared_discovery";
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "shared", name: "Shared discovery", apiKey: token, agentIds: [], allAgents: false }, token);
|
||||
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user", subjectId })));
|
||||
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user" as const, subjectId })));
|
||||
const binding = { provider: "github", method: "api_key", mode: "shared", ...account };
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
|
||||
const app = express(); app.use(express.json());
|
||||
@@ -1338,7 +1338,7 @@ describe("managed AI connections", () => {
|
||||
agentIds: [], allAgents: true,
|
||||
routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none", models: [] },
|
||||
}, "");
|
||||
const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
|
||||
const binding = { provider, method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } as const;
|
||||
const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
|
||||
const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
|
||||
const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
|
||||
@@ -1357,7 +1357,7 @@ describe("managed AI connections", () => {
|
||||
}, "fixture-grok-key");
|
||||
const taskKey = `grok-history-${randomUUID()}`;
|
||||
const runInput = { ...input, taskKey, adapterType: "grok_local", responsibleUserId: "alice", config: {},
|
||||
binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
|
||||
binding: { provider: "xai", method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } as const };
|
||||
const first = await prepareManagedAiRuntime(db, runInput);
|
||||
const scope = createHash("sha256").update(JSON.stringify([
|
||||
companyId, agentId, taskKey, first.sessionIdentity,
|
||||
@@ -1597,12 +1597,12 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "shared", name: "Shared usage account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "shared-usage-secret");
|
||||
const selected = { provider: "anthropic", method: "subscription", mode: "shared", ...account } as const;
|
||||
const tools = toolAccessService(db);
|
||||
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["alice"], { userId: "alice" });
|
||||
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["alice"], { actorType: "user", actorId: "alice" });
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 100 }, extra_usage: { is_enabled: false } })));
|
||||
try {
|
||||
await expect(service.probeUsage(companyId, "bob", account.connectionId)).rejects.toThrow("not found");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["bob"], { userId: "alice" });
|
||||
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["bob"], { actorType: "user", actorId: "alice" });
|
||||
for (const config of [{ adapterType: "claude_local" }, { adapterType: "paperclip_runner", runnerProvider: "acpx", acpxAgent: "claude" }]) {
|
||||
const row = await service.select({ companyId, userId: "bob", agentId, binding: selected, ...config });
|
||||
expect(await service.probeUsage(companyId, "bob", row.connection.id, row.grant.id)).toMatchObject({ status: "ok", overage: { available: false }, limits: [{ limitReached: true }, { allowed: false }] });
|
||||
@@ -1673,7 +1673,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
it("reconnects JSONB routing without changing its identity or access", async () => {
|
||||
const routing = { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example/v1", models: [{ id: "gateway-model" }] } as const;
|
||||
const input = { provider: "openai", method: "api_key", name: "Reconnect gateway", ownership: "personal", allAgents: false, agentIds: [agentId], routing: { ...routing, models: [...routing.models] } } as const;
|
||||
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds] }, "old-gateway-credential");
|
||||
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds], routing: { ...routing, models: [...routing.models] } }, "old-gateway-credential");
|
||||
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, saved.connectionId));
|
||||
expect(stored!.config.ai).toMatchObject({ routing });
|
||||
expect(stored!.config.sourceTemplateKey).toBe("responses-api");
|
||||
@@ -1788,7 +1788,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
const runId = randomUUID();
|
||||
const vault = secretService(db);
|
||||
const definition = await vault.createUserSecretDefinition(companyId, {
|
||||
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding",
|
||||
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding", provider: "local_encrypted",
|
||||
}, { userId: "alice" });
|
||||
const ownerSecret = await vault.createCurrentUserSecretValue(companyId, "alice", {
|
||||
definitionId: definition.id, value: "fixture-original-owner-key",
|
||||
@@ -1877,8 +1877,8 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
}
|
||||
expect(subEnv.HOME).not.toBe(apiEnvValues.HOME);
|
||||
expect(subRun.identity).not.toBe(apiRun.identity);
|
||||
expect(subRun.config.model).toBe(bot.config.model);
|
||||
expect(apiRun.config.model).toBe(bot.config.model);
|
||||
expect((subRun.config as Record<string, unknown>).model).toBe(bot.config.model);
|
||||
expect((apiRun.config as Record<string, unknown>).model).toBe(bot.config.model);
|
||||
expect(bot).toEqual(original);
|
||||
expect(aiConnectionBindingSchema.parse(bot.binding)).toEqual(bot.binding);
|
||||
} finally { await Promise.all([subRun.cleanup(), apiRun.cleanup()]); }
|
||||
@@ -2005,16 +2005,16 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
const shared = await create("alice", "Engineering", "shared");
|
||||
const sharedBinding = { ...binding, mode: "shared" as const, ...shared };
|
||||
const tools = toolAccessService(db);
|
||||
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { userId: "alice" });
|
||||
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { actorType: "user", actorId: "alice" });
|
||||
const rejection = await service.select({ ...input, userId: "bob", binding: sharedBinding }).catch(error => error);
|
||||
expect(rejection).toMatchObject({ status: 403, message: "This credential is not shared with the responsible user" });
|
||||
expect(readAiConnectionConfigurationFailure(rejection)).toBe("ai_connection_credential_not_shared");
|
||||
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(false);
|
||||
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { userId: "alice" });
|
||||
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { actorType: "user", actorId: "alice" });
|
||||
expect((await service.select({ ...input, userId: "bob", binding: sharedBinding })).grant.id).toBe(shared.grantId);
|
||||
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(true);
|
||||
await expect(service.select({ ...input, userId: "alice", binding: sharedBinding })).rejects.toThrow("not shared");
|
||||
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { userId: "alice" });
|
||||
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { actorType: "user", actorId: "alice" });
|
||||
for (const userId of ["alice", "bob"]) {
|
||||
expect((await service.select({ ...input, userId, binding: sharedBinding })).grant.id).toBe(shared.grantId);
|
||||
}
|
||||
@@ -2062,7 +2062,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
const ae = a.config.env as Record<string,string>, be = b.config.env as Record<string,string>;
|
||||
expect(ae.ANTHROPIC_API_KEY).toBe("fixture-Alice second"); expect(be.ANTHROPIC_API_KEY).toBe("fixture-Bob first");
|
||||
expect(ae.HOME).not.toBe(be.HOME); expect(a.identity).not.toBe(b.identity);
|
||||
expect(a.config.model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
|
||||
expect((a.config as Record<string, unknown>).model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
|
||||
await Promise.all([a.cleanup(), b.cleanup()]); await expect(access(ae.HOME)).rejects.toThrow();
|
||||
});
|
||||
it("blocks missing identity, incompatible providers, and cross-company explicit selections", async () => {
|
||||
@@ -2585,7 +2585,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
const selectionInput = { ...input, agentId: futureAgentId, userId: "alice", binding: selected };
|
||||
await expect(service.select(selectionInput)).rejects.toThrow("not permitted for this agent");
|
||||
const run = await prepareManagedAiRuntime(db, { companyId, agentId: futureAgentId, responsibleUserId: "alice", adapterType: "claude_local", binding: selected, config: { cwd: home, model: "same-model" }, allowUninstalledShared: true });
|
||||
expect(run.config.model).toBe("same-model");
|
||||
expect((run.config as Record<string, unknown>).model).toBe("same-model");
|
||||
await run.cleanup();
|
||||
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
|
||||
await expect(service.select({ ...selectionInput, allowUninstalledShared: true })).rejects.toThrow("not shared with the responsible user");
|
||||
@@ -2629,7 +2629,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: "alice", contextSnapshot: { issueId } });
|
||||
interactionId = (await connectionIntentService(db).requestForRunAuthFailure(runId))!.interactionId!;
|
||||
}
|
||||
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn(async () => ({ exitCode: 0, stdout: "", stderr: "", timedOut: false })) } } as const;
|
||||
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn(async () => ({ exitCode: 0, signal: null, stdout: "", stderr: "", timedOut: false, pid: null, startedAt: null })) } } as const;
|
||||
const resolveTarget = vi.spyOn(targetModule, "resolveEnvironmentExecutionTarget").mockResolvedValue(target);
|
||||
const release = vi.fn(async () => undefined);
|
||||
const acquire = vi.fn(async () => ({ lease: { id: randomUUID(), provider: "daytona", providerLeaseId: "test-sandbox", metadata: {} }, leaseContext: {} }));
|
||||
|
||||
@@ -52,7 +52,7 @@ it("reconnect detaches an indexed credential without changing unadopted legacy a
|
||||
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "delegated", connectionId: connection.id, grantId: connection.grantId } as const, config: {}, allowLegacyValidation: true };
|
||||
const before = await prepareManagedAiRuntime(db, input);
|
||||
await before.cleanup();
|
||||
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
|
||||
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { consumerType: "agent", consumerId: agentId, responsibleUserId: owner, actorType: "system" });
|
||||
expect((await resolve())?.value).toBe("fixture-original");
|
||||
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
|
||||
expect((await resolve())?.value).toBe("fixture-original");
|
||||
@@ -155,7 +155,7 @@ it("isolates sign-in and refresh from the host, survives restart, and completes
|
||||
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
|
||||
const next = await prepareManagedAiRuntime(db, input);
|
||||
expect(JSON.parse(await readFile(path.join(String(next.config.env.CODEX_HOME), "auth.json"), "utf8")).tokens.refresh_token).toBe("refresh-rotated-independent");
|
||||
expect(next.config.model).toBe(input.config.model);
|
||||
expect((next.config as Record<string, unknown>).model).toBe(input.config.model);
|
||||
await next.cleanup();
|
||||
// Cancellation after successful completion must not revoke the saved account.
|
||||
await login.cancel(companyId, owner, attempt.sessionId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { access, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import {
|
||||
agents, authUsers, closeRegisteredClients, companies, companyMemberships, companySecrets, companySecretVersions,
|
||||
connectionGrants, createDb, userSecretDefinitions,
|
||||
@@ -36,7 +36,7 @@ describeDatabase("Google AI connection authority with real PostgreSQL", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let embedded: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let isolatedHome: ReturnType<typeof createIsolatedTestHome>;
|
||||
let request: ReturnType<typeof vi.spyOn<typeof globalThis, "fetch">>;
|
||||
let request: MockInstance<typeof fetch>;
|
||||
|
||||
beforeAll(async () => {
|
||||
isolatedHome = createIsolatedTestHome();
|
||||
|
||||
@@ -190,7 +190,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
it("wakes from batch-imported PR work products", async () => {
|
||||
const target = await issue(), approval = await pending(target.id);
|
||||
await start(); await quiet();
|
||||
await workProductService(db).createManyForImport([{ companyId, issueId: target.id, type: "pull_request", provider: "github", title: "Imported PR", status: "open", reviewState: "none", isPrimary: false, healthStatus: "unknown" }]);
|
||||
await workProductService(db).createManyForImport([{ companyId, issueId: target.id, projectId: null, type: "pull_request", provider: "github", externalId: null, title: "Imported PR", url: null, status: "open", reviewState: "none", isPrimary: false, healthStatus: "unknown", summary: null, metadata: null, executionWorkspaceId: null, runtimeServiceId: null, createdByRunId: null, sourceTrust: null }]);
|
||||
await seeded(approval.id); await quiet();
|
||||
});
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
const rows = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "in_review", assigneeAgentId: agentId, updatedAt: old() }))).returning();
|
||||
await db.insert(issueThreadInteractions).values(rows.map(row => ({ companyId, issueId: row.id,
|
||||
kind: "request_confirmation", addresseeAgentId: agentId, payload: { version: 1, prompt: "Agent verdict" } })));
|
||||
kind: "request_confirmation" as const, addresseeAgentId: agentId, payload: { version: 1 as const, prompt: "Agent verdict" } })));
|
||||
statements.length = 0;
|
||||
const page = await attentionRetentionService(db).load({ companyId, sourceKind: "review" });
|
||||
expect(page.rows).toEqual([]); expect(page.complete).toBe(false);
|
||||
@@ -278,11 +278,11 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
it("batches 100 blocker graphs with exact terminal timestamps and fresh terminal visibility", async () => {
|
||||
const roots = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "blocked", updatedAt: old(), blockedTransitionAt: old(),
|
||||
unblockDescriptor: { owner: "board", action: "Pick an owner" } }))).returning();
|
||||
unblockDescriptor: { owner: "board" as const, action: "Pick an owner" } }))).returning();
|
||||
const terminals = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "backlog", assigneeAgentId: agentId, updatedAt: old() }))).returning();
|
||||
await db.insert(issueRelations).values(roots.map((root, index) => ({ companyId,
|
||||
issueId: terminals[index].id, relatedIssueId: root.id, type: "blocks" })));
|
||||
issueId: terminals[index].id, relatedIssueId: root.id, type: "blocks" as const })));
|
||||
await db.execute(sql`update issues set updated_at = updated_at + interval '0.000321 seconds' where id = ${terminals[99].id}::uuid`);
|
||||
const service = attentionRetentionService(db), scope = { companyId, sourceKind: "blocker_attention" as const };
|
||||
statements.length = 0;
|
||||
@@ -318,7 +318,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
{ companyId, issueId: middle.id, relatedIssueId: root.id, type: "blocks" },
|
||||
{ companyId, issueId: terminal.id, relatedIssueId: middle.id, type: "blocks" },
|
||||
...heavyNodes.map((node, index) => ({ companyId, issueId: node.id,
|
||||
relatedIssueId: limit === "nodes" || index === 0 ? heavy.id : heavyNodes[index - 1].id, type: "blocks" })),
|
||||
relatedIssueId: limit === "nodes" || index === 0 ? heavy.id : heavyNodes[index - 1].id, type: "blocks" as const })),
|
||||
]);
|
||||
const owner = issueService(db);
|
||||
const expected = (await owner.listBlockerAttention(companyId, [root])).get(root.id)!;
|
||||
@@ -350,7 +350,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
const cycle = await insertIssue("blocked");
|
||||
const leaf = await insertIssue("backlog", { assigneeAgentId: agentId });
|
||||
await db.insert(issueRelations).values([
|
||||
...chain.map((node, index) => ({ companyId, issueId: node.id, relatedIssueId: index ? chain[index - 1].id : root.id, type: "blocks" })),
|
||||
...chain.map((node, index) => ({ companyId, issueId: node.id, relatedIssueId: index ? chain[index - 1].id : root.id, type: "blocks" as const })),
|
||||
{ companyId, issueId: leaf.id, relatedIssueId: cycle.id, type: "blocks" },
|
||||
{ companyId, issueId: cycle.id, relatedIssueId: leaf.id, type: "blocks" },
|
||||
]);
|
||||
@@ -378,7 +378,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
});
|
||||
const listed = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
|
||||
expect(listed).toHaveLength(roots.length);
|
||||
for (const root of listed) expect(root.blockerAttention).toMatchObject({ state: "needs_attention", terminalBlockerIssueId: root.id });
|
||||
for (const root of listed) expect((root as typeof root & { blockerAttention?: unknown }).blockerAttention).toMatchObject({ state: "needs_attention", terminalBlockerIssueId: root.id });
|
||||
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
|
||||
const visible = new Set(feed.items.filter(item => item.sourceKind === "blocker_attention").map(item => item.subject.id));
|
||||
expect(visible).toEqual(new Set(roots.map(root => root.id)));
|
||||
@@ -457,7 +457,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
await db.update(issues).set({ executionRunId: run.id }).where(eq(issues.id, child.id));
|
||||
} else if (kind === "confirmation") {
|
||||
await db.insert(issueThreadInteractions).values({ companyId, issueId: child.id, kind: "request_confirmation", status: "pending", payload: { prompt: "Approve?" } });
|
||||
await db.insert(issueThreadInteractions).values({ companyId, issueId: child.id, kind: "request_confirmation", status: "pending", payload: { version: 1, prompt: "Approve?" } });
|
||||
}
|
||||
parameterCounts.length = 0;
|
||||
const batch = await issueService(db).listBlockerAttentionBatch(companyId, [root]);
|
||||
@@ -495,7 +495,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
await db.update(issues).set({ executionRunId: run.id }).where(eq(issues.id, live.id));
|
||||
} else if (kind === "confirmation") await db.insert(issueThreadInteractions).values({ companyId, issueId: live.id,
|
||||
kind: "request_confirmation", status: "pending", payload: { prompt: "Still waiting" } });
|
||||
kind: "request_confirmation", status: "pending", payload: { version: 1, prompt: "Still waiting" } });
|
||||
}
|
||||
statements.length = 0; parameterCounts.length = 0;
|
||||
captureBlockerPlan = kind === "completed";
|
||||
@@ -687,13 +687,13 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
let query!: Parameters<typeof db.execute>[0], returned = 0;
|
||||
await db.transaction(async tx => {
|
||||
const execute = tx.execute.bind(tx);
|
||||
vi.spyOn(tx, "execute").mockImplementation(async statement => {
|
||||
vi.spyOn(tx, "execute").mockImplementation((async (statement: Parameters<typeof execute>[0]) => {
|
||||
const result = await execute(statement);
|
||||
if (new PgDialect().sqlToQuery(statement as SQL).sql.includes("with recursive affected")) {
|
||||
query = statement; returned = result.length;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}) as unknown as typeof execute);
|
||||
await notifyIssueAttentionRetentionWork(tx, { companyId, issueId: leaf.id });
|
||||
});
|
||||
expect(returned).toBe(101);
|
||||
@@ -804,7 +804,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
const [decision] = await db.insert(decisions).values({ companyId, originAgentId: agentId, originIssueId: origin.id, originRunId: run.id,
|
||||
title: "Decision", body: "Choose", options: [], signedSpec: "fixture", targetSnapshots: {}, expiresAt: new Date(Date.now() + 86_400_000), updatedAt: old() }).returning();
|
||||
const [recovery] = await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: origin.id, kind: "execution", ownerType: "board", cause: "fixture", fingerprint: "fixture", nextAction: "Choose", updatedAt: old() }).returning();
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, updatedAt: old() }).returning();
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { version: 1, questions: [] }, updatedAt: old() }).returning();
|
||||
await start(); await quiet();
|
||||
await issueService(db).update(origin.id, { hiddenAt: null });
|
||||
for (const [kind, id] of [["decision", decision.id], ["recovery_action", recovery.id], ["failed_run", run.id], ["issue_thread_interaction", interaction.id]]) await archived(kind, id);
|
||||
@@ -933,10 +933,10 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
await db.insert(decisions).values({ companyId, originAgentId: agentId, originIssueId: origin.id, originRunId: run.id,
|
||||
title: "Decision", body: "Choose", options: [], signedSpec: "fixture", targetSnapshots: {}, expiresAt: new Date(Date.now() + 86_400_000), updatedAt: old() });
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, addresseeAgentId: agentId, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, addresseeUserId: "board", updatedAt: old() },
|
||||
{ companyId, issueId: hidden.id, kind: "ask_user_questions", payload: { questions: [] }, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { version: 1, questions: [] }, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { version: 1, questions: [] }, addresseeAgentId: agentId, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { version: 1, questions: [] }, addresseeUserId: "board", updatedAt: old() },
|
||||
{ companyId, issueId: hidden.id, kind: "ask_user_questions", payload: { version: 1, questions: [] }, updatedAt: old() },
|
||||
]);
|
||||
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: origin.id, kind: "execution", ownerType: "board", cause: "fixture", fingerprint: "fixture", nextAction: "Choose", updatedAt: old() });
|
||||
const [policy] = await db.insert(budgetPolicies).values({ companyId, scopeType: "company", scopeId: companyId, windowKind: "calendar_month_utc", amount: 100 }).returning();
|
||||
@@ -1024,16 +1024,16 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
{ companyId, name: "Unhealthy", role: "engineer", status: "active" },
|
||||
]).returning();
|
||||
await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, unhealthy.id));
|
||||
const common = { companyId, issueId: origin.id, kind: "request_confirmation", payload: { prompt: "Approve?" }, createdAt: old(), updatedAt: old() };
|
||||
const common = { companyId, issueId: origin.id, kind: "request_confirmation", payload: { version: 1, prompt: "Approve?" }, createdAt: old(), updatedAt: old() };
|
||||
const [older, newest, independent] = await db.insert(issueThreadInteractions).values([
|
||||
common, { ...common, createdAt: new Date(old().getTime() + 1000) },
|
||||
{ ...common, payload: { prompt: "Independent", qualityReview: { artifactKey: "copy" } } },
|
||||
{ ...common, createdAt: new Date(), addresseeUserId: "board" },
|
||||
{ ...common, addresseeAgentId: agentId },
|
||||
{ ...common, issueId: hidden.id },
|
||||
{ ...common, issueId: conversation.id, kind: "ask_user_questions", payload: { questions: [] } },
|
||||
{ ...common, issueId: conversation.id, kind: "ask_user_questions", payload: { version: 1, questions: [] } },
|
||||
{ ...common, issueId: conversation.id, addresseeAgentId: paused.id },
|
||||
{ ...common, issueId: conversation.id, addresseeAgentId: unhealthy.id, kind: "ask_user_questions", payload: { questions: [] } },
|
||||
{ ...common, issueId: conversation.id, addresseeAgentId: unhealthy.id, kind: "ask_user_questions", payload: { version: 1, questions: [] } },
|
||||
] as typeof issueThreadInteractions.$inferInsert[]).returning();
|
||||
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
|
||||
const expected = [...new Set(feed.items.map(item => `${item.sourceKind}:${item.subject.id}`))].sort();
|
||||
@@ -1048,7 +1048,7 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
it("excludes maintained reviews until a pending approval creates human attention", async () => {
|
||||
const row = await insertIssue("in_review", { assigneeAgentId: agentId });
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({ companyId, issueId: row.id,
|
||||
kind: "request_confirmation", addresseeAgentId: agentId, payload: { prompt: "Agent verdict" } }).returning();
|
||||
kind: "request_confirmation", addresseeAgentId: agentId, payload: { version: 1, prompt: "Agent verdict" } }).returning();
|
||||
expect((await loadAll()).some(entry => entry.sourceKind === "review")).toBe(false);
|
||||
const [approval] = await db.insert(approvals).values({ companyId, type: "hire_agent", payload: {}, status: "pending" }).returning();
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: row.id, approvalId: approval.id });
|
||||
|
||||
@@ -1545,8 +1545,8 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATQ-1", title: "Review copy and visual", status: "in_review", assigneeAgentId: workerId });
|
||||
const ids = [randomUUID(), randomUUID(), randomUUID(), randomUUID()];
|
||||
await db.insert(issueThreadInteractions).values(ids.map((id, index) => ({
|
||||
id, companyId, issueId, kind: "request_confirmation", status: "pending", continuationPolicy: "wake_assignee", createdByAgentId: workerId,
|
||||
payload: { version: 1, prompt: "Approve output?", ...(index < 2 ? { qualityReview: { key: index === 0 ? "copy" : "visual", requirementHash: "a".repeat(64) } } : {}) },
|
||||
id, companyId, issueId, kind: "request_confirmation" as const, status: "pending" as const, continuationPolicy: "wake_assignee" as const, createdByAgentId: workerId,
|
||||
payload: { version: 1 as const, prompt: "Approve output?", ...(index < 2 ? { qualityReview: { key: index === 0 ? "copy" : "visual", requirementHash: "a".repeat(64) } } : {}) },
|
||||
createdAt: new Date(Date.UTC(2026, 6, 9, 12, index)),
|
||||
})));
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
|
||||
@@ -18,7 +18,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
db = createDb(database.connectionString, { maxConnections: 1 });
|
||||
client = db.$client;
|
||||
client.options.transform.value.from = (value, column) => {
|
||||
if (recording && column.type === 3802 && value !== null) jsonBytes.push(Buffer.byteLength(JSON.stringify(value)));
|
||||
if (recording && column?.type === 3802 && value !== null) jsonBytes.push(Buffer.byteLength(JSON.stringify(value)));
|
||||
return value;
|
||||
};
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Request } from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess, hasCompanyAccess } from "../routes/authz.js";
|
||||
@@ -9,7 +10,7 @@ function makeReq(input: {
|
||||
return {
|
||||
method: input.method ?? "GET",
|
||||
actor: input.actor,
|
||||
} as Express.Request;
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe("assertCompanyAccess", () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Request } from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildActorSecretContext } from "../routes/authz.js";
|
||||
|
||||
function makeReq(actor: Express.Request["actor"]) {
|
||||
return { method: "POST", actor } as Express.Request;
|
||||
return { method: "POST", actor } as unknown as Request;
|
||||
}
|
||||
|
||||
describe("buildActorSecretContext", () => {
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("POST /bootstrap/claim", () => {
|
||||
expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
it.each<[Record<string, unknown>, string]>([
|
||||
[{ type: "none", source: "none" }, "anonymous caller"],
|
||||
[{ type: "agent", source: "agent_key", agentId: "agent-1" }, "agent key"],
|
||||
[{ type: "board", source: "board_key", userId: "user-1" }, "board API key"],
|
||||
|
||||
@@ -214,7 +214,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
if (methodKey.endsWith("oauth")) await f.signIn(connected.connectionId);
|
||||
else await f.service.reconnectGalleryApp(connected.connectionId, f.company.id, { credentialValues: { "credentials.authorization": f.accessToken } }, actor);
|
||||
const afterReconnect = await f.service.refreshCatalog(connected.connectionId, actor, { enableAllByDefault: true });
|
||||
expect(afterReconnect.connection.config.quarantineNewEntries).toBe(true);
|
||||
expect(afterReconnect.connection.config?.quarantineNewEntries).toBe(true);
|
||||
expect(afterReconnect.connection.transportConfig.quarantineNewEntries).toBe(true);
|
||||
expect(afterReconnect.catalog.filter(entry => ["sql_query", "provider_added_tool"].includes(entry.toolName)).every(entry => entry.status === "quarantined")).toBe(true);
|
||||
f.request.mockClear();
|
||||
|
||||
@@ -1165,10 +1165,10 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
|
||||
const resizePath = path.replace("/viewer", "/viewport");
|
||||
const resizeSpy = vi
|
||||
.spyOn(browserUseViewports, "resize")
|
||||
.mockResolvedValue({ preset: "phone" });
|
||||
.mockResolvedValue({ preset: "phone", applied: true });
|
||||
expect(
|
||||
(await http(app).post(resizePath).send({ preset: "phone" })).body,
|
||||
).toEqual({ preset: "phone" });
|
||||
).toEqual({ preset: "phone", applied: true });
|
||||
expect(resizeSpy).toHaveBeenCalledTimes(1);
|
||||
const viewerId = randomUUID();
|
||||
resizeSpy.mockResolvedValueOnce({
|
||||
@@ -1457,7 +1457,7 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
|
||||
});
|
||||
it.each(["default_allow", "tool_name"])("retains valid %s profile authorization at browser admission", async selector => {
|
||||
const f = await fixture();
|
||||
await db.update(toolProfiles).set({ status: "inactive" }).where(eq(toolProfiles.companyId, f.company.id));
|
||||
await db.update(toolProfiles).set({ status: "disabled" }).where(eq(toolProfiles.companyId, f.company.id));
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId: f.company.id, name: "Scoped browser access",
|
||||
profileKey: randomUUID(), defaultAction: selector === "default_allow" ? "allow" : "deny" }).returning();
|
||||
await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: profile.id, targetType: "agent", targetId: f.agent.id });
|
||||
|
||||
@@ -35,7 +35,7 @@ afterEach(async () => {
|
||||
});
|
||||
function setup() {
|
||||
const execute = vi.fn(async () => [{ xid: "42", status: "committed" }]);
|
||||
const owner = installDatabaseWorkSignals({ execute, async transaction(callback: (tx: unknown) => Promise<unknown>) {
|
||||
const owner = installDatabaseWorkSignals({ execute, async transaction(callback: (tx: unknown) => Promise<unknown>): Promise<unknown> {
|
||||
return callback({ execute, transaction: owner.transaction });
|
||||
} });
|
||||
const onError = vi.fn();
|
||||
|
||||
@@ -83,8 +83,8 @@ describePostgres("bundled image registration in PostgreSQL", () => {
|
||||
const [companyA, companyB, unconfigured] = await db.insert(companies).values([
|
||||
{ name: "Company A", issuePrefix: "CFA" }, { name: "Company B", issuePrefix: "CFB" }, { name: "Unconfigured", issuePrefix: "CFC" },
|
||||
]).returning();
|
||||
await registry.upsertConfig(row.id, companyA!.id, { configJson: { token: "only-A" } });
|
||||
await registry.upsertConfig(row.id, companyB!.id, { configJson: { token: "only-B" } });
|
||||
await registry.upsertConfig(row.id, companyA!.id, { companyId: companyA!.id, configJson: { token: "only-A" } });
|
||||
await registry.upsertConfig(row.id, companyB!.id, { companyId: companyB!.id, configJson: { token: "only-B" } });
|
||||
row = (await registry.getById(row.id))!;
|
||||
rmSync(old.root, { recursive: true });
|
||||
const original = { ...row };
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("current image plugin runtime loader", () => {
|
||||
fs.writeFileSync(service, (await transform(source, { loader: "ts", format: "esm", target: "node24" })).code);
|
||||
const entry = path.join(root, "server", "dist", "probe.js");
|
||||
fs.writeFileSync(entry, 'import { resolveBundledPluginRuntimeLoader } from "./services/bundled-plugin-runtime.js"; process.stdout.write(resolveBundledPluginRuntimeLoader());');
|
||||
const bundled = await bundleRuntime({ entry, outdir: path.join(root, "server", "dist-runtime") });
|
||||
const bundled = await bundleRuntime({ entry, outdir: path.join(root, "server", "dist-runtime"), maintenanceEntry: undefined, ephemeralMigrationEntry: undefined });
|
||||
expect(execFileSync(process.execPath, [bundled.outfile], { encoding: "utf8" })).toBe(loader);
|
||||
});
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ function makeDeps(overrides?: {
|
||||
const installedRows = new Map(
|
||||
Object.entries(rows).map(([key, row]) => [key, normalize(row)] as const),
|
||||
);
|
||||
const installPlugin = vi.fn(async ({ localPath }: { localPath: string }) => {
|
||||
const installPlugin = vi.fn(async ({ localPath }: { localPath: string }): Promise<{ manifest: { id: string } | null }> => {
|
||||
if (overrides?.installError) throw overrides.installError;
|
||||
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) =>
|
||||
localPath.endsWith(candidate.relativePath),
|
||||
|
||||
@@ -76,6 +76,7 @@ describeEmbeddedPostgres("cases routes", () => {
|
||||
const storage: StorageService = {
|
||||
provider: "local_disk",
|
||||
async putFile(input) {
|
||||
if (!Buffer.isBuffer(input.body)) throw new Error("Expected bounded bytes");
|
||||
return {
|
||||
provider: "local_disk",
|
||||
objectKey: `${input.namespace}/${randomUUID()}`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -517,9 +517,9 @@ describeEmbeddedPostgres(
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "suggest_tasks", payload: { version: 1, tasks: [{ clientKey: "docs", title: "Review the migration", description: "Verify preserved configuration" }] }, expected: "Review the migration" },
|
||||
{ kind: "request_item_verdicts", payload: { version: 1, prompt: "Review each change", items: [{ id: "docs", label: "Migration notes", description: "Verify preserved configuration" }] }, expected: "Migration notes" },
|
||||
] as const)("delivers and settles $kind reviews in Slack", async ({ kind, payload, expected }) => {
|
||||
{ kind: "suggest_tasks" as const, payload: { version: 1 as const, tasks: [{ clientKey: "docs", title: "Review the migration", description: "Verify preserved configuration" }] }, expected: "Review the migration" },
|
||||
{ kind: "request_item_verdicts" as const, payload: { version: 1 as const, prompt: "Review each change", items: [{ id: "docs", label: "Migration notes", description: "Verify preserved configuration" }] }, expected: "Migration notes" },
|
||||
])("delivers and settles $kind reviews in Slack", async ({ kind, payload, expected }) => {
|
||||
const fixture = await seedBoundIssue();
|
||||
const service = issueThreadInteractionService(db);
|
||||
const issue = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
|
||||
@@ -330,7 +330,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await db.insert(chatPublications).values(ids.map(id => ({
|
||||
companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId,
|
||||
issueId: f.issueId, idempotencyKey: `run:${id}:working:${f.endpointId}`,
|
||||
payload: { text: "Maya is working…", progressState: "working" }, state: "published",
|
||||
payload: { text: "Maya is working…", progressState: "working" as const }, state: "published" as const,
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
})));
|
||||
await db.insert(heartbeatRunEvents).values(ids.map(id => ({
|
||||
|
||||
@@ -300,7 +300,7 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
||||
const paths = [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`];
|
||||
const selections = [{ projectWorkspaceId: f.projectWorkspaceId }, { executionWorkspaceId: executionWorkspace.id },
|
||||
const selections: Array<Record<string, string>> = [{ projectWorkspaceId: f.projectWorkspaceId }, { executionWorkspaceId: executionWorkspace.id },
|
||||
{ inheritExecutionWorkspaceFromIssueId: f.blockerId }];
|
||||
const create = async (path: string, selection: Record<string, string>) => {
|
||||
const response = await fetch(`${server.apiUrl}${path}`, {
|
||||
|
||||
@@ -37,7 +37,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
{ id: closedIssueId, companyId, title: "Closed" },
|
||||
{ id: foreignIssueId, companyId: foreignCompanyId, title: "Foreign" },
|
||||
]);
|
||||
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Slack", type: "native" });
|
||||
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Slack", type: "chat" });
|
||||
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Slack", uid: connectionId, transport: "chat_sdk", connectionPurpose: "channel" });
|
||||
await db.insert(chatEndpoints).values({ id: endpointId, companyId, connectionId, provider: "slack", publicId: endpointId, assignedAgentId: agentId });
|
||||
await db.insert(chatConversations).values([
|
||||
@@ -74,7 +74,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
(id, company_id, agent_id, status, scope_kind, issue_id, native_issue_id, context_snapshot, updated_at)
|
||||
values ($1, $2, $3, 'running', $4, $5, $6, $7::jsonb, $8)`,
|
||||
[runId, companyId, agentId, "runIssueId" in item ? "issue" : "company",
|
||||
"runIssueId" in item ? item.runIssueId : null, "nativeIssueId" in item ? item.nativeIssueId : null,
|
||||
"runIssueId" in item ? item.runIssueId ?? null : null, "nativeIssueId" in item ? item.nativeIssueId ?? null : null,
|
||||
JSON.stringify(item.context), (item.updatedAt ?? since).toISOString()]);
|
||||
if (item.matches) expectedIds.push(runId);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createHeartbeatQueueTestDependencies } from "./helpers/heartbeat-queue.
|
||||
import { CHAT_CONTROL_RECOVERY_ADMISSION_KEY, CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE } from "../services/chat-control-recovery-stop.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { commitNativeStatusDecision, dispatchPendingNativeStatusEffects } from "../services/native-runtime/status-decision-committer.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "../services/native-runtime/status-arbiter.js";
|
||||
import { claimQueuedNativeReviewRun } from "../services/native-runtime/native-review-dispatch.js";
|
||||
import { createChatPublicationResolution } from "../services/chat-channels/publications/resolution.js";
|
||||
import { createChatPublicationWorker } from "../services/chat-channels/publications/worker.js";
|
||||
@@ -58,9 +59,12 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||
function chatContextSnapshot(scope = f) {
|
||||
return { issueId: scope.issueId, source: "chat:slack", endpointId: scope.endpointId, wakeCommentId: scope.commentId };
|
||||
}
|
||||
function runValues(scope = f, patch: Partial<typeof heartbeatRuns.$inferInsert> = {}) {
|
||||
return { id: randomUUID(), companyId: scope.companyId, agentId: scope.agentId, responsibleUserId: scope.userId, status: "running", updatedAt: new Date(),
|
||||
contextSnapshot: { issueId: scope.issueId, source: "chat:slack", endpointId: scope.endpointId, wakeCommentId: scope.commentId }, ...patch };
|
||||
contextSnapshot: chatContextSnapshot(scope), ...patch };
|
||||
}
|
||||
async function run(scope = f, patch: Partial<typeof heartbeatRuns.$inferInsert> = {}) {
|
||||
const values = runValues(scope, patch);
|
||||
@@ -155,7 +159,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
let admittedId: string | undefined;
|
||||
await adapter.withIssueExecutionLock({ companyId: f.companyId, runId: finishing.id, now: new Date() }, async (locked, ports) => {
|
||||
const input = { companyId: f.companyId, issue: locked.primaryIssue, finishingRun: locked.run,
|
||||
contextSnapshot: runValues().contextSnapshot, responsibleUserId: f.userId, sessionBefore: null, now: new Date(),
|
||||
contextSnapshot: chatContextSnapshot(), responsibleUserId: f.userId, sessionBefore: null, now: new Date(),
|
||||
};
|
||||
const agent = { id: f.agentId, companyId: f.companyId, name: "Maya", invokable: true };
|
||||
if (kind === "deferred") {
|
||||
@@ -186,7 +190,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
dependencies.getSchedulingSuppression.mockResolvedValue({ suppressed: true, reason: "task_drain" });
|
||||
await start(); await flush(); await quiet();
|
||||
const admitted = await service.enqueueWakeup(f.agentId, { source: "on_demand", reason: "issue_commented",
|
||||
contextSnapshot: runValues().contextSnapshot, requestedByActorType: "user", requestedByActorId: f.userId,
|
||||
contextSnapshot: chatContextSnapshot(), requestedByActorType: "user", requestedByActorId: f.userId,
|
||||
});
|
||||
expect(admitted).toMatchObject({ status: "queued", companyId: f.companyId });
|
||||
await flush();
|
||||
@@ -268,7 +272,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
dependencies.getSchedulingSuppression.mockResolvedValue({ suppressed: true, reason: "task_drain" });
|
||||
await start(); await flush(); await quiet();
|
||||
const admitted = await service.enqueueWakeup(replacement.id, { source: "on_demand", reason: "issue_commented",
|
||||
contextSnapshot: runValues().contextSnapshot, requestedByActorType: "user", requestedByActorId: f.userId,
|
||||
contextSnapshot: chatContextSnapshot(), requestedByActorType: "user", requestedByActorId: f.userId,
|
||||
});
|
||||
expect(admitted).toMatchObject({ agentId: replacement.id, status: "queued" });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, old.id)))[0])
|
||||
@@ -326,7 +330,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await db.transaction(async tx => {
|
||||
await createWakeAdmissionWriter().coalesceIntoActiveExecutionRun(
|
||||
createAdmissionTransactionScope(f.companyId, tx as unknown as typeof db), {
|
||||
companyId: f.companyId, activeExecutionRunId: seeded.id, mergedContextSnapshot: runValues().contextSnapshot,
|
||||
companyId: f.companyId, activeExecutionRunId: seeded.id, mergedContextSnapshot: chatContextSnapshot(),
|
||||
agentId: f.agentId, source: "on_demand", triggerDetail: null, payload: { issueId: f.issueId },
|
||||
requestedByActorType: "user", requestedByActorId: f.userId, idempotencyKey: null,
|
||||
});
|
||||
@@ -411,11 +415,11 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
});
|
||||
return { companyId: f.companyId, issueId: f.issueId, runId: native.row.id, assessmentId,
|
||||
priorStatus: "in_progress", priorStatusVersion: 0, priorDecisionId: null,
|
||||
decision: { policyVersion: "phase6-v1", statusAction: "preserve" as const, toStatus: "in_progress" as const,
|
||||
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "preserve" as const, toStatus: "in_progress" as const,
|
||||
reasonCode: "live_continuation_registered", unblockDescriptor: null,
|
||||
effects: [{ kind: "enqueue_continuation" as const, agentId: f.agentId, continuationKind: "same_agent" as const,
|
||||
summary: "Continue responding", idempotencyKey: `continuation:${native.row.id}` }],
|
||||
},
|
||||
} satisfies NativeStatusDecision,
|
||||
};
|
||||
}
|
||||
it("wakes from a governed native reviewer claim and leaves repeated claims quiet", async () => {
|
||||
@@ -576,7 +580,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
const delivery = questionResponseDeliveryService(db, { heartbeat: {
|
||||
cancelRun: async () => { throw new Error("The source is already terminal"); },
|
||||
wakeup: async (_agentId, options) => {
|
||||
const values = runValues(f, { status: "queued", contextSnapshot: options.contextSnapshot });
|
||||
const values = runValues(f, { status: "queued", contextSnapshot: options?.contextSnapshot });
|
||||
const [target] = await db.insert(heartbeatRuns).values(values).returning();
|
||||
targetId = target.id;
|
||||
await flush();
|
||||
@@ -714,6 +718,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
let beforeReceiptKeys: string[] | undefined;
|
||||
await start(); await flush(); await quiet();
|
||||
const deliver = questionResponseDeliveryService(db, { heartbeat: {
|
||||
cancelRun: async () => { throw new Error("The source is already terminal"); },
|
||||
wakeup: async () => {
|
||||
const [target] = await db.insert(heartbeatRuns).values(runValues(f, {
|
||||
id: targetId, status: "queued", contextSnapshot: { issueId: f.issueId },
|
||||
@@ -731,7 +736,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
});
|
||||
// Controlled transport/authorization ports; actual attempt lease, receipt
|
||||
// transaction, question delivery receipt and milestone worker use PostgreSQL.
|
||||
const worker = createChatPublicationWorker(db, {
|
||||
const worker = createChatPublicationWorker(db, ({
|
||||
CAPABILITIES, settleGitHubAutomaticPublication: async () => false,
|
||||
ensureDurablePublicationTransport: async publication => publication,
|
||||
tryProcessTeamsFilePublication: async () => false,
|
||||
@@ -749,7 +754,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
postSafePublication: post, commitTaskControlCompletion: async () => {}, receiptReactionCompletionRunId: async () => null,
|
||||
finalizePublicationFailure: async (_publication, error) => { errors.push(error); return false; },
|
||||
redactError: String,
|
||||
} as Parameters<typeof createChatPublicationWorker>[1]);
|
||||
} satisfies Partial<Parameters<typeof createChatPublicationWorker>[1]>) as unknown as Parameters<typeof createChatPublicationWorker>[1]);
|
||||
await worker.processSelectedPublication(prompt);
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect((await db.select().from(chatPublications).where(eq(chatPublications.id, prompt.id)))[0]).toMatchObject({ state: "published", providerMessageId: "published-question" });
|
||||
@@ -794,7 +799,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await quiet();
|
||||
if (resolution) {
|
||||
const targetId = randomUUID();
|
||||
await questionResponseDeliveryService(db, { heartbeat: { wakeup: async () => {
|
||||
await questionResponseDeliveryService(db, { heartbeat: { cancelRun: async () => { throw new Error("The source is already terminal"); }, wakeup: async () => {
|
||||
const [target] = await db.insert(heartbeatRuns).values(runValues(f, {
|
||||
id: targetId, status: "queued", contextSnapshot: { issueId: f.issueId },
|
||||
})).returning();
|
||||
@@ -804,11 +809,11 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect((await publications()).some(row => row.idempotencyKey.startsWith(`run:${targetId}:`))).toBe(false);
|
||||
await quiet();
|
||||
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpointId));
|
||||
const resolver = createChatPublicationResolution(db, {
|
||||
const resolver = createChatPublicationResolution(db, ({
|
||||
endpointRecord: async () => ({ endpoint, assignedAgentName: "Maya", connectionName: "Chat", applicationId: "test", credentialSecretRefs: [] }),
|
||||
withCredentialMutationLease: async (_endpoint, operation) => operation({ assertOwned: async () => {} }),
|
||||
commitTaskControlCompletion: async () => {}, processPendingPublications: async () => {},
|
||||
} as Parameters<typeof createChatPublicationResolution>[1]);
|
||||
commitTaskControlCompletion: async () => {}, processPendingPublications: async () => 0,
|
||||
} satisfies Partial<Parameters<typeof createChatPublicationResolution>[1]>) as unknown as Parameters<typeof createChatPublicationResolution>[1]);
|
||||
await resolver.resolvePublication(f.endpointId, prompt.id, resolution, f.userId);
|
||||
await flush();
|
||||
const keys = (await publications()).map(row => row.idempotencyKey);
|
||||
@@ -928,7 +933,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await start(); await flush(); expect(await publications()).toEqual([]); await quiet();
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatRunMilestones(tx, f);
|
||||
await tx.update(heartbeatRuns).set({ contextSnapshot: runValues().contextSnapshot }).where(eq(heartbeatRuns.id, row.id));
|
||||
await tx.update(heartbeatRuns).set({ contextSnapshot: chatContextSnapshot() }).where(eq(heartbeatRuns.id, row.id));
|
||||
});
|
||||
await flush(); expect(await publications()).toHaveLength(1); await quiet();
|
||||
});
|
||||
|
||||
@@ -177,6 +177,7 @@ type CapturePayload = {
|
||||
skillEntries: string[];
|
||||
claudeConfigDir: string | null;
|
||||
claudeConfigEntries?: string[];
|
||||
mcpConfigContents?: string | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
paperclipApiKey?: string | null;
|
||||
paperclipApiBridgeMode?: string | null;
|
||||
@@ -1058,7 +1059,7 @@ describe("claude execute", () => {
|
||||
const preparedCommand = path.join(remoteWorkspace, ".paperclip-runtime/claude-cli/2.1.280/bin/claude");
|
||||
const logs: string[] = [];
|
||||
const commands: string[] = [];
|
||||
const loggedCommands: string[] = [];
|
||||
const loggedCommands: Array<string | undefined> = [];
|
||||
try {
|
||||
await fs.writeFile(commandPath, (await fs.readFile(commandPath, "utf8")).replace("2.1.286", "2.1.263"));
|
||||
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||
@@ -1081,7 +1082,7 @@ describe("claude execute", () => {
|
||||
return runner.execute(input);
|
||||
} } },
|
||||
authToken: "run-jwt-token", onLog: async (_stream, chunk) => { logs.push(chunk); },
|
||||
onMeta: async meta => { commands.push(meta.command); loggedCommands.push(meta.env.PAPERCLIP_RESOLVED_COMMAND); },
|
||||
onMeta: async meta => { commands.push(meta.command); loggedCommands.push(meta.env?.PAPERCLIP_RESOLVED_COMMAND); },
|
||||
});
|
||||
if (state === "compatible") {
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("unclaimed Cloud background work", () => {
|
||||
const isEnabled = vi.fn().mockResolvedValue(false);
|
||||
const db = installDatabaseWorkSignals({ execute: vi.fn(), transaction: vi.fn() }) as unknown as Db;
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !standby, onError: vi.fn() });
|
||||
const events = createPublicMcpEvents(db, { isEnabled } as PublicMcpOAuth, vi.fn(), {
|
||||
const events = createPublicMcpEvents(db, { isEnabled } as unknown as PublicMcpOAuth, vi.fn(), {
|
||||
isBackgroundWorkEnabled: () => !standby,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -1339,7 +1339,7 @@ process.exit(1);
|
||||
|
||||
const policy = conversationMode
|
||||
? buildPaperclipTaskMarkdown({
|
||||
issue: { id: "issue-1", title: "Chat", workMode: "planning", conversationAgentId: "agent-1" },
|
||||
issue: { id: "issue-1", identifier: null, title: "Chat", workMode: "planning", conversationAgentId: "agent-1" },
|
||||
interaction: { kind: "request_confirmation", status: "rejected" },
|
||||
planReview: { status: "rejected", reason: "Revise the final note." },
|
||||
includeDescription: false,
|
||||
@@ -1437,7 +1437,7 @@ process.exit(1);
|
||||
expect(invocationPrompt).toContain(policy);
|
||||
if (resumedSession) expect(capture.prompt).not.toContain("Full description that must not replay");
|
||||
else expect(capture.prompt).toContain("Full description that must not replay");
|
||||
expect(promptMetrics.taskContextChars).toBe(resumedSession ? policy.length : `Full description that must not replay\n${policy}`.length);
|
||||
expect(promptMetrics.taskContextChars).toBe(resumedSession ? policy!.length : `Full description that must not replay\n${policy}`.length);
|
||||
if (conversationMode) {
|
||||
expect(invocationPrompt).toContain(AGENT_CHAT_DIRECTIVE);
|
||||
expect(invocationPrompt).toContain("baseRevisionId set to that latestRevisionId");
|
||||
|
||||
@@ -60,10 +60,12 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
lifecycleWorker = startAgentLifecycle(db, { ...createLifecycleDriver(db, {} as never),
|
||||
requiredPluginIds: async () => [], runPlugin: async () => "complete" }, () => true);
|
||||
});
|
||||
const lifecycleStateFor = (status: typeof agents.$inferInsert["status"]): NonNullable<typeof agents.$inferInsert["lifecycleState"]> =>
|
||||
status === "paused" || status === "pending_approval" || status === "terminated" ? status : "ready";
|
||||
async function insertAgents(input: typeof agents.$inferInsert | Array<typeof agents.$inferInsert>) {
|
||||
const rows = Array.isArray(input) ? input : [input];
|
||||
await db.insert(agents).values(rows.map(row => ({ ...row,
|
||||
lifecycleState: ["paused", "pending_approval", "terminated"].includes(row.status ?? "") ? row.status! : "ready",
|
||||
lifecycleState: lifecycleStateFor(row.status),
|
||||
lifecycleHolds: row.status === "paused" ? [row.pauseReason ?? "manual"] : [],
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const companySvc = {
|
||||
// Async-empty default (not a bare vi.fn()): every new-company import reads
|
||||
// the existing names for de-duplication, including describes that never
|
||||
// touch this mock.
|
||||
list: vi.fn(async () => []),
|
||||
list: vi.fn(async (): Promise<Array<{ name: string }>> => []),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
@@ -516,7 +516,7 @@ describe("company portability", () => {
|
||||
entryFile: "AGENTS.md",
|
||||
warnings: [],
|
||||
}));
|
||||
agentInstructionsSvc.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: Record<string, unknown> }) => ({
|
||||
agentInstructionsSvc.materializeManagedBundle.mockImplementation(async (agent: { id: string; adapterConfig: Record<string, unknown> }) => ({
|
||||
bundle: null,
|
||||
adapterConfig: {
|
||||
...agent.adapterConfig,
|
||||
|
||||
@@ -5,6 +5,10 @@ import { issueRoutes } from "../routes/issues.js";
|
||||
import { createCompanySearchRateLimiter } from "../services/company-search-rate-limit.js";
|
||||
import type { CompanySearchQuery, CompanySearchResponse } from "@paperclipai/shared";
|
||||
|
||||
async function unusedExtract(): Promise<never> {
|
||||
throw new Error("Rate limit tests do not call extract");
|
||||
}
|
||||
|
||||
function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse {
|
||||
return {
|
||||
query: query.q,
|
||||
@@ -44,7 +48,7 @@ describe("company search route rate limiting", () => {
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as never, {} as never, {
|
||||
searchService: { search },
|
||||
searchService: { search, extract: unusedExtract },
|
||||
searchRateLimiter: createCompanySearchRateLimiter({
|
||||
maxRequests: 1,
|
||||
windowMs: 60_000,
|
||||
@@ -76,7 +80,7 @@ describe("company search route rate limiting", () => {
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as never, {} as never, {
|
||||
searchService: { search },
|
||||
searchService: { search, extract: unusedExtract },
|
||||
searchRateLimiter: createCompanySearchRateLimiter({
|
||||
maxRequests: 10,
|
||||
windowMs: 60_000,
|
||||
@@ -104,7 +108,7 @@ describe("company search route rate limiting", () => {
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as never, {} as never, {
|
||||
searchService: { search },
|
||||
searchService: { search, extract: unusedExtract },
|
||||
searchRateLimiter: createCompanySearchRateLimiter({
|
||||
maxRequests: 10,
|
||||
windowMs: 60_000,
|
||||
|
||||
@@ -80,7 +80,6 @@ describeEmbeddedPostgres("company skill policy routes", () => {
|
||||
source: "agent_key",
|
||||
agentId,
|
||||
companyId: actor === "cross-company" ? otherCompanyId : companyId,
|
||||
runId: null,
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { requestHumanInputAction } from "../../../packages/paperclip-runner/src/protocol-actions/request-human-input.js";
|
||||
import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -240,7 +240,7 @@ async function createRemoteMcpToolFixture(db: ReturnType<typeof createDb>, compa
|
||||
|
||||
describe("Honcho workspace configuration", () => {
|
||||
const schema = { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id", "query"] };
|
||||
const connection = { config: { sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "team-workspace" } } } as typeof toolConnections.$inferSelect;
|
||||
const connection = { transport: "mcp_remote" as const, config: { sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "team-workspace" } } };
|
||||
it("projects the workspace and hides its argument while keeping old unconfigured tools usable", () => {
|
||||
expect(projectedConnectionToolArguments(connection, { workspace_id: "forged", query: "decisions" }, "query", schema)).toEqual({ workspace_id: "team-workspace", query: "decisions" });
|
||||
expect(projectedConnectionToolInputSchema(connection, schema, "query")).toEqual({ type: "object", properties: { query: { type: "string" } }, required: ["query"] });
|
||||
|
||||
@@ -980,7 +980,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
const access = toolAccessService(db);
|
||||
const effective = await access.getEffectiveProfilesForAgent(f.company.id, f.agent.id);
|
||||
const profile = effective.profiles.find(profile => profile.profileKey.startsWith("connection-intent:"))!;
|
||||
await access.deleteProfile(profile.id, {});
|
||||
await access.deleteProfile(profile.id, { force: false });
|
||||
expect((await access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
|
||||
for (const tool of f.catalog.slice(0, 2)) {
|
||||
const decision = await toolAccessPolicyService(db).decide({
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("connection management with production default agents", () => {
|
||||
await server.db.update(heartbeatRuns).set({ agentId: agent.id }).where(eq(heartbeatRuns.id, base.runId));
|
||||
await server.db.update(issues).set({ assigneeAgentId: agent.id, responsibleUserId: userId }).where(eq(issues.id, base.issueId));
|
||||
const key = await agentService(server.db).createApiKey(agent.id, "integration", { kind: "standard" }, { responsibleUserId: userId });
|
||||
const jwt = createLocalAgentJwt(agent.id, base.companyId, "paperclip_runner", base.runId, userId);
|
||||
const jwt = createLocalAgentJwt(agent.id, base.companyId, "paperclip_runner", base.runId, userId)!;
|
||||
const request = async (method: string, path: string, body?: unknown, token = key.token) => {
|
||||
const response = await fetch(server.apiUrl + (path.startsWith("/mcp/") ? "" : "/api") + path, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
@@ -480,7 +480,7 @@ describe("connection management with production default agents", () => {
|
||||
try {
|
||||
const [environment] = await server.db.insert(environments).values({ name: `Login fixture ${f.agentId}`, driver: "sandbox" }).returning();
|
||||
const [session] = await server.db.insert(adapterAuthSessions).values({ companyId: f.companyId, environmentId: environment.id, adapterType: "codex_local", startedByUserId: f.userId,
|
||||
publicSessionId: randomUUID(), status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000), aiConnection: { ownership: "personal", name: "Saved login", allAgents: false, agentIds: [] } }).returning();
|
||||
publicSessionId: randomUUID(), status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000), aiConnection: { provider: "openai", method: "subscription", ownership: "personal", name: "Saved login", allAgents: false, agentIds: [] } }).returning();
|
||||
const path = `/companies/${f.companyId}/adapters/codex_local/login-sessions`;
|
||||
expect(await f.request("GET", `${path}/active`)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } });
|
||||
expect(await f.request("GET", `${path}/${session.publicSessionId}`, undefined, f.jwt)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } });
|
||||
@@ -569,7 +569,7 @@ describe("connection management with production default agents", () => {
|
||||
}
|
||||
if (revocation === "other-agent-credential" || revocation === "other-agent-credential-none") {
|
||||
await server.db.update(toolConnections).set({ authKind: revocation === "other-agent-credential-none" ? "none" : "api_key", credentialPolicy: "per_agent" }).where(eq(toolConnections.id, created.body.id));
|
||||
await server.db.insert(connectionGrants).values([f.agentId, targetAgentId].map(subjectAgentId => ({ companyId: f.companyId, connectionId: created.body.id, kind: "agent" as const, subjectAgentId, status: "active" })));
|
||||
await server.db.insert(connectionGrants).values([f.agentId, targetAgentId].map(subjectAgentId => ({ companyId: f.companyId, connectionId: created.body.id, kind: "agent" as const, subjectAgentId, status: "active" as const })));
|
||||
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 403, body: { reasonCode: "test_credential_identity_mismatch" } });
|
||||
expect(calls).toBe(0);
|
||||
return;
|
||||
@@ -688,7 +688,7 @@ describe("connection management with production default agents", () => {
|
||||
expect(settled.policyExplanation?.delegatedCallerConsumedRateAdmissions).toHaveLength(1);
|
||||
const [nextRun] = await server.db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
|
||||
status: "running", responsibleUserId: f.userId, contextSnapshot: { issueId: f.issueId } }).returning();
|
||||
const nextJwt = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", nextRun.id, f.userId);
|
||||
const nextJwt = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", nextRun.id, f.userId)!;
|
||||
expect(await f.request("POST", path, body, nextJwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } });
|
||||
expect(calls).toBe(1);
|
||||
}
|
||||
@@ -732,7 +732,7 @@ describe("connection management with production default agents", () => {
|
||||
const ownBinding = await f.request("POST", `${path}/bind`, { targetType: "issue", targetId: f.issueId });
|
||||
expect(ownBinding, JSON.stringify(ownBinding)).toMatchObject({ status: 201 });
|
||||
expect(await f.request("POST", `${path}/unbind`, { targetType: "issue", targetId: f.issueId })).toMatchObject({ status: 200 });
|
||||
for (const target of [{ targetType: "project", targetId: f.projectId }, { targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) {
|
||||
for (const target of [{ targetType: "project" as const, targetId: f.projectId }, { targetType: "issue" as const, targetId: issue.id }, { targetType: "routine" as const, targetId: routine.id }, { targetType: "gateway" as const, targetId: gateway.body.id }]) {
|
||||
expect(await f.request("POST", `${path}/bind`, target), JSON.stringify(target)).toMatchObject({ status: 403 });
|
||||
const [saved] = await server.db.insert(toolProfileBindings).values({ ...target, companyId: f.companyId, profileId: profile.body.id }).returning();
|
||||
expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit retained target" })).toMatchObject({ status: 403 });
|
||||
@@ -786,7 +786,7 @@ describe("connection management with production default agents", () => {
|
||||
return originalFrom(table);
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
}) as typeof server.db.select);
|
||||
}) as unknown as typeof server.db.select);
|
||||
try {
|
||||
const path = `/companies/${f.companyId}/tools/policies`;
|
||||
const body = { name: "Bounded policy", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: Array(5000).fill(created.body.id) } };
|
||||
@@ -1414,7 +1414,7 @@ describe("connection management with production default agents", () => {
|
||||
externalConversationId: randomUUID(), externalLabel: "History" }))).returning();
|
||||
const rows = Array.from({ length: 30 }, (_, index) => ({ id: `${f.agentId.slice(0, 24)}${index.toString(16).padStart(12, "0")}`,
|
||||
companyId: f.companyId, endpointId: endpoint.body.id, conversationId: conversations[index < 3 ? 0 : 1].id,
|
||||
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: index < 3 ? "readable_history" : "private_history", state: "processed", normalizedEvent: {}, createdAt: new Date("2026-10-25T12:00:00.000Z") }));
|
||||
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "message" as const, state: "processed" as const, redactedError: index < 3 ? null : "private_history", normalizedEvent: {}, createdAt: new Date("2026-10-25T12:00:00.000Z") }));
|
||||
await server.db.insert(chatDeliveries).values(rows);
|
||||
const path = `/chat-endpoints/${endpoint.body.id}/activity?limit=25`;
|
||||
const first = await f.request("GET", path, undefined, f.jwt);
|
||||
@@ -1594,7 +1594,7 @@ describe("connection management with production default agents", () => {
|
||||
if (mode === "ask-rate") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One issuer admission", policyType: "rate_limit", selectors: { connectionId: connection.body.id, agentId: f.agentId }, priority: -1, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
||||
if (mode === "credential") {
|
||||
await server.db.update(toolConnections).set({ credentialPolicy: "per_agent" }).where(eq(toolConnections.id, connection.body.id));
|
||||
await server.db.insert(connectionGrants).values([f.agentId, target.id].map(subjectAgentId => ({ companyId: f.companyId, connectionId: connection.body.id, kind: "agent" as const, subjectAgentId, status: "active" })));
|
||||
await server.db.insert(connectionGrants).values([f.agentId, target.id].map(subjectAgentId => ({ companyId: f.companyId, connectionId: connection.body.id, kind: "agent" as const, subjectAgentId, status: "active" as const })));
|
||||
}
|
||||
const called = await rpc("tools/call", { name: tool.name, arguments: {} });
|
||||
if (isRate) {
|
||||
@@ -1896,7 +1896,7 @@ describe("connection management with production default agents", () => {
|
||||
await server.db.update(agentApiKeys).set({ responsibleUserId: null }).where(eq(agentApiKeys.id, f.key.id));
|
||||
expect((await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400);
|
||||
const g = await fixture();
|
||||
await server.db.update(agentApiKeys).set({ scopeConfig: { kind: "task_bridge", issueId: g.issueId } }).where(eq(agentApiKeys.id, g.key.id));
|
||||
await server.db.update(agentApiKeys).set({ scopeConfig: { kind: "task_bridge", parentIssueId: g.issueId } }).where(eq(agentApiKeys.id, g.key.id));
|
||||
expect((await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
|
||||
@@ -104,7 +104,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const actor = { actorType: "user" as const, actorId: "policy-reviewer" };
|
||||
const connection = await service.createConnection(company.id, {
|
||||
name: "Custom reviewed endpoint", transport: "mcp_remote", enabled: true, status: "active",
|
||||
name: "Custom reviewed endpoint", connectionPurpose: "tool", transport: "mcp_remote", authKind: "none", ownership: "customer", connectionKind: "managed",
|
||||
transportConfig: {}, credentialSecretRefs: [], enabled: true, status: "active",
|
||||
config: { sourceTemplateKey: "custom", url: RAILWAY_MCP_URL, quarantineNewEntries: true },
|
||||
}, actor);
|
||||
const initial = await service.refreshCatalog(connection.id, actor, { enableAllByDefault: true });
|
||||
@@ -112,8 +113,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
tools = [...tools, { name: "provider_added_tool", annotations: { readOnlyHint: true } }];
|
||||
const refreshed = await service.refreshCatalog(connection.id, actor, { enableAllByDefault: true });
|
||||
expect(refreshed.catalog.find(tool => tool.toolName === "provider_added_tool")).toMatchObject({ riskLevel: "write", status: "quarantined" });
|
||||
expect(refreshed.connection.config.quarantineNewEntries).toBe(true);
|
||||
expect(refreshed.connection.config.sourceTemplateKey).toBe("custom");
|
||||
expect(refreshed.connection.config?.quarantineNewEntries).toBe(true);
|
||||
expect(refreshed.connection.config?.sourceTemplateKey).toBe("custom");
|
||||
expect(request).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
if (reply === "response") await db.insert(issueComments).values({ companyId, issueId,
|
||||
createdByRunId: runId, authorAgentId: agentId, origin: "comment", body: padding });
|
||||
if (reply === "interaction") await db.insert(issueThreadInteractions).values({ companyId, issueId,
|
||||
sourceRunId: runId, createdByAgentId: agentId, status: "pending", kind: "request_confirmation", payload: { version: 1 } });
|
||||
sourceRunId: runId, createdByAgentId: agentId, status: "pending", kind: "request_confirmation", payload: { version: 1, prompt: "Approve?" } });
|
||||
return { companyId, agentId, issueId, runId, wakeId, wakeTime };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createServer, type RequestListener } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
@@ -118,7 +118,7 @@ describe.skipIf(!cloudDist)("customer-success managed agent / Cloud / tenant qua
|
||||
.values({ companyId: customer.id, projectId: project.id, title: "First successful task" })
|
||||
.returning();
|
||||
const storage = { provider: "local_disk" } as StorageService;
|
||||
const start = async (handler: Parameters<typeof createServer>[0]) => {
|
||||
const start = async (handler: RequestListener) => {
|
||||
const server = createServer(handler);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -289,7 +289,7 @@ describe.skipIf(!cloudDist)("customer-success managed agent / Cloud / tenant qua
|
||||
env: { PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN: cloudOrigin },
|
||||
},
|
||||
context: {},
|
||||
runtime: { sessionId: null, sessionParams: null, taskKey: null },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
onLog: async (_stream, chunk) => {
|
||||
output.push(chunk);
|
||||
},
|
||||
|
||||
@@ -559,7 +559,7 @@ describe("customer-success read authority", () => {
|
||||
companyId,
|
||||
companySkillId: skill.id,
|
||||
revisionNumber: 1,
|
||||
fileInventory: [{ path: "SKILL.md", kind: "markdown", sizeBytes: 9, content: "# Example" }],
|
||||
fileInventory: [{ path: "SKILL.md", kind: "markdown", content: "# Example" }],
|
||||
})
|
||||
.returning();
|
||||
await db
|
||||
|
||||
@@ -49,7 +49,7 @@ const credential = "daytona-fixture-secret";
|
||||
throw new Error("Unexpected provider request");
|
||||
});
|
||||
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "daytona", methodKey: "workspace-diagnostics",
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "daytona", connectionMethodKey: "workspace-diagnostics",
|
||||
...(personal ? { grantKind: "user" as const } : {}), credentialValues: { "credentials.authorization": credential } }, actor);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Maintainer", role: "engineer", adapterType: "process" }).returning();
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map(c => c.id),
|
||||
@@ -156,7 +156,7 @@ const credential = "daytona-fixture-secret";
|
||||
const legacy = await db.insert(toolCatalogEntries).values(legacyNames.map(toolName => ({
|
||||
companyId: f.company.id, applicationId: f.connected.connection.applicationId, connectionId: f.connected.connectionId,
|
||||
name: toolName, toolName, title: toolName === "daytona_get_workspace_recovery" ? "Get workspace recovery" : toolName,
|
||||
entryKind: "tool", status: "active", riskLevel: "read", isReadOnly: true,
|
||||
entryKind: "tool" as const, status: "active" as const, riskLevel: "read" as const, isReadOnly: true,
|
||||
inputSchema: { type: "object" }, versionHash: "legacy", schemaHash: "legacy",
|
||||
}))).returning();
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
||||
|
||||
@@ -16,7 +16,7 @@ it.each([
|
||||
])("preserves $due continuation with $spacing ms minimum spacing and disarms after settlement", async ({ due, spacing, delay }) => {
|
||||
vi.useFakeTimers();
|
||||
const onError = vi.fn();
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: vi.fn() });
|
||||
coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => true, onError });
|
||||
const now = Date.now();
|
||||
const run = vi.fn(async () => {});
|
||||
@@ -68,7 +68,7 @@ it("releases a settled first-write hold during drain and retains a probe-free re
|
||||
it("retains failed work through drain and preserves a future domain deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let draining = false;
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: vi.fn() });
|
||||
const run = vi.fn(async () => { if (run.mock.calls.length === 1) { draining = true; throw new Error("retry debt"); } });
|
||||
const deadline = Date.now() + 600_000;
|
||||
const next = vi.fn(async () => deadline);
|
||||
@@ -87,7 +87,7 @@ it("retains failed work through drain and preserves a future domain deadline", a
|
||||
it("does no domain probes in warm standby and reconstructs after admission opens", async () => {
|
||||
vi.useFakeTimers();
|
||||
let standby = true;
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: vi.fn() });
|
||||
const run = vi.fn(async () => {}), next = vi.fn(async () => null);
|
||||
coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => !standby, canReconcile: () => !standby, onError: vi.fn() });
|
||||
const worker = registerDeadlineWork(coordinator, DELIVERY_QUEUES.agentTimers, { run, next, canRun: () => !standby, retryMs: 30_000 });
|
||||
@@ -126,7 +126,7 @@ it("does not release an ambiguous database intent while dispatch is drained", as
|
||||
it("backs off an expired deadline when only the domain is drained and resumes promptly", async () => {
|
||||
vi.useFakeTimers();
|
||||
let paused = false;
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: vi.fn() });
|
||||
const run = vi.fn(async () => {});
|
||||
const domainCanRun = vi.fn(() => !paused);
|
||||
const deadline = Date.now() + 1_000;
|
||||
@@ -155,7 +155,7 @@ it("backs off an expired deadline when only the domain is drained and resumes pr
|
||||
it("retains a prompt paused wake when a new request preempts a far-future deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let paused = false;
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []), transaction: vi.fn() });
|
||||
const run = vi.fn(async () => {});
|
||||
const deadline = Date.now() + 86_400_000;
|
||||
coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => true, onError: vi.fn() });
|
||||
|
||||
@@ -87,7 +87,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
kind: "request_item_verdicts", status: "answered", resolvedAt: new Date(),
|
||||
payload: { version: 1, prompt: "Review", items: [] },
|
||||
result: { version: 1, outcome: "resolved", complete: true,
|
||||
items: verdicts.map((verdict, index) => ({ id: String(index), verdict })) } });
|
||||
items: verdicts.map((verdict, index) => ({ id: String(index), verdict, resolvedAt: new Date().toISOString() })) } });
|
||||
}
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ companyId, issueId, title: "Question answered", kind: "ask_user_questions", status: "answered", resolvedAt: new Date(), payload: { version: 1, questions: [] } },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type { AttentionItem } from "@paperclipai/shared";
|
||||
import { DEFAULT_DECISION_SHELF_DAYS } from "../services/decision-retention.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -323,6 +324,18 @@ describeEmbeddedPostgres("decision queue routes", () => {
|
||||
} : null,
|
||||
project: null,
|
||||
workspace: null,
|
||||
expiresAt: null,
|
||||
ruleKey: null,
|
||||
originAgentName: null,
|
||||
queues: [],
|
||||
shelf: false,
|
||||
retentionDays: DEFAULT_DECISION_SHELF_DAYS,
|
||||
keep: false,
|
||||
archivedAt: null,
|
||||
retentionVersion: 0,
|
||||
decideBy: null,
|
||||
decideByAttribution: null,
|
||||
snoozedUntil: null,
|
||||
detail: null,
|
||||
trainingExampleId: null,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { decisionService } from "../services/decisions.js";
|
||||
import { signDecisionSpec } from "../services/decision-signing.js";
|
||||
import * as retentionModule from "../services/decision-retention.js";
|
||||
import { hashAttentionArchiveManifest } from "../services/decision-retention.js";
|
||||
import type { AttentionArchiveManifestEntry, AttentionArchiveTargetSnapshot } from "@paperclipai/shared";
|
||||
@@ -395,9 +396,12 @@ describePg("decisionService", () => {
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Cancel?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{
|
||||
type: "cancel_issue_tree", targetIssueId, staleness: "lenient", reasonComment: "cleanup",
|
||||
type: "cancel_issue_tree", targetIssueId, staleness: "strict", reasonComment: "cleanup",
|
||||
}] }],
|
||||
});
|
||||
// The effect type admits only strict cancellation, so a lenient one can exist only as a stored, signed row.
|
||||
const [stored] = await db.update(decisions).set({ options: sql`jsonb_set(${decisions.options}, '{0,effects,0,staleness}', '"lenient"')` }).where(eq(decisions.id, created.id)).returning();
|
||||
await db.update(decisions).set({ signedSpec: signDecisionSpec({ decisionId: stored!.id, options: stored!.options, targetSnapshots: stored!.targetSnapshots }) }).where(eq(decisions.id, created.id));
|
||||
const unreviewedChildId = randomUUID();
|
||||
await db.insert(issues).values({ id: unreviewedChildId, companyId, title: "Unreviewed child", status: "todo", priority: "medium",
|
||||
parentId: targetIssueId, responsibleUserId: decidedByUserId });
|
||||
|
||||
@@ -213,7 +213,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
|
||||
it("delivers reviewed REST actions through native assignments without admitting unsupported or inactive connections", async () => {
|
||||
vi.stubEnv("PAPERCLIP_API_URL", "http://127.0.0.1:3100");
|
||||
onTestFinished(() => vi.unstubAllEnvs());
|
||||
onTestFinished(() => { vi.unstubAllEnvs(); });
|
||||
const f = await fixture(provider, true);
|
||||
await f.service.putConnectionInstalls(f.connected.connectionId, {
|
||||
installs: [{ targetType: "agent", targetId: f.agent.id }],
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture });
|
||||
const nativeRunnerRequired = paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, glibcVersionRuntime ? "glibc" : "unknown", glibcVersionRuntime as string | undefined);
|
||||
expect(nativeRunnerRequired).toBe(native);
|
||||
expect(paperclipRunnerBinaryNeedsBuild({ repoRoot: "/checkout/without/a/staged/binary", nativeRunnerRequired })).toBe(native);
|
||||
expect(paperclipRunnerBinaryNeedsBuild({ repoRoot: "/checkout/without/a/staged/binary", nativeRunnerRequired, configuredBinary: undefined })).toBe(native);
|
||||
});
|
||||
it("uses an explicit status response and fails safe when status is unknown", () => {
|
||||
expect(
|
||||
@@ -109,6 +109,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
paperclipRunnerBinaryNeedsBuild({
|
||||
repoRoot: checkout.root,
|
||||
nativeRunnerRequired: true,
|
||||
configuredBinary: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -117,6 +118,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
paperclipRunnerBinaryNeedsBuild({
|
||||
repoRoot: checkout.root,
|
||||
nativeRunnerRequired: true,
|
||||
configuredBinary: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -125,6 +127,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
paperclipRunnerBinaryNeedsBuild({
|
||||
repoRoot: checkout.root,
|
||||
nativeRunnerRequired: true,
|
||||
configuredBinary: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -134,6 +137,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
paperclipRunnerBinaryNeedsBuild({
|
||||
repoRoot: "/checkout/without/a/staged/binary",
|
||||
nativeRunnerRequired: false,
|
||||
configuredBinary: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -148,14 +152,14 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
const now = Date.now(), old = new Date(now - 2_000), built = new Date(now + 2_000), changed = new Date(now + 4_000);
|
||||
fs.utimesSync(dependency, old, old);
|
||||
fs.utimesSync(checkout.binary, built, built);
|
||||
const options = { repoRoot: checkout.root, nativeRunnerRequired: true };
|
||||
const options = { repoRoot: checkout.root, nativeRunnerRequired: true, configuredBinary: undefined };
|
||||
expect(paperclipRunnerBinaryNeedsBuild(options)).toBe(false);
|
||||
fs.utimesSync(dependency, changed, changed);
|
||||
expect(paperclipRunnerBinaryNeedsBuild(options)).toBe(true);
|
||||
expect(paperclipRunnerBinaryNeedsBuild({ ...options, nativeRunnerRequired: false })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["arm64", "x64"])("uses the staged Mac %s target before the flat build output", architecture => {
|
||||
it.each(["arm64", "x64"] as const)("uses the staged Mac %s target before the flat build output", architecture => {
|
||||
const checkout = createRunnerCheckout();
|
||||
const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd");
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
@@ -163,7 +167,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
const old = new Date(Date.now() - 2000), current = new Date(Date.now() + 2000);
|
||||
fs.utimesSync(checkout.binary, old, old);
|
||||
fs.utimesSync(target, current, current);
|
||||
const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture };
|
||||
const input = { repoRoot: checkout.root, nativeRunnerRequired: true, configuredBinary: undefined, platform: "darwin" as const, architecture };
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false);
|
||||
fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64"));
|
||||
const wrongHostNewer = new Date(Date.now() + 4000);
|
||||
@@ -178,7 +182,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["arm64", "x64"])("uses only a newer matching Mac %s source build without changing release selection", architecture => {
|
||||
it.each(["arm64", "x64"] as const)("uses only a newer matching Mac %s source build without changing release selection", architecture => {
|
||||
const checkout = createRunnerCheckout();
|
||||
const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd");
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
@@ -186,7 +190,7 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture));
|
||||
const old = new Date(Date.now() - 2000), fresh = new Date(Date.now() + 2000), changed = new Date(Date.now() + 4000);
|
||||
fs.utimesSync(target, old, old); fs.utimesSync(checkout.binary, fresh, fresh);
|
||||
const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture };
|
||||
const input = { repoRoot: checkout.root, nativeRunnerRequired: true, configuredBinary: undefined, platform: "darwin" as const, architecture };
|
||||
expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: checkout.binary, sourceBuild: true });
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false);
|
||||
fs.utimesSync(checkout.source, changed, changed);
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("createCapturedOutputBuffer", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(parseJsonResponseWithLimit<{ ok: boolean }>(response, 64)).resolves.toEqual({ ok: true });
|
||||
await expect(parseJsonResponseWithLimit(response, 64)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects oversized JSON responses before parsing them", async () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("dev-runner watched snapshot", () => {
|
||||
|
||||
const fileSystem = {
|
||||
existsSync: fs.existsSync,
|
||||
readdirSync(target: fs.PathLike, options?: fs.ObjectEncodingOptions & { withFileTypes: true }) {
|
||||
readdirSync(target: fs.PathLike, options: fs.ObjectEncodingOptions & { withFileTypes: true }) {
|
||||
if (target === routesDir) {
|
||||
fs.rmSync(routesDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -84,6 +84,8 @@ describe("dev-runner watched snapshot", () => {
|
||||
const root = createTempRoot("paperclip-dev-runner-snapshot-diff-");
|
||||
const missingPath = path.join(root, "server", "deleted.ts");
|
||||
const fileSystem = {
|
||||
existsSync: fs.existsSync,
|
||||
readdirSync: fs.readdirSync,
|
||||
statSync() {
|
||||
const error = new Error("not a directory") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTDIR";
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
writeDevServerRestartRequest,
|
||||
} from "../dev-server-status.js";
|
||||
|
||||
const tempDirs = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempStatusFile(payload: unknown) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-status-"));
|
||||
|
||||
@@ -30,6 +30,7 @@ function createMemoryReaperStore(): AdapterAuthReaperStore & {
|
||||
seed(input) {
|
||||
const row: AdapterAuthSessionRow = {
|
||||
id: input.id,
|
||||
publicSessionId: input.publicSessionId ?? randomUUID(),
|
||||
companyId: input.companyId ?? randomUUID(),
|
||||
environmentId: input.environmentId ?? randomUUID(),
|
||||
adapterType: input.adapterType ?? ADAPTER_TYPE,
|
||||
@@ -40,6 +41,7 @@ function createMemoryReaperStore(): AdapterAuthReaperStore & {
|
||||
promotionExpiresAt: input.promotionExpiresAt ?? null,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
failureReason: input.failureReason ?? null,
|
||||
resultClaim: input.resultClaim ?? null,
|
||||
};
|
||||
rows.set(row.id, row);
|
||||
return row;
|
||||
|
||||
@@ -218,6 +218,7 @@ function createMemoryStore(): AdapterAuthSessionStore & {
|
||||
promotionExpiresAt: null,
|
||||
finishedAt: null,
|
||||
failureReason: null,
|
||||
resultClaim: null,
|
||||
});
|
||||
},
|
||||
async recordLeaseAcquired(input) {
|
||||
@@ -1413,7 +1414,7 @@ describeEmbeddedPostgres("device login service concurrency (embedded postgres)",
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("device-login");
|
||||
stopDb = started.stop;
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
@@ -2039,7 +2040,7 @@ describeEmbeddedPostgres("device login service concurrency (embedded postgres)",
|
||||
// The start ttl read: `session.expiresAt` reflects the profile's
|
||||
// `timeoutMs`, not the fixed `DEVICE_LOGIN_TIMEOUT_MS`.
|
||||
const expectedExpiresAt = Date.now() + customTimeoutMs;
|
||||
expect(Math.abs(new Date(started.session.expiresAt).getTime() - expectedExpiresAt)).toBeLessThan(
|
||||
expect(Math.abs(new Date(started.session.expiresAt!).getTime() - expectedExpiresAt)).toBeLessThan(
|
||||
5000,
|
||||
);
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ const mockIssueReferenceService = vi.hoisted(() => ({
|
||||
syncIssue: vi.fn(async () => undefined),
|
||||
}));
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
wakeup: vi.fn(async (): Promise<{ id: string } | undefined> => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async (): Promise<Array<{ id: string; kind: string; status: string }>> => []),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
}, 120000);
|
||||
beforeEach(async () => { await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false }); });
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
async function fixture(authorizationOrigin?: string, lifecycleState = "ready") {
|
||||
async function fixture(authorizationOrigin?: string, lifecycleState: NonNullable<typeof agents.$inferInsert["lifecycleState"]> = "ready") {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot onboarding", issuePrefix: "DO" + randomBytes(3).toString("hex") }).returning();
|
||||
@@ -64,7 +64,7 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
expect(revoked.lifecycleVersion).toBe(paired.lifecycleVersion + 1);
|
||||
await expect(f.oauth.authenticate(tokens.access_token)).rejects.toThrow();
|
||||
});
|
||||
it.each(["preparing", "verifying"])("allows Dot setup during %s without allowing task access", async (state) => {
|
||||
it.each(["preparing", "verifying"] as const)("allows Dot setup during %s without allowing task access", async (state) => {
|
||||
const f = await fixture(undefined, state);
|
||||
const tokens = await connect(f);
|
||||
const principal = await f.oauth.authenticate(tokens.access_token);
|
||||
|
||||
@@ -24,7 +24,8 @@ import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.j
|
||||
import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
|
||||
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js";
|
||||
import { createPublicMcpEvents, type EventFetch } from "../services/public-mcp/events.js";
|
||||
import { createPublicMcpEvents } from "../services/public-mcp/events.js";
|
||||
import type { EventFetch } from "../services/public-mcp/event-webhooks.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { canonicalNativeRuntimeContextDigest, type StrictCompletionContractInput } from "../vendor/paperclip-runner/index.js";
|
||||
import { createDotRunnerMcpTools, dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
@@ -46,6 +47,11 @@ import { createReleaseIssueExecution } from "../modules/wake-queue/application/u
|
||||
|
||||
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_DOT_RUNNER_TEST_DATABASE_URL");
|
||||
|
||||
function uploadGrantUrl(grant: { url: string } | { attachment: unknown }) {
|
||||
if (!("url" in grant)) throw new Error("Expected an upload grant");
|
||||
return new URL(grant.url);
|
||||
}
|
||||
|
||||
describe("durable Dot Runner integration", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
@@ -264,7 +270,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const body = Buffer.from("Authenticated concurrent deliverable"), uploadRequestId = randomUUID(), operationId = randomUUID();
|
||||
const upload = await transfers.getUploadUrl(f.principal, { companyId: f.company.id, taskId: issue!.id, requestId: uploadRequestId,
|
||||
filename: "concurrent.txt", contentType: "text/plain", byteSize: body.length, sha256: createHash("sha256").update(body).digest("hex") }, assignment.id);
|
||||
const url = new URL(String(upload.url)), api = express(); api.use(transfers.router);
|
||||
const url = uploadGrantUrl(upload), api = express(); api.use(transfers.router);
|
||||
let uploading: Promise<request.Response> | undefined, uploaded: request.Response | undefined, uploadError: unknown;
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
@@ -961,7 +967,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const [assignment] = await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id));
|
||||
if (provider === "openai_dot") await f.events.tick();
|
||||
expect(JSON.stringify(f.received)).not.toContain("17 + 25");
|
||||
const work = await f.broker.read(f.principal, assignment!.id);
|
||||
const work = await f.broker.read(f.principal, assignment!.id) as Awaited<ReturnType<typeof f.broker.read>> & { tools: unknown };
|
||||
expect(work.accounting).toEqual({ usage: null, cost: null });
|
||||
expect(work.status).toBe("offered");
|
||||
expect(observed.filter(event => event.eventType === "turn.started")).toHaveLength(0);
|
||||
@@ -990,7 +996,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const body = Buffer.from("Grok arithmetic file: 42\n"), uploadId = randomUUID();
|
||||
const args = { companyId: f.company.id, taskId: issue!.id, requestId: uploadId, filename: "report.txt", contentType: "text/plain", byteSize: body.length, sha256: createHash("sha256").update(body).digest("hex") };
|
||||
const grant = await transfers.getUploadUrl(f.principal, args, assignment!.id);
|
||||
const url = new URL(String(grant.url));
|
||||
const url = uploadGrantUrl(grant);
|
||||
if (revokeDuringUpload) {
|
||||
const uploading = request(api).put(url.pathname + url.search).set("Content-Type", "text/plain").send(body)
|
||||
.then(response => response);
|
||||
@@ -1077,7 +1083,7 @@ describe("durable Dot Runner integration", () => {
|
||||
await expect(transfers.getUploadUrl(f.principal, { ...args, taskId: randomUUID() }, assignment!.id)).rejects.toThrow();
|
||||
const download = new URL(String((await transfers.getDownloadUrl(f.principal, uploaded.body.attachment.id, assignment!.id)).url));
|
||||
expect((await request(api).get(download.pathname + download.search)).status).toBe(200);
|
||||
const pendingUpload = new URL(String((await transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id)).url));
|
||||
const pendingUpload = uploadGrantUrl(await transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id));
|
||||
await db.update(issues).set({ workMode: "ask" }).where(eq(issues.id, issue!.id));
|
||||
await expect(transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id)).rejects.toThrow("work mode");
|
||||
expect((await request(api).put(pendingUpload.pathname + pendingUpload.search).set("Content-Type", "text/plain").send(body)).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DUPLEX_SPAN_CHANNEL_OPEN,
|
||||
DUPLEX_SPAN_REQUEST,
|
||||
DUPLEX_TRANSPORT_EVENT,
|
||||
type DuplexObservabilityDimensions,
|
||||
} from "@paperclipai/adapter-utils/duplex-observability";
|
||||
import { runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
||||
import {
|
||||
@@ -180,7 +181,7 @@ describe("createHostDuplexObservabilityRecorder", () => {
|
||||
});
|
||||
|
||||
it("forwards the transport event with its name and dimensions", () => {
|
||||
const events: Array<{ name: string; dimensions: Record<string, unknown> }> =
|
||||
const events: Array<{ name: string; dimensions: DuplexObservabilityDimensions }> =
|
||||
[];
|
||||
const recorder = createHostDuplexObservabilityRecorder({
|
||||
tracer: createRecordingTracer().tracer,
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("durable inbound chat scheduler receipts", () => {
|
||||
wakeCommentId: durableChatRequest.commentId,
|
||||
durableChatRequest,
|
||||
rethrowOnError: true,
|
||||
});
|
||||
}) as Promise<Awaited<ReturnType<typeof heartbeat.wakeup>>> | undefined;
|
||||
return {
|
||||
companyId,
|
||||
agentId,
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("AgentMail durable email pipeline", () => {
|
||||
});
|
||||
|
||||
async function fixture(mode: "websocket" | "webhook" = "webhook", storage?: StorageService,
|
||||
beforeSetup?: (input: { companyId: string; fetcher: typeof fetch; wakeup: ReturnType<typeof vi.fn> }) => Promise<void>,
|
||||
beforeSetup?: (input: { companyId: string; fetcher: typeof fetch; wakeup: Parameters<typeof emailChannelService>[1]["heartbeat"]["wakeup"] }) => Promise<void>,
|
||||
) {
|
||||
const companyId = randomUUID(),
|
||||
agentId = randomUUID(),
|
||||
@@ -344,8 +344,8 @@ describe("AgentMail durable email pipeline", () => {
|
||||
{ userId: "email-board" },
|
||||
);
|
||||
function message(
|
||||
id = randomUUID(),
|
||||
thread = randomUUID(),
|
||||
id: string = randomUUID(),
|
||||
thread: string = randomUUID(),
|
||||
extra: Partial<AgentmailMessage> = {},
|
||||
) {
|
||||
return agentmailMessageSchema.parse({
|
||||
@@ -1318,7 +1318,7 @@ describe("AgentMail durable email pipeline", () => {
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user",
|
||||
principalId: actor.userId, permissionKey: "tools:manage_connections" });
|
||||
expect(await accessService(db).hasPermission(f.companyId, "user", actor.userId, "tools:manage_connections")).toBe(true);
|
||||
expect(await accessService(db).hasPermission(f.companyId, "user", actor.userId, "agent_config:update")).toBe(false);
|
||||
expect(await accessService(db).hasPermission(f.companyId, "user", actor.userId, "agents:configure")).toBe(false);
|
||||
const requestId = randomUUID();
|
||||
const svc = emailConnectionService(db, f.fetcher);
|
||||
const connection = await svc.connect(f.companyId, { apiKey: "test-key", grantKind: "organization",
|
||||
|
||||
@@ -14,8 +14,9 @@ function fixture() {
|
||||
vi.useFakeTimers();
|
||||
const select = vi.fn(() => ({ from: () => ({ where: async () => [] }) }));
|
||||
const execute = vi.fn(async () => [{ xid: "42", status: "aborted" }]);
|
||||
type FixtureTx = { execute: typeof execute; transaction: <T>(callback: (tx: FixtureTx) => Promise<T>) => Promise<T> };
|
||||
const db = installDatabaseWorkSignals({ select, execute,
|
||||
async transaction(callback: (tx: any) => Promise<any>) {
|
||||
async transaction<T>(callback: (tx: FixtureTx) => Promise<T>): Promise<T> {
|
||||
return callback({ execute, transaction: db.transaction });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,11 +186,11 @@ function createSession(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
function createApp(actor: Express.Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as typeof req & { actor: Record<string, unknown> }).actor = actor;
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", environmentRoutes({} as never));
|
||||
@@ -202,7 +202,7 @@ function createApp(actor: Record<string, unknown>) {
|
||||
return app;
|
||||
}
|
||||
|
||||
function boardActor(overrides: Record<string, unknown> = {}) {
|
||||
function boardActor(overrides: Partial<Express.Request["actor"]> = {}): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -213,7 +213,7 @@ function boardActor(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function agentActor() {
|
||||
function agentActor(): Express.Request["actor"] {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createServer, type Server as HttpServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
vi.mock("../services/environment-custom-images.js", () => ({
|
||||
environmentCustomImageService: vi.fn(() => {
|
||||
@@ -78,6 +78,10 @@ function futureDate(minutes = 60) {
|
||||
return new Date(Date.now() + minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
type CustomImageTerminalService = NonNullable<
|
||||
NonNullable<Parameters<typeof setupEnvironmentCustomImageTerminalWebSocketServer>[2]>["customImageService"]
|
||||
>;
|
||||
|
||||
function createSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "session-1",
|
||||
@@ -178,8 +182,8 @@ describe("custom image terminal websocket bridge", () => {
|
||||
let connectionRegistry: EnvironmentCustomImageTerminalConnectionRegistry;
|
||||
let fakeShell: FakeSshShell;
|
||||
let customImages: {
|
||||
getSessionById: ReturnType<typeof vi.fn>;
|
||||
refreshSetupSession: ReturnType<typeof vi.fn>;
|
||||
getSessionById: Mock<CustomImageTerminalService["getSessionById"]>;
|
||||
refreshSetupSession: Mock<CustomImageTerminalService["refreshSetupSession"]>;
|
||||
};
|
||||
let connector: EnvironmentCustomImageSshConnector & { connect: ReturnType<typeof vi.fn> };
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
environments,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import {
|
||||
environmentCustomImageService,
|
||||
@@ -47,6 +48,8 @@ function pluginManifest() {
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Sandbox Provider",
|
||||
description: "Test fake sandbox provider",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
@@ -68,7 +71,7 @@ function pluginManifest() {
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
const ALL_CUSTOM_IMAGE_WORKER_METHODS = [
|
||||
@@ -1229,6 +1232,8 @@ function secretPluginManifest() {
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Secret Sandbox Provider",
|
||||
description: "Test fake sandbox provider",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
@@ -1259,7 +1264,7 @@ function secretPluginManifest() {
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
function invalidIdentityPluginManifest() {
|
||||
@@ -1268,6 +1273,8 @@ function invalidIdentityPluginManifest() {
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Invalid Identity Sandbox Provider",
|
||||
description: "Test fake sandbox provider",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
@@ -1287,7 +1294,7 @@ function invalidIdentityPluginManifest() {
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
function prototypeKeyIdentityPluginManifest() {
|
||||
@@ -1296,6 +1303,8 @@ function prototypeKeyIdentityPluginManifest() {
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Prototype Key Sandbox Provider",
|
||||
description: "Test fake sandbox provider",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
@@ -1316,7 +1325,7 @@ function prototypeKeyIdentityPluginManifest() {
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("environmentCustomImageService relink", () => {
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("sandbox driver duplex channel wiring", () => {
|
||||
const channel = await service.openDuplexChannel({
|
||||
environment: SANDBOX_ENVIRONMENT,
|
||||
lease: PLUGIN_LEASE,
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
});
|
||||
|
||||
// The driver resolves the worker by the pinned plugin id and passes the same
|
||||
@@ -142,7 +142,7 @@ describe("sandbox driver duplex channel wiring", () => {
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "provider-lease-1",
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
});
|
||||
|
||||
// write / stop / close map to write / kill / close on the host session.
|
||||
@@ -185,7 +185,7 @@ describe("sandbox driver duplex channel wiring", () => {
|
||||
service.openDuplexChannel({
|
||||
environment: SANDBOX_ENVIRONMENT,
|
||||
lease: nonPluginLease,
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
}),
|
||||
).rejects.toThrow(DUPLEX_DENIED);
|
||||
expect(worker.openDuplexChannel).not.toHaveBeenCalled();
|
||||
@@ -210,7 +210,7 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
|
||||
service.openDuplexChannel({
|
||||
environment: SANDBOX_ENVIRONMENT,
|
||||
lease: PLUGIN_LEASE,
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
}),
|
||||
).rejects.toThrow(DUPLEX_DENIED);
|
||||
expect(worker.openDuplexChannel).not.toHaveBeenCalled();
|
||||
@@ -228,7 +228,7 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
|
||||
service.openDuplexChannel({
|
||||
environment: SANDBOX_ENVIRONMENT,
|
||||
lease: PLUGIN_LEASE,
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
}),
|
||||
).rejects.toThrow(DUPLEX_DENIED);
|
||||
expect(worker.openDuplexChannel).not.toHaveBeenCalled();
|
||||
@@ -253,7 +253,7 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
|
||||
service.openDuplexChannel({
|
||||
environment: SANDBOX_ENVIRONMENT,
|
||||
lease: { ...PLUGIN_LEASE, metadata: { ...PLUGIN_LEASE.metadata, driver: "sandbox" } },
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
}),
|
||||
).rejects.toThrow(DUPLEX_DENIED);
|
||||
expect(openDuplexChannel).not.toHaveBeenCalled();
|
||||
@@ -271,7 +271,7 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
|
||||
service.openDuplexChannel({
|
||||
environment: SANDBOX_ENVIRONMENT,
|
||||
lease: PLUGIN_LEASE,
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
}),
|
||||
).rejects.toThrow(DUPLEX_DENIED);
|
||||
expect(worker.openDuplexChannel).not.toHaveBeenCalled();
|
||||
@@ -353,11 +353,11 @@ describe("inline sandbox runner duplex capability gate", () => {
|
||||
const { runner, openDuplexChannel } = await buildSandboxRunner({ snapshot: DUPLEX_GRANT });
|
||||
expect(runner?.openDuplexChannel).toBeDefined();
|
||||
|
||||
await runner!.openDuplexChannel!({ command: "bridge-callback" });
|
||||
await runner!.openDuplexChannel!({ command: ["bridge-callback"] });
|
||||
expect(openDuplexChannel).toHaveBeenCalledWith({
|
||||
environment: expect.objectContaining({ id: "env-1" }),
|
||||
lease: expect.objectContaining({ id: "lease-1" }),
|
||||
command: "bridge-callback",
|
||||
command: ["bridge-callback"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ function createRecordingTrace() {
|
||||
parent: unknown;
|
||||
ended: boolean;
|
||||
setAttribute(key: string, value: unknown): void;
|
||||
setStatus(status: { code: number; message?: string }): void;
|
||||
end(): void;
|
||||
}> = [];
|
||||
const tracer = {
|
||||
@@ -47,6 +48,7 @@ function createRecordingTrace() {
|
||||
setAttribute(key: string, value: unknown) {
|
||||
span.attributes[key] = value;
|
||||
},
|
||||
setStatus(_status: { code: number; message?: string }) {},
|
||||
end() {
|
||||
span.ended = true;
|
||||
},
|
||||
@@ -624,7 +626,7 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
||||
async function runnerWithExecute(input: {
|
||||
provider: string;
|
||||
tracer: unknown;
|
||||
execute: (...args: unknown[]) => Promise<unknown>;
|
||||
execute: (input: { onLog?: (s: string, c: string) => Promise<void> }) => Promise<unknown>;
|
||||
}) {
|
||||
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
|
||||
driver: "sandbox",
|
||||
@@ -646,7 +648,7 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
||||
tracer: input.tracer as never,
|
||||
});
|
||||
return (target as { runner?: {
|
||||
execute(input: { command: string; args?: string[] }): Promise<unknown>;
|
||||
execute(input: { command: string; args?: string[]; env?: Record<string, string>; cwd?: string; stdin?: string }): Promise<unknown>;
|
||||
} }).runner!;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@ function createEnvironment(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
function createApp(actor: Express.Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as typeof req & { actor: Record<string, unknown> }).actor = actor;
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", environmentRoutes({
|
||||
|
||||
@@ -179,7 +179,7 @@ describe("probeEnvironment", () => {
|
||||
it("reports local environments as immediately available", async () => {
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-1",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "Local",
|
||||
description: null,
|
||||
driver: "local",
|
||||
@@ -203,7 +203,7 @@ describe("probeEnvironment", () => {
|
||||
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-ssh",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "SSH Fixture",
|
||||
description: null,
|
||||
driver: "ssh",
|
||||
@@ -241,7 +241,7 @@ describe("probeEnvironment", () => {
|
||||
it("reports fake sandbox environments as ready without external calls", async () => {
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-sandbox",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "Fake Sandbox",
|
||||
description: null,
|
||||
driver: "sandbox",
|
||||
@@ -283,7 +283,7 @@ describe("probeEnvironment", () => {
|
||||
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-sandbox-plugin",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "Fake Plugin Sandbox",
|
||||
description: null,
|
||||
driver: "sandbox",
|
||||
@@ -349,7 +349,7 @@ describe("probeEnvironment", () => {
|
||||
|
||||
const environment = {
|
||||
id: "env-sandbox-plugin",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "Daytona",
|
||||
description: null,
|
||||
driver: "sandbox" as const,
|
||||
@@ -411,7 +411,7 @@ describe("probeEnvironment", () => {
|
||||
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-plugin",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "Plugin Sandbox",
|
||||
description: null,
|
||||
driver: "plugin",
|
||||
@@ -451,7 +451,7 @@ describe("probeEnvironment", () => {
|
||||
|
||||
const result = await probeEnvironment({} as any, {
|
||||
id: "env-ssh",
|
||||
companyId: "company-1",
|
||||
envVars: {},
|
||||
name: "SSH Fixture",
|
||||
description: null,
|
||||
driver: "ssh",
|
||||
|
||||
@@ -80,12 +80,12 @@ import type { EnvironmentRuntimeService } from "../services/environment-runtime.
|
||||
function makeEnvironment(driver: string = "local"): Environment {
|
||||
return {
|
||||
id: "env-1",
|
||||
companyId: "company-1",
|
||||
name: "Test Environment",
|
||||
description: null,
|
||||
driver: driver as Environment["driver"],
|
||||
status: "active",
|
||||
config: {},
|
||||
envVars: {},
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -129,6 +129,7 @@ function makeExecutionWorkspace(cwd: string = "/workspace/project"): RealizedExe
|
||||
cwd,
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
branchCreatedByRuntime: false,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
@@ -143,15 +144,15 @@ function makePersistedExecutionWorkspace(
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
sourceIssueId: null,
|
||||
mode: "standard",
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "workspace",
|
||||
status: "open",
|
||||
status: "active",
|
||||
cwd: "/workspace/project",
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: null,
|
||||
providerType: "local",
|
||||
providerType: "local_fs",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
@@ -161,6 +162,7 @@ function makePersistedExecutionWorkspace(
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
deliveryState: "unknown",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
@@ -803,7 +805,7 @@ describe("native runner lifecycle changes before lease acquisition", () => {
|
||||
const environment = { ...makeEnvironment("plugin"), config: { pluginKey: "fixture", driverKey: "tasks", driverConfig: {} } };
|
||||
mockGetEnvironment.mockResolvedValue(environment);
|
||||
const acquireRunLease = vi.fn(async input => ({ environment: input.environment,
|
||||
lease: makeLease({ leasePolicy: input.environment.config.runnerLifecycleMode === "warm" ? "reuse_by_environment" : "ephemeral" }), leaseContext: {} }));
|
||||
lease: makeLease({ leasePolicy: input.environment.config.runnerLifecycleMode === "warm" ? "reuse_by_environment" : "ephemeral" }), leaseContext: { executionWorkspaceId: null, executionWorkspaceMode: null } }));
|
||||
const orchestrator = environmentRunOrchestrator({} as never, { environmentRuntime: makeMockRuntime({ acquireRunLease }) });
|
||||
const result = await orchestrator.acquireForRun({ companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local",
|
||||
adapterType: "paperclip_runner", adapterConfig: { lifecycleMode: "warm" }, managedAiCredentialIdentity,
|
||||
@@ -819,7 +821,7 @@ describe("native runner lifecycle changes before lease acquisition", () => {
|
||||
const acquireRunLease = vi.fn(async (input) => ({
|
||||
environment: input.environment,
|
||||
lease: makeLease({ leasePolicy: input.environment.config.reuseLease ? "reuse_by_environment" : "ephemeral" }),
|
||||
leaseContext: { executionWorkspaceId: "existing-workspace", executionWorkspaceMode: "shared_workspace" },
|
||||
leaseContext: { executionWorkspaceId: "existing-workspace", executionWorkspaceMode: "shared_workspace" as const },
|
||||
}));
|
||||
const orchestrator = environmentRunOrchestrator({} as never, { environmentRuntime: makeMockRuntime({ acquireRunLease }) });
|
||||
const input = {
|
||||
@@ -853,7 +855,7 @@ describe("native runner lifecycle changes before lease acquisition", () => {
|
||||
])("respects environment precedence and adapter scope (%s, %s, %s)", async (adapterType, runnerLifecycleMode, lifecycleMode, expectedReuse) => {
|
||||
const environment = { ...makeEnvironment("sandbox"), config: { provider: "daytona", reuseLease: false, runnerLifecycleMode } };
|
||||
mockGetEnvironment.mockResolvedValue(environment);
|
||||
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: {} }));
|
||||
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: { executionWorkspaceId: null, executionWorkspaceMode: null } }));
|
||||
const orchestrator = environmentRunOrchestrator({} as never, { environmentRuntime: makeMockRuntime({ acquireRunLease }) });
|
||||
await orchestrator.acquireForRun({
|
||||
companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local", adapterType,
|
||||
@@ -872,7 +874,7 @@ describe("admitted native lifecycle recovery", () => {
|
||||
] as const)("preserves admitted %s over changed agent/environment settings", async (admittedLifecycleMode, lifecycleMode, runnerLifecycleMode, expectedReuse) => {
|
||||
const environment = { ...makeEnvironment("sandbox"), config: { provider: "daytona", reuseLease: false, runnerLifecycleMode } };
|
||||
mockGetEnvironment.mockResolvedValue(environment);
|
||||
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: {} }));
|
||||
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: { executionWorkspaceId: null, executionWorkspaceMode: null } }));
|
||||
const orchestrator = environmentRunOrchestrator({} as never, { environmentRuntime: makeMockRuntime({ acquireRunLease }) });
|
||||
await orchestrator.acquireForRun({
|
||||
companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getSshEnvLabSupport,
|
||||
startSshEnvLabFixture,
|
||||
stopSshEnvLabFixture,
|
||||
type SshEnvironmentConfig,
|
||||
} from "@paperclipai/adapter-utils/ssh";
|
||||
import {
|
||||
agents,
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
environments,
|
||||
heartbeatRuns,
|
||||
} from "@paperclipai/db";
|
||||
import type { Environment } from "@paperclipai/shared";
|
||||
import type { Environment, EnvironmentDriver } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -43,7 +42,7 @@ if (!embeddedPostgresSupport.supported) {
|
||||
|
||||
interface RuntimeContractCase {
|
||||
name: string;
|
||||
driver: string;
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown>;
|
||||
setup?: () => Promise<() => Promise<void>>;
|
||||
expectLease: (lease: {
|
||||
@@ -59,7 +58,7 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract");
|
||||
stopDb = started.stop;
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
@@ -84,7 +83,7 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
});
|
||||
|
||||
async function seedEnvironment(input: {
|
||||
driver: string;
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown>;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
@@ -173,16 +172,16 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
runId,
|
||||
environment: {
|
||||
id: resolvedEnvironmentId,
|
||||
companyId,
|
||||
name: `${input.driver} contract`,
|
||||
description: null,
|
||||
driver: input.driver,
|
||||
status: "active",
|
||||
config,
|
||||
envVars: {},
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as Environment,
|
||||
} satisfies Environment,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,7 +300,7 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
await runContract({
|
||||
name: "ssh",
|
||||
driver: "ssh",
|
||||
config: sshConfig as SshEnvironmentConfig as unknown as Record<string, unknown>,
|
||||
config: { ...sshConfig },
|
||||
expectLease: (lease) => {
|
||||
expect(lease.providerLeaseId).toContain(`ssh://${sshConfig.username}@${sshConfig.host}:${sshConfig.port}`);
|
||||
expect(lease.metadata).toMatchObject({
|
||||
|
||||
@@ -41,7 +41,7 @@ describeEmbeddedPostgres("environmentRuntimeService.readSandboxDuplexBridgeInput
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-duplex-bridge");
|
||||
stopDb = started.stop;
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
runtime = environmentRuntimeService(db);
|
||||
settings = instanceSettingsService(db);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { resolveEnvironmentDriverConfigForRuntime } from "../services/environment-config.ts";
|
||||
import {
|
||||
SANDBOX_CAPABILITY_KEYS,
|
||||
classifyEnvironmentCapabilities,
|
||||
environmentRuntimeService,
|
||||
findReusableSandboxLeaseId,
|
||||
} from "../services/environment-runtime.ts";
|
||||
@@ -54,6 +55,7 @@ import { remoteExecutionHasStopped } from "../services/remote-execution-terminat
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { secretService } from "../services/secrets.ts";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
|
||||
import type { Environment, EnvironmentDriver, EnvironmentStatus } from "@paperclipai/shared";
|
||||
import {
|
||||
getActiveStepContext,
|
||||
runWithRuntimeParent,
|
||||
@@ -207,7 +209,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
if (externalTestDatabaseUrl) connectionString = externalTestDatabaseUrl;
|
||||
else {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-runtime");
|
||||
stopDb = started.stop;
|
||||
stopDb = started.cleanup;
|
||||
connectionString = started.connectionString;
|
||||
}
|
||||
});
|
||||
@@ -393,7 +395,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
const config = { provider: "daytona", snapshot: "managed-snapshot", ...(savedReuse === undefined ? {} : { reuseLease: savedReuse }), timeoutMs: 1234 };
|
||||
await insertSandboxProviderPlugin(pluginId, { environmentDrivers: [{ driverKey: "daytona", kind: "sandbox_provider", displayName: "Daytona", configSchema: { type: "object" } }] });
|
||||
await environmentService(db).update(baseEnvironment.id, { driver: "sandbox", config });
|
||||
const environment = { ...baseEnvironment, driver: "sandbox", config };
|
||||
const environment: Environment = { ...baseEnvironment, driver: "sandbox", config };
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
const snapshotId = "a6b9e7d0-f886-471a-b461-640dd03e8d0f";
|
||||
environment.metadata = { paperclipSandboxPlatformProbes: { [companyId]: {
|
||||
@@ -469,7 +471,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
{ env: { DIFFERENT_RUNTIME: "changed" } },
|
||||
])("rejects non-retention run overrides before provider dispatch (%j)", async override => {
|
||||
const { companyId, environment: baseEnvironment } = await seedEnvironment();
|
||||
const environment = { ...baseEnvironment, driver: "sandbox", config: { provider: "daytona", snapshot: "managed-snapshot" } };
|
||||
const environment: Environment = { ...baseEnvironment, driver: "sandbox", config: { provider: "daytona", snapshot: "managed-snapshot" } };
|
||||
const worker = createFreshPluginLeaseWorker(randomUUID());
|
||||
const service = environmentRuntimeService(db, { pluginWorkerManager: worker });
|
||||
await expect(service.acquireRunLease({ companyId, environment: { ...environment, config: { ...environment.config, ...override, reuseLease: false, archiveOnRelease: true } },
|
||||
@@ -635,16 +637,16 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
}
|
||||
|
||||
async function seedEnvironment(input: {
|
||||
driver?: string;
|
||||
driver?: EnvironmentDriver;
|
||||
name?: string;
|
||||
status?: "active" | "disabled";
|
||||
status?: EnvironmentStatus;
|
||||
config?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const driver = input.driver ?? "local";
|
||||
const driver: EnvironmentDriver = input.driver ?? "local";
|
||||
const environmentName = input.name ?? `${driver}-${environmentId.slice(0, 8)}`;
|
||||
let config = input.config ?? {};
|
||||
|
||||
@@ -729,22 +731,23 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const environment: Environment = {
|
||||
id: environmentRecord.id,
|
||||
name: environmentRecord.name,
|
||||
description: environmentRecord.description,
|
||||
driver,
|
||||
status: environmentRecord.status as EnvironmentStatus,
|
||||
config: environmentRecord.config,
|
||||
envVars: {},
|
||||
metadata: environmentRecord.metadata,
|
||||
createdAt: environmentRecord.createdAt,
|
||||
updatedAt: environmentRecord.updatedAt,
|
||||
};
|
||||
|
||||
return {
|
||||
companyId,
|
||||
agentId,
|
||||
environment: {
|
||||
id: environmentRecord.id,
|
||||
companyId,
|
||||
name: environmentRecord.name,
|
||||
description: environmentRecord.description,
|
||||
driver: environmentRecord.driver,
|
||||
status: environmentRecord.status,
|
||||
config: environmentRecord.config,
|
||||
metadata: environmentRecord.metadata,
|
||||
createdAt: environmentRecord.createdAt,
|
||||
updatedAt: environmentRecord.updatedAt,
|
||||
} as const,
|
||||
environment,
|
||||
runId,
|
||||
};
|
||||
}
|
||||
@@ -761,7 +764,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
...(options.timeoutMs === null ? {} : { timeoutMs: options.timeoutMs ?? 1234 }),
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Reusable Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -921,7 +924,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
warmProviderLeaseId = warm.lease.providerLeaseId!;
|
||||
expect(warm.lease.leasePolicy).toBe("reuse_by_environment");
|
||||
expect(warm.lease.executionWorkspaceId).toBe(seeded.executionWorkspaceId);
|
||||
expect((await runtime.resolveCapabilities({ environment: warmEnvironment, lease: warm.lease })).reusableLeases).toBe(true);
|
||||
expect((await runtime.resolveCapabilities({ environment: warmEnvironment, lease: warm.lease }))?.reusableLeases).toBe(true);
|
||||
await runtime.releaseRunLeases(warmRunId, "released", undefined, "stop_and_retain");
|
||||
const nextRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: nextRunId, companyId: seeded.companyId, agentId: seeded.agentId, status: "running" });
|
||||
@@ -1295,7 +1298,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(rows[0].status).toBe("pending_cleanup");
|
||||
expect(deleted).toBe(fault === "deleted receipt lost");
|
||||
const restarted = environmentRuntimeService(db, { pluginWorkerManager: worker });
|
||||
await expect(restarted.retryPendingSandboxTeardown({ environment: null, lease: rows[0]! })).resolves.toEqual({ providerLeaseId: cleanup.providerLeaseId, state: "destroyed" });
|
||||
await expect(restarted.retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(rows[0]!.id))! })).resolves.toEqual({ providerLeaseId: cleanup.providerLeaseId, state: "destroyed" });
|
||||
expect(deleted).toBe(true);
|
||||
},
|
||||
);
|
||||
@@ -1410,7 +1413,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
cleanupStatus: "success",
|
||||
});
|
||||
expect(
|
||||
workerManager.call.mock.calls.filter(
|
||||
vi.mocked(workerManager.call).mock.calls.filter(
|
||||
(call) => call[1] === "environmentAcquireLease",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
@@ -1625,6 +1628,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
throw new Error("should not acquire");
|
||||
},
|
||||
releaseRunLease: async () => null,
|
||||
resolveCapabilities: async () => classifyEnvironmentCapabilities({ supportedCapabilities: new Set() }),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1659,7 +1663,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
const { companyId, environment, runId } = await seedEnvironment({
|
||||
driver: "ssh",
|
||||
name: "Fixture SSH",
|
||||
config: sshConfig,
|
||||
config: { ...sshConfig },
|
||||
});
|
||||
try {
|
||||
const acquired = await runtime.acquireRunLease({
|
||||
@@ -2571,7 +2575,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
const pluginId = randomUUID();
|
||||
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
|
||||
const pluginConfig = { provider: "fake-plugin", image: "fake:test", reuseLease: false };
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Cleanup Retry Plugin",
|
||||
driver: "sandbox",
|
||||
@@ -2653,7 +2657,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Fake Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -2769,7 +2773,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Fake Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -2791,7 +2795,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
// RPC. The real worker manager mints the provider-span traceparent from
|
||||
// exactly this value, so a non-null context with a valid parent proves the
|
||||
// teardown span keeps a host-minted parent.
|
||||
let releaseStepContext: ReturnType<typeof getActiveStepContext> = null;
|
||||
let releaseStepContext = null as ReturnType<typeof getActiveStepContext>;
|
||||
const workerManager = {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
call: vi.fn(async (_pluginId: string, method: string) => {
|
||||
@@ -2865,7 +2869,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
it("releases a plugin-backed lease with no active step context when the run drove no exec", async () => {
|
||||
const { companyId, environment, runId, pluginId } = await seedFakePluginSandbox();
|
||||
|
||||
let releaseStepContext: ReturnType<typeof getActiveStepContext> = null;
|
||||
let releaseStepContext = null as ReturnType<typeof getActiveStepContext>;
|
||||
let releaseCalled = false;
|
||||
const workerManager = {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
@@ -3059,7 +3063,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Fake Plugin Sandbox Realize",
|
||||
driver: "sandbox",
|
||||
@@ -3198,7 +3202,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Secure Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -3341,7 +3345,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Secure Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -3401,7 +3405,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Eventually Running Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -3471,7 +3475,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Never Running Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -3514,7 +3518,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
).rejects.toThrow(/worker is not running/);
|
||||
// Confirms the loop actually polled repeatedly across the deadline window
|
||||
// instead of giving up after a single check.
|
||||
expect(workerManager.isRunning.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(vi.mocked(workerManager.isRunning).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("keeps polling across a longer worker restart window and succeeds once the handle registers late", async () => {
|
||||
@@ -3526,7 +3530,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Registered Late Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -3608,7 +3612,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
...config,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Long Lease Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -3738,7 +3742,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Reusable Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4140,7 +4144,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Reusable Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4278,7 +4282,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Reusable Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4440,7 +4444,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Reusable Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4524,7 +4528,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
|
||||
const nextRunId = randomUUID();
|
||||
await insertRunningManualRun(nextRunId, companyId, agentId);
|
||||
const updatedEnvironment = {
|
||||
const updatedEnvironment: Environment = {
|
||||
...environment,
|
||||
config: {
|
||||
...providerConfig,
|
||||
@@ -4579,7 +4583,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Secure Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4740,7 +4744,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Non-reusable Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4808,7 +4812,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Nested-disabled Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -4900,7 +4904,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
};
|
||||
const environment = {
|
||||
const environment: Environment = {
|
||||
...baseEnvironment,
|
||||
name: "Unverified-worker Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
@@ -6275,6 +6279,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
},
|
||||
}),
|
||||
releaseRunLease: localRelease,
|
||||
resolveCapabilities: async () => classifyEnvironmentCapabilities({ supportedCapabilities: new Set() }),
|
||||
},
|
||||
{
|
||||
driver: "ssh",
|
||||
@@ -6282,6 +6287,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
throw new Error("ssh acquire should not be called");
|
||||
},
|
||||
releaseRunLease: sshRelease,
|
||||
resolveCapabilities: async () => classifyEnvironmentCapabilities({ supportedCapabilities: new Set() }),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6340,6 +6346,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
}
|
||||
return await environmentsSvc.releaseLease(lease.id, status);
|
||||
},
|
||||
resolveCapabilities: async () => classifyEnvironmentCapabilities({ supportedCapabilities: new Set() }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user