Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe6bc0c2d6 | |||
| 2d057f085d | |||
| 570fdae9c4 | |||
| 985d55e125 | |||
| 5e67a4dd3b |
@@ -13,6 +13,16 @@ npm run test:watch # Run vitest in watch mode
|
|||||||
|
|
||||||
Run a single test file: `npx vitest run src/server/parse.test.ts`
|
Run a single test file: `npx vitest run src/server/parse.test.ts`
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Bump `version` in `package.json`, commit, push to `master`, then push a matching tag — the CI publish job only runs on `v*` tags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v0.1.x && git push origin v0.1.x
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow verifies the tag matches `package.json` version before publishing to npm.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Job pods. It exposes three entry points:
|
This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Job pods. It exposes three entry points:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperclip-adapter-opencode-k8s",
|
"name": "paperclip-adapter-opencode-k8s",
|
||||||
"version": "0.1.34",
|
"version": "0.1.38",
|
||||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ export function getConfigSchema(): AdapterConfigSchema {
|
|||||||
hint: "Provider-specific reasoning/profile variant passed as --variant",
|
hint: "Provider-specific reasoning/profile variant passed as --variant",
|
||||||
group: "Core",
|
group: "Core",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "instructionsFilePath",
|
|
||||||
label: "Instructions File Path",
|
|
||||||
type: "text",
|
|
||||||
hint: "Absolute path to a markdown file (e.g. AGENTS.md) prepended as system instructions before the task prompt",
|
|
||||||
group: "Core",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "dangerouslySkipPermissions",
|
key: "dangerouslySkipPermissions",
|
||||||
label: "Skip Permission Checks",
|
label: "Skip Permission Checks",
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ const HAPPY_JSONL = [
|
|||||||
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
|
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}): AdapterExecutionContext {
|
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}, authToken = "test-auth-token"): AdapterExecutionContext {
|
||||||
return {
|
return {
|
||||||
runId: "run-test-123",
|
runId: "run-test-123",
|
||||||
agent: { id: "agent-id-test", name: "Test Agent", companyId: "co-1", adapterType: null, adapterConfig: null },
|
agent: { id: "agent-id-test", name: "Test Agent", companyId: "co-1", adapterType: null, adapterConfig: null },
|
||||||
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
|
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
|
||||||
config: configOverrides,
|
config: configOverrides,
|
||||||
|
authToken,
|
||||||
context: {
|
context: {
|
||||||
taskId: null,
|
taskId: null,
|
||||||
issueId: null,
|
issueId: null,
|
||||||
@@ -879,23 +880,16 @@ describe("execute — log dedup (waitForPod status dedup)", () => {
|
|||||||
describe("execute — external cancel polling", () => {
|
describe("execute — external cancel polling", () => {
|
||||||
const KEEPALIVE_MS = 15_000;
|
const KEEPALIVE_MS = 15_000;
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.PAPERCLIP_DEV_API_KEY = "test-key";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
delete process.env.PAPERCLIP_API_URL;
|
delete process.env.PAPERCLIP_API_URL;
|
||||||
delete process.env.PAPERCLIP_API_KEY;
|
|
||||||
delete process.env.PAPERCLIP_DEV_API_KEY;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
|
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
process.env.PAPERCLIP_API_URL = "http://test-api";
|
process.env.PAPERCLIP_API_URL = "http://test-api";
|
||||||
process.env.PAPERCLIP_API_KEY = "test-key";
|
|
||||||
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -918,7 +912,7 @@ describe("execute — external cancel polling", () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
|
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
|
||||||
|
|
||||||
const ctx = makeCtx({}, { issueId: "issue-test-456" });
|
const ctx = makeCtx({}, { issueId: "issue-test-456" }, "run-jwt-token");
|
||||||
const executePromise = execute(ctx);
|
const executePromise = execute(ctx);
|
||||||
|
|
||||||
// Advance in 1-second steps. vi.advanceTimersByTimeAsync fires fake timers
|
// Advance in 1-second steps. vi.advanceTimersByTimeAsync fires fake timers
|
||||||
@@ -939,7 +933,7 @@ describe("execute — external cancel polling", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
"http://test-api/api/issues/issue-test-456",
|
"http://test-api/api/issues/issue-test-456",
|
||||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-key" }) }),
|
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer run-jwt-token" }) }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -958,7 +952,6 @@ describe("execute — external cancel polling", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
process.env.PAPERCLIP_API_URL = "http://test-api";
|
process.env.PAPERCLIP_API_URL = "http://test-api";
|
||||||
process.env.PAPERCLIP_API_KEY = "test-key";
|
|
||||||
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -569,9 +569,7 @@ async function streamAndAwaitJob(
|
|||||||
await new Promise<void>((resolve) => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
|
await new Promise<void>((resolve) => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
|
||||||
if (logStopSignal.stopped || cancelSignal.cancelled) break;
|
if (logStopSignal.stopped || cancelSignal.cancelled) break;
|
||||||
try {
|
try {
|
||||||
// Prefer PAPERCLIP_DEV_API_KEY if set (allows dev instance key to be
|
const apiKey = ctx.authToken ?? "";
|
||||||
// distinct from the main-instance run JWT in PAPERCLIP_API_KEY).
|
|
||||||
const apiKey = process.env.PAPERCLIP_DEV_API_KEY ?? process.env.PAPERCLIP_API_KEY ?? "";
|
|
||||||
const resp = await fetch(`${apiUrl}/api/issues/${issueId}`, {
|
const resp = await fetch(`${apiUrl}/api/issues/${issueId}`, {
|
||||||
headers: { Authorization: `Bearer ${apiKey}` },
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
||||||
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||||
import { type, agentConfigurationDoc } from "../index.js";
|
import { type, agentConfigurationDoc } from "../index.js";
|
||||||
import { listK8sModels } from "./models.js";
|
import { listK8sModels, STATIC_MODELS } from "./models.js";
|
||||||
import { execute } from "./execute.js";
|
import { execute } from "./execute.js";
|
||||||
import { testEnvironment } from "./test.js";
|
import { testEnvironment } from "./test.js";
|
||||||
import { sessionCodec } from "./session.js";
|
import { sessionCodec } from "./session.js";
|
||||||
@@ -14,6 +14,7 @@ export function createServerAdapter(): ServerAdapterModule {
|
|||||||
execute,
|
execute,
|
||||||
testEnvironment,
|
testEnvironment,
|
||||||
sessionCodec,
|
sessionCodec,
|
||||||
|
models: STATIC_MODELS,
|
||||||
listModels: listK8sModels,
|
listModels: listK8sModels,
|
||||||
listSkills: listOpenCodeSkills,
|
listSkills: listOpenCodeSkills,
|
||||||
syncSkills: syncOpenCodeSkills,
|
syncSkills: syncOpenCodeSkills,
|
||||||
|
|||||||
@@ -484,15 +484,14 @@ describe("buildJobManifest — env wiring branches", () => {
|
|||||||
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
|
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inherits PAPERCLIP_API_URL and PAPERCLIP_DEV_API_KEY from selfPod inheritedEnv", () => {
|
it("inherits PAPERCLIP_API_URL from selfPod inheritedEnv", () => {
|
||||||
const selfPod = {
|
const selfPod = {
|
||||||
...mockSelfPod,
|
...mockSelfPod,
|
||||||
inheritedEnv: { PAPERCLIP_API_URL: "http://api", PAPERCLIP_DEV_API_KEY: "dev_key" },
|
inheritedEnv: { PAPERCLIP_API_URL: "http://api" },
|
||||||
};
|
};
|
||||||
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
||||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||||
expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api");
|
expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api");
|
||||||
expect(env.find((e) => e.name === "PAPERCLIP_DEV_API_KEY")?.value).toBe("dev_key");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -173,13 +173,6 @@ function buildEnvVars(
|
|||||||
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
|
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
|
||||||
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
|
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
|
||||||
}
|
}
|
||||||
// Inherit PAPERCLIP_DEV_API_KEY if set (dev-instance key, distinct from the
|
|
||||||
// main-instance run JWT in PAPERCLIP_API_KEY). Used by the external cancel
|
|
||||||
// polling in execute.ts to authenticate against the dev Paperclip instance.
|
|
||||||
if (selfPod.inheritedEnv.PAPERCLIP_DEV_API_KEY) {
|
|
||||||
paperclipEnv.PAPERCLIP_DEV_API_KEY = selfPod.inheritedEnv.PAPERCLIP_DEV_API_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
|
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
|
||||||
const merged: Record<string, string> = {
|
const merged: Record<string, string> = {
|
||||||
...selfPod.inheritedEnv,
|
...selfPod.inheritedEnv,
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import os from "node:os";
|
|||||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||||
import { asString, ensurePathInEnv, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
import { asString, ensurePathInEnv, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
export const STATIC_MODELS: AdapterModel[] = [
|
||||||
|
{ id: "anthropic/claude-opus-4-7", label: "anthropic/claude-opus-4-7" },
|
||||||
|
{ id: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
|
||||||
|
{ id: "anthropic/claude-haiku-4-5", label: "anthropic/claude-haiku-4-5" },
|
||||||
|
{ id: "openai/gpt-4o", label: "openai/gpt-4o" },
|
||||||
|
{ id: "google/gemini-2.5-pro", label: "google/gemini-2.5-pro" },
|
||||||
|
{ id: "google/gemini-2.5-flash", label: "google/gemini-2.5-flash" },
|
||||||
|
];
|
||||||
|
|
||||||
const MODELS_CACHE_TTL_MS = 60_000;
|
const MODELS_CACHE_TTL_MS = 60_000;
|
||||||
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
|
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ describe("createServerAdapter", () => {
|
|||||||
expect(adapter.type).toBe("opencode_k8s");
|
expect(adapter.type).toBe("opencode_k8s");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes a non-empty static models list so the UI renders before listModels resolves", () => {
|
||||||
|
const adapter = createServerAdapter();
|
||||||
|
expect(Array.isArray(adapter.models)).toBe(true);
|
||||||
|
expect(adapter.models!.length).toBeGreaterThan(0);
|
||||||
|
for (const m of adapter.models!) {
|
||||||
|
expect(m.id).toMatch(/^[^/]+\/.+/);
|
||||||
|
expect(m.label).toBe(m.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes listModels for dynamic model discovery", () => {
|
it("exposes listModels for dynamic model discovery", () => {
|
||||||
const adapter = createServerAdapter();
|
const adapter = createServerAdapter();
|
||||||
expect(typeof adapter.listModels).toBe("function");
|
expect(typeof adapter.listModels).toBe("function");
|
||||||
|
|||||||
Reference in New Issue
Block a user