2026-03-20 16:26:29 -05:00
|
|
|
import { createHmac, randomUUID } from "node:crypto";
|
2026-03-20 07:21:38 -05:00
|
|
|
import { eq } from "drizzle-orm";
|
|
|
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
|
import {
|
2026-03-20 16:26:29 -05:00
|
|
|
activityLog,
|
2026-03-20 07:21:38 -05:00
|
|
|
agents,
|
|
|
|
|
companies,
|
2026-03-20 16:26:29 -05:00
|
|
|
companySecrets,
|
|
|
|
|
companySecretVersions,
|
2026-03-20 07:21:38 -05:00
|
|
|
createDb,
|
2026-04-02 11:38:57 -05:00
|
|
|
executionWorkspaces,
|
2026-03-20 07:21:38 -05:00
|
|
|
heartbeatRuns,
|
2026-04-02 11:38:57 -05:00
|
|
|
instanceSettings,
|
2026-04-27 20:03:24 -05:00
|
|
|
issueInboxArchives,
|
|
|
|
|
issueReadStates,
|
2026-03-20 07:21:38 -05:00
|
|
|
issues,
|
2026-04-02 11:38:57 -05:00
|
|
|
projectWorkspaces,
|
2026-03-20 07:21:38 -05:00
|
|
|
projects,
|
|
|
|
|
routineRuns,
|
|
|
|
|
routines,
|
2026-03-20 16:26:29 -05:00
|
|
|
routineTriggers,
|
2026-03-20 07:21:38 -05:00
|
|
|
} from "@paperclipai/db";
|
2026-03-26 11:04:07 -05:00
|
|
|
import {
|
|
|
|
|
getEmbeddedPostgresTestSupport,
|
|
|
|
|
startEmbeddedPostgresTestDatabase,
|
|
|
|
|
} from "./helpers/embedded-postgres.js";
|
2026-03-20 07:21:38 -05:00
|
|
|
import { issueService } from "../services/issues.ts";
|
2026-04-02 11:38:57 -05:00
|
|
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
2026-03-20 07:21:38 -05:00
|
|
|
import { routineService } from "../services/routines.ts";
|
|
|
|
|
|
2026-03-26 11:04:07 -05:00
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
|
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
2026-03-20 07:21:38 -05:00
|
|
|
|
2026-03-26 11:04:07 -05:00
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
|
|
|
);
|
2026-03-20 07:21:38 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 11:04:07 -05:00
|
|
|
describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
2026-03-20 07:21:38 -05:00
|
|
|
let db!: ReturnType<typeof createDb>;
|
2026-03-26 11:04:07 -05:00
|
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
2026-03-20 07:21:38 -05:00
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
2026-03-26 11:04:07 -05:00
|
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
|
|
|
|
|
db = createDb(tempDb.connectionString);
|
2026-03-20 07:21:38 -05:00
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
2026-03-20 16:26:29 -05:00
|
|
|
await db.delete(activityLog);
|
2026-04-27 20:03:24 -05:00
|
|
|
await db.delete(issueInboxArchives);
|
|
|
|
|
await db.delete(issueReadStates);
|
2026-03-20 07:21:38 -05:00
|
|
|
await db.delete(routineRuns);
|
2026-03-20 16:26:29 -05:00
|
|
|
await db.delete(routineTriggers);
|
2026-03-20 07:21:38 -05:00
|
|
|
await db.delete(routines);
|
2026-03-20 16:26:29 -05:00
|
|
|
await db.delete(companySecretVersions);
|
|
|
|
|
await db.delete(companySecrets);
|
2026-03-20 07:21:38 -05:00
|
|
|
await db.delete(heartbeatRuns);
|
|
|
|
|
await db.delete(issues);
|
2026-04-02 11:38:57 -05:00
|
|
|
await db.delete(executionWorkspaces);
|
|
|
|
|
await db.delete(projectWorkspaces);
|
2026-03-20 07:21:38 -05:00
|
|
|
await db.delete(projects);
|
|
|
|
|
await db.delete(agents);
|
|
|
|
|
await db.delete(companies);
|
2026-04-02 11:38:57 -05:00
|
|
|
await db.delete(instanceSettings);
|
2026-03-20 07:21:38 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
2026-03-26 11:04:07 -05:00
|
|
|
await tempDb?.cleanup();
|
2026-03-20 07:21:38 -05:00
|
|
|
});
|
|
|
|
|
|
2026-03-20 08:58:24 -05:00
|
|
|
async function seedFixture(opts?: {
|
|
|
|
|
wakeup?: (
|
|
|
|
|
agentId: string,
|
|
|
|
|
wakeupOpts: {
|
|
|
|
|
source?: string;
|
|
|
|
|
triggerDetail?: string;
|
|
|
|
|
reason?: string | null;
|
|
|
|
|
payload?: Record<string, unknown> | null;
|
|
|
|
|
requestedByActorType?: "user" | "agent" | "system";
|
|
|
|
|
requestedByActorId?: string | null;
|
|
|
|
|
contextSnapshot?: Record<string, unknown>;
|
|
|
|
|
},
|
|
|
|
|
) => Promise<unknown>;
|
|
|
|
|
}) {
|
2026-03-20 07:21:38 -05:00
|
|
|
const companyId = randomUUID();
|
|
|
|
|
const agentId = randomUUID();
|
|
|
|
|
const projectId = randomUUID();
|
|
|
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
2026-03-20 08:11:19 -05:00
|
|
|
const wakeups: Array<{
|
|
|
|
|
agentId: string;
|
|
|
|
|
opts: {
|
|
|
|
|
source?: string;
|
|
|
|
|
triggerDetail?: string;
|
|
|
|
|
reason?: string | null;
|
|
|
|
|
payload?: Record<string, unknown> | null;
|
|
|
|
|
requestedByActorType?: "user" | "agent" | "system";
|
|
|
|
|
requestedByActorId?: string | null;
|
|
|
|
|
contextSnapshot?: Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
}> = [];
|
2026-03-20 07:21:38 -05:00
|
|
|
|
|
|
|
|
await db.insert(companies).values({
|
|
|
|
|
id: companyId,
|
|
|
|
|
name: "Paperclip",
|
|
|
|
|
issuePrefix,
|
|
|
|
|
requireBoardApprovalForNewAgents: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(agents).values({
|
|
|
|
|
id: agentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "CodexCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: projectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Routines",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 08:11:19 -05:00
|
|
|
const svc = routineService(db, {
|
|
|
|
|
heartbeat: {
|
2026-03-20 08:58:24 -05:00
|
|
|
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
|
|
|
|
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
|
2026-03-20 16:15:32 -05:00
|
|
|
if (opts?.wakeup) return opts.wakeup(wakeupAgentId, wakeupOpts);
|
|
|
|
|
const issueId =
|
|
|
|
|
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
|
|
|
|
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
|
|
|
|
null;
|
|
|
|
|
if (!issueId) return null;
|
|
|
|
|
const queuedRunId = randomUUID();
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: queuedRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
agentId: wakeupAgentId,
|
|
|
|
|
invocationSource: wakeupOpts.source ?? "assignment",
|
|
|
|
|
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
|
|
|
|
status: "queued",
|
|
|
|
|
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
|
|
|
|
});
|
|
|
|
|
await db
|
|
|
|
|
.update(issues)
|
|
|
|
|
.set({
|
|
|
|
|
executionRunId: queuedRunId,
|
|
|
|
|
executionLockedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(issues.id, issueId));
|
|
|
|
|
return { id: queuedRunId };
|
2026-03-20 08:11:19 -05:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-03-20 07:21:38 -05:00
|
|
|
const issueSvc = issueService(db);
|
|
|
|
|
const routine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "ascii frog",
|
|
|
|
|
description: "Run the frog routine",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-20 08:11:19 -05:00
|
|
|
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
2026-03-20 07:21:38 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 11:58:15 -05:00
|
|
|
it("filters listed routines by project", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, routine, svc } = await seedFixture();
|
|
|
|
|
const otherProjectId = randomUUID();
|
|
|
|
|
await db.insert(projects).values({
|
|
|
|
|
id: otherProjectId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "Other routines",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
});
|
|
|
|
|
const otherRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId: otherProjectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "other project routine",
|
|
|
|
|
description: null,
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const projectRoutines = await svc.list(companyId, { projectId });
|
|
|
|
|
const allRoutines = await svc.list(companyId);
|
|
|
|
|
|
|
|
|
|
expect(projectRoutines.map((entry) => entry.id)).toEqual([routine.id]);
|
|
|
|
|
expect(allRoutines.map((entry) => entry.id)).toEqual(expect.arrayContaining([routine.id, otherRoutine.id]));
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 07:21:38 -05:00
|
|
|
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
|
|
|
|
const { companyId, issueSvc, routine, svc } = await seedFixture();
|
|
|
|
|
const previousRunId = randomUUID();
|
|
|
|
|
const previousIssue = await issueSvc.create(companyId, {
|
|
|
|
|
projectId: routine.projectId,
|
|
|
|
|
title: routine.title,
|
|
|
|
|
description: routine.description,
|
|
|
|
|
status: "todo",
|
|
|
|
|
priority: routine.priority,
|
|
|
|
|
assigneeAgentId: routine.assigneeAgentId,
|
|
|
|
|
originKind: "routine_execution",
|
|
|
|
|
originId: routine.id,
|
|
|
|
|
originRunId: previousRunId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(routineRuns).values({
|
|
|
|
|
id: previousRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
routineId: routine.id,
|
|
|
|
|
triggerId: null,
|
|
|
|
|
source: "manual",
|
|
|
|
|
status: "issue_created",
|
|
|
|
|
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
|
|
|
|
linkedIssueId: previousIssue.id,
|
|
|
|
|
completedAt: new Date("2026-03-20T12:00:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const detailBefore = await svc.getDetail(routine.id);
|
|
|
|
|
expect(detailBefore?.activeIssue).toBeNull();
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
expect(run.linkedIssueId).not.toBe(previousIssue.id);
|
|
|
|
|
|
|
|
|
|
const routineIssues = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: issues.id,
|
|
|
|
|
originRunId: issues.originRunId,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.originId, routine.id));
|
|
|
|
|
|
|
|
|
|
expect(routineIssues).toHaveLength(2);
|
|
|
|
|
expect(routineIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
|
|
|
|
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 10:19:52 -05:00
|
|
|
it("creates draft routines without a project or default assignee", async () => {
|
|
|
|
|
const { companyId, svc } = await seedFixture();
|
|
|
|
|
|
|
|
|
|
const routine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId: null,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "draft routine",
|
|
|
|
|
description: "No defaults yet",
|
|
|
|
|
assigneeAgentId: null,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(routine.projectId).toBeNull();
|
|
|
|
|
expect(routine.assigneeAgentId).toBeNull();
|
|
|
|
|
expect(routine.status).toBe("paused");
|
|
|
|
|
});
|
|
|
|
|
|
Add routine revision history and restore flow (#5285)
## Thinking Path
> - Paperclip is the control plane for autonomous AI companies.
> - Routines are the scheduled/recurring work surface that keeps a
company operating without manual kicks.
> - Operators need routine edits to be auditable and recoverable,
especially when routines control assignments, prompts, triggers, and
webhook secrets.
> - Documents already have revision-style safety, but routines did not
have equivalent history or restore semantics.
> - This pull request adds append-only routine revisions across the
database, shared contracts, server routes, and board UI.
> - The benefit is safer routine iteration: users can inspect history,
compare changes, restore older definitions, and avoid overwriting newer
edits.
## What Changed
- Added `routine_revisions` storage, latest revision pointers on
routines, shared types, validators, and API docs for routine revision
history.
- Added server service/route support for listing routine revisions,
conflict-aware routine saves, and append-only restore operations.
- Added a History tab on routine detail with revision preview,
structured change summaries, description line diffs, dirty-edit
blocking, restore confirmation, and restored webhook secret surfacing.
- Extracted the line diff helper from `DocumentDiffModal` into
`ui/src/lib/line-diff.ts` for reuse.
- Rebased the branch onto current `public-gh/master` and renumbered the
routine revision migration to `0077_unusual_karnak` after upstream
`0076_useful_elektra`.
- Made the `0077` routine revision migration idempotent so installs that
already applied the branch-local `0076_unusual_karnak` can safely
advance.
- Updated the plugin SDK test harness routine fixture with the new
revision fields required by the shared `Routine` contract.
## Verification
- `pnpm --filter @paperclipai/db run check:migrations` passed.
- `pnpm exec vitest run --project @paperclipai/shared
packages/shared/src/validators/routine.test.ts` passed.
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/lib/line-diff.test.ts
ui/src/components/RoutineHistoryTab.test.tsx
ui/src/lib/workspace-routines.test.ts ui/src/pages/Routines.test.tsx`
passed.
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/routines-service.test.ts --pool=forks
--poolOptions.forks.isolate=true` passed.
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/routines-routes.test.ts --pool=forks
--poolOptions.forks.isolate=true` passed.
- `pnpm --filter @paperclipai/plugin-sdk typecheck` passed after
updating the SDK test harness fixture.
- `pnpm --filter @paperclipai/plugin-sdk build` passed; this refreshed
local generated SDK output needed by plugin example typechecks.
- `pnpm -r typecheck` passed.
## Risks
- Medium migration risk: this adds routine revision storage and
backfills existing routines. The migration is ordered after upstream
`0076` and uses `IF NOT EXISTS` / duplicate-object guards to tolerate
earlier branch-local migration application.
- Restore behavior intentionally appends a new revision instead of
mutating history; callers expecting an in-place rollback need to follow
the new latest revision pointer.
- Restoring webhook triggers recreates webhook secret material, so users
must copy newly surfaced secrets after restore.
- Conflict-aware saves now reject stale routine edits when the client
sends an older `baseRevisionId`.
> 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
- OpenAI Codex, GPT-5-based coding agent, with shell/tool use in a local
git worktree. Exact context-window size is not exposed in this runtime.
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
Screenshots: not attached in this draft PR; the new UI flow is covered
by component tests listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-05 11:54:52 -05:00
|
|
|
it("creates revision 1 on routine create and appends revisions for real updates only", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
|
|
|
|
|
const initialRevisions = await svc.listRevisions(routine.id);
|
|
|
|
|
expect(initialRevisions).toHaveLength(1);
|
|
|
|
|
expect(initialRevisions[0]).toMatchObject({
|
|
|
|
|
id: routine.latestRevisionId,
|
|
|
|
|
revisionNumber: 1,
|
|
|
|
|
title: "ascii frog",
|
|
|
|
|
changeSummary: "Created routine",
|
|
|
|
|
});
|
|
|
|
|
expect(initialRevisions[0]?.snapshot.routine.description).toBe("Run the frog routine");
|
|
|
|
|
|
|
|
|
|
const updated = await svc.update(
|
|
|
|
|
routine.id,
|
|
|
|
|
{
|
|
|
|
|
description: "Run the frog routine with logs",
|
|
|
|
|
baseRevisionId: routine.latestRevisionId,
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
expect(updated?.latestRevisionNumber).toBe(2);
|
|
|
|
|
expect(updated?.latestRevisionId).not.toBe(routine.latestRevisionId);
|
|
|
|
|
|
|
|
|
|
const noOp = await svc.update(
|
|
|
|
|
routine.id,
|
|
|
|
|
{
|
|
|
|
|
description: "Run the frog routine with logs",
|
|
|
|
|
baseRevisionId: updated?.latestRevisionId,
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
expect(noOp?.latestRevisionId).toBe(updated?.latestRevisionId);
|
|
|
|
|
expect(noOp?.latestRevisionNumber).toBe(2);
|
|
|
|
|
|
|
|
|
|
const revisions = await svc.listRevisions(routine.id);
|
|
|
|
|
expect(revisions.map((revision) => revision.revisionNumber)).toEqual([2, 1]);
|
|
|
|
|
expect(revisions[0]?.snapshot.routine.description).toBe("Run the frog routine with logs");
|
|
|
|
|
expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects stale routine baseRevisionId updates", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const updated = await svc.update(routine.id, { description: "new description" }, {});
|
|
|
|
|
await expect(
|
|
|
|
|
svc.update(routine.id, {
|
|
|
|
|
title: "stale update",
|
|
|
|
|
baseRevisionId: routine.latestRevisionId,
|
|
|
|
|
}, {}),
|
|
|
|
|
).rejects.toMatchObject({
|
|
|
|
|
status: 409,
|
|
|
|
|
details: {
|
|
|
|
|
currentRevisionId: updated?.latestRevisionId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("restores an older routine revision append-only and preserves run history", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const revision1Id = routine.latestRevisionId!;
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
|
|
|
|
const revision2Routine = await svc.update(routine.id, { description: "revision 2" }, {});
|
|
|
|
|
|
|
|
|
|
const restored = await svc.restoreRevision(routine.id, revision1Id, {});
|
|
|
|
|
|
|
|
|
|
expect(restored.restoredFromRevisionId).toBe(revision1Id);
|
|
|
|
|
expect(restored.restoredFromRevisionNumber).toBe(1);
|
|
|
|
|
expect(restored.routine.latestRevisionNumber).toBe(3);
|
|
|
|
|
expect(restored.routine.latestRevisionId).not.toBe(revision2Routine?.latestRevisionId);
|
|
|
|
|
expect(restored.routine.description).toBe("Run the frog routine");
|
|
|
|
|
expect(restored.revision.restoredFromRevisionId).toBe(revision1Id);
|
|
|
|
|
expect(restored.revision.snapshot.routine.description).toBe("Run the frog routine");
|
|
|
|
|
|
|
|
|
|
const revisions = await svc.listRevisions(routine.id);
|
|
|
|
|
expect(revisions.map((revision) => revision.revisionNumber)).toEqual([3, 2, 1]);
|
|
|
|
|
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, run.id))).resolves.toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects restoring the current latest routine revision", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.restoreRevision(routine.id, routine.latestRevisionId!, {}),
|
|
|
|
|
).rejects.toMatchObject({
|
|
|
|
|
status: 409,
|
|
|
|
|
details: {
|
|
|
|
|
currentRevisionId: routine.latestRevisionId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("recreates deleted webhook trigger secrets when restoring a historical revision", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const created = await svc.createTrigger(routine.id, {
|
|
|
|
|
kind: "webhook",
|
|
|
|
|
signingMode: "bearer",
|
|
|
|
|
replayWindowSec: 300,
|
|
|
|
|
}, {});
|
|
|
|
|
await svc.deleteTrigger(created.trigger.id, {});
|
|
|
|
|
|
|
|
|
|
const restored = await svc.restoreRevision(routine.id, created.revision.id, {});
|
|
|
|
|
|
|
|
|
|
expect(restored.secretMaterials).toHaveLength(1);
|
|
|
|
|
expect(restored.secretMaterials[0]).toMatchObject({
|
|
|
|
|
triggerId: created.trigger.id,
|
|
|
|
|
});
|
|
|
|
|
expect(restored.secretMaterials[0]?.webhookSecret).toBeTruthy();
|
|
|
|
|
expect(restored.secretMaterials[0]?.webhookUrl).toContain("/api/routine-triggers/public/");
|
|
|
|
|
|
|
|
|
|
const restoredTrigger = await svc.getTrigger(created.trigger.id);
|
|
|
|
|
expect(restoredTrigger?.secretId).toBeTruthy();
|
|
|
|
|
expect(restoredTrigger?.publicId).toBeTruthy();
|
|
|
|
|
expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("blocks agents from restoring routine revisions assigned to another agent", async () => {
|
|
|
|
|
const { companyId, routine, svc } = await seedFixture();
|
|
|
|
|
const otherAgentId = randomUUID();
|
|
|
|
|
await db.insert(agents).values({
|
|
|
|
|
id: otherAgentId,
|
|
|
|
|
companyId,
|
|
|
|
|
name: "OtherCoder",
|
|
|
|
|
role: "engineer",
|
|
|
|
|
status: "active",
|
|
|
|
|
adapterType: "codex_local",
|
|
|
|
|
adapterConfig: {},
|
|
|
|
|
runtimeConfig: {},
|
|
|
|
|
permissions: {},
|
|
|
|
|
});
|
|
|
|
|
const revision1Id = routine.latestRevisionId!;
|
|
|
|
|
|
|
|
|
|
await svc.update(routine.id, { assigneeAgentId: otherAgentId }, {});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.restoreRevision(routine.id, revision1Id, { agentId: otherAgentId }),
|
|
|
|
|
).rejects.toMatchObject({
|
|
|
|
|
status: 403,
|
|
|
|
|
message: "Agents can only restore routine revisions assigned to themselves",
|
|
|
|
|
});
|
|
|
|
|
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
|
|
|
|
assigneeAgentId: otherAgentId,
|
|
|
|
|
latestRevisionNumber: 2,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("blocks restoring routine revisions assigned to agents that are no longer assignable", async () => {
|
|
|
|
|
const { agentId, routine, svc } = await seedFixture();
|
|
|
|
|
const revision1Id = routine.latestRevisionId!;
|
|
|
|
|
await svc.update(routine.id, { description: "revision 2" }, {});
|
|
|
|
|
await db
|
|
|
|
|
.update(agents)
|
|
|
|
|
.set({ status: "terminated" })
|
|
|
|
|
.where(eq(agents.id, agentId));
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.restoreRevision(routine.id, revision1Id, { userId: "board-user" }),
|
|
|
|
|
).rejects.toMatchObject({
|
|
|
|
|
status: 409,
|
|
|
|
|
message: "Cannot assign routines to terminated agents",
|
|
|
|
|
});
|
|
|
|
|
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
|
|
|
|
description: "revision 2",
|
|
|
|
|
latestRevisionNumber: 2,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("appends safe trigger metadata revisions without leaking webhook secrets", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const created = await svc.createTrigger(routine.id, {
|
|
|
|
|
kind: "webhook",
|
|
|
|
|
signingMode: "bearer",
|
|
|
|
|
replayWindowSec: 300,
|
|
|
|
|
}, {});
|
|
|
|
|
expect(created.revision.revisionNumber).toBe(2);
|
|
|
|
|
expect(created.secretMaterial?.webhookSecret).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
const updated = await svc.updateTrigger(created.trigger.id, { label: "deploy hook" }, {});
|
|
|
|
|
expect(updated?.revision.revisionNumber).toBe(3);
|
|
|
|
|
|
|
|
|
|
const rotated = await svc.rotateTriggerSecret(created.trigger.id, {});
|
|
|
|
|
expect(rotated.revision.revisionNumber).toBe(4);
|
|
|
|
|
expect(rotated.secretMaterial.webhookSecret).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
const deleted = await svc.deleteTrigger(created.trigger.id, {});
|
|
|
|
|
expect(deleted.revision?.revisionNumber).toBe(5);
|
|
|
|
|
|
|
|
|
|
const revisions = await svc.listRevisions(routine.id);
|
|
|
|
|
const serialized = JSON.stringify(revisions.map((revision) => revision.snapshot));
|
|
|
|
|
expect(serialized).toContain(created.trigger.publicId!);
|
|
|
|
|
expect(serialized).not.toContain(created.secretMaterial!.webhookSecret);
|
|
|
|
|
expect(serialized).not.toContain(rotated.secretMaterial.webhookSecret);
|
|
|
|
|
expect(serialized).not.toContain(created.trigger.secretId!);
|
|
|
|
|
expect(revisions[0]?.snapshot.triggers).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 08:11:19 -05:00
|
|
|
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
|
|
|
|
const { agentId, routine, svc, wakeups } = await seedFixture();
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
expect(run.linkedIssueId).toBeTruthy();
|
|
|
|
|
expect(wakeups).toEqual([
|
|
|
|
|
{
|
|
|
|
|
agentId,
|
|
|
|
|
opts: {
|
|
|
|
|
source: "assignment",
|
|
|
|
|
triggerDetail: "system",
|
|
|
|
|
reason: "issue_assigned",
|
|
|
|
|
payload: { issueId: run.linkedIssueId, mutation: "create" },
|
|
|
|
|
requestedByActorType: undefined,
|
|
|
|
|
requestedByActorId: null,
|
|
|
|
|
contextSnapshot: { issueId: run.linkedIssueId, source: "routine.dispatch" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-27 20:03:24 -05:00
|
|
|
it("records the manual board runner on fresh routine issues so they appear in that user's inbox", async () => {
|
|
|
|
|
const { companyId, agentId, issueSvc, routine, svc } = await seedFixture();
|
|
|
|
|
const userId = randomUUID();
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" }, { userId });
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
expect(run.linkedIssueId).toBeTruthy();
|
|
|
|
|
const [createdIssue] = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: issues.id,
|
|
|
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
|
|
|
createdByUserId: issues.createdByUserId,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, run.linkedIssueId!));
|
|
|
|
|
expect(createdIssue).toMatchObject({
|
|
|
|
|
id: run.linkedIssueId,
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
createdByUserId: userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const inboxIssues = await issueSvc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
includeRoutineExecutions: true,
|
|
|
|
|
});
|
|
|
|
|
expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 08:58:24 -05:00
|
|
|
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
|
|
|
|
|
let wakeupResolved = false;
|
|
|
|
|
const { routine, svc } = await seedFixture({
|
|
|
|
|
wakeup: async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
|
wakeupResolved = true;
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
expect(wakeupResolved).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 07:21:38 -05:00
|
|
|
it("coalesces only when the existing routine issue has a live execution run", async () => {
|
|
|
|
|
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
|
|
|
|
const previousRunId = randomUUID();
|
|
|
|
|
const liveHeartbeatRunId = randomUUID();
|
|
|
|
|
const previousIssue = await issueSvc.create(companyId, {
|
|
|
|
|
projectId: routine.projectId,
|
|
|
|
|
title: routine.title,
|
|
|
|
|
description: routine.description,
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: routine.priority,
|
|
|
|
|
assigneeAgentId: routine.assigneeAgentId,
|
|
|
|
|
originKind: "routine_execution",
|
|
|
|
|
originId: routine.id,
|
|
|
|
|
originRunId: previousRunId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(routineRuns).values({
|
|
|
|
|
id: previousRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
routineId: routine.id,
|
|
|
|
|
triggerId: null,
|
|
|
|
|
source: "manual",
|
|
|
|
|
status: "issue_created",
|
|
|
|
|
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
|
|
|
|
linkedIssueId: previousIssue.id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: liveHeartbeatRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
agentId,
|
|
|
|
|
invocationSource: "assignment",
|
|
|
|
|
triggerDetail: "system",
|
|
|
|
|
status: "running",
|
|
|
|
|
contextSnapshot: { issueId: previousIssue.id },
|
|
|
|
|
startedAt: new Date("2026-03-20T12:01:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(issues)
|
|
|
|
|
.set({
|
|
|
|
|
checkoutRunId: liveHeartbeatRunId,
|
|
|
|
|
executionRunId: liveHeartbeatRunId,
|
|
|
|
|
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(issues.id, previousIssue.id));
|
|
|
|
|
|
|
|
|
|
const detailBefore = await svc.getDetail(routine.id);
|
|
|
|
|
expect(detailBefore?.activeIssue?.id).toBe(previousIssue.id);
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
|
|
|
|
expect(run.status).toBe("coalesced");
|
|
|
|
|
expect(run.linkedIssueId).toBe(previousIssue.id);
|
|
|
|
|
expect(run.coalescedIntoRunId).toBe(previousRunId);
|
|
|
|
|
|
|
|
|
|
const routineIssues = await db
|
|
|
|
|
.select({ id: issues.id })
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.originId, routine.id));
|
|
|
|
|
|
|
|
|
|
expect(routineIssues).toHaveLength(1);
|
|
|
|
|
expect(routineIssues[0]?.id).toBe(previousIssue.id);
|
|
|
|
|
});
|
2026-03-20 16:15:32 -05:00
|
|
|
|
2026-04-27 20:03:24 -05:00
|
|
|
it("touches a coalesced routine issue for the manual runner's inbox", async () => {
|
|
|
|
|
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
|
|
|
|
const userId = randomUUID();
|
|
|
|
|
const previousRunId = randomUUID();
|
|
|
|
|
const liveHeartbeatRunId = randomUUID();
|
|
|
|
|
const previousIssue = await issueSvc.create(companyId, {
|
|
|
|
|
projectId: routine.projectId,
|
|
|
|
|
title: routine.title,
|
|
|
|
|
description: routine.description,
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: routine.priority,
|
|
|
|
|
assigneeAgentId: routine.assigneeAgentId,
|
|
|
|
|
originKind: "routine_execution",
|
|
|
|
|
originId: routine.id,
|
|
|
|
|
originRunId: previousRunId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(routineRuns).values({
|
|
|
|
|
id: previousRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
routineId: routine.id,
|
|
|
|
|
triggerId: null,
|
|
|
|
|
source: "manual",
|
|
|
|
|
status: "issue_created",
|
|
|
|
|
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
|
|
|
|
linkedIssueId: previousIssue.id,
|
|
|
|
|
});
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: liveHeartbeatRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
agentId,
|
|
|
|
|
invocationSource: "assignment",
|
|
|
|
|
triggerDetail: "system",
|
|
|
|
|
status: "running",
|
|
|
|
|
contextSnapshot: { issueId: previousIssue.id },
|
|
|
|
|
startedAt: new Date("2026-03-20T12:01:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
await db
|
|
|
|
|
.update(issues)
|
|
|
|
|
.set({
|
|
|
|
|
checkoutRunId: liveHeartbeatRunId,
|
|
|
|
|
executionRunId: liveHeartbeatRunId,
|
|
|
|
|
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(issues.id, previousIssue.id));
|
|
|
|
|
await db.insert(issueInboxArchives).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: previousIssue.id,
|
|
|
|
|
userId,
|
|
|
|
|
archivedAt: new Date("2026-03-20T12:02:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" }, { userId });
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("coalesced");
|
|
|
|
|
expect(run.linkedIssueId).toBe(previousIssue.id);
|
|
|
|
|
await expect(
|
|
|
|
|
db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)),
|
|
|
|
|
).resolves.toHaveLength(0);
|
|
|
|
|
await expect(
|
|
|
|
|
db.select().from(issueReadStates).where(eq(issueReadStates.issueId, previousIssue.id)),
|
|
|
|
|
).resolves.toEqual([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: previousIssue.id,
|
|
|
|
|
userId,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const inboxIssues = await issueSvc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
includeRoutineExecutions: true,
|
|
|
|
|
});
|
|
|
|
|
expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("touches a skipped active routine issue for the manual runner's inbox", async () => {
|
|
|
|
|
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
|
|
|
|
const userId = randomUUID();
|
|
|
|
|
const previousRunId = randomUUID();
|
|
|
|
|
const liveHeartbeatRunId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(routines)
|
|
|
|
|
.set({ concurrencyPolicy: "skip_if_active" })
|
|
|
|
|
.where(eq(routines.id, routine.id));
|
|
|
|
|
|
|
|
|
|
const previousIssue = await issueSvc.create(companyId, {
|
|
|
|
|
projectId: routine.projectId,
|
|
|
|
|
title: routine.title,
|
|
|
|
|
description: routine.description,
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
priority: routine.priority,
|
|
|
|
|
assigneeAgentId: routine.assigneeAgentId,
|
|
|
|
|
originKind: "routine_execution",
|
|
|
|
|
originId: routine.id,
|
|
|
|
|
originRunId: previousRunId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(routineRuns).values({
|
|
|
|
|
id: previousRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
routineId: routine.id,
|
|
|
|
|
triggerId: null,
|
|
|
|
|
source: "manual",
|
|
|
|
|
status: "issue_created",
|
|
|
|
|
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
|
|
|
|
linkedIssueId: previousIssue.id,
|
|
|
|
|
});
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: liveHeartbeatRunId,
|
|
|
|
|
companyId,
|
|
|
|
|
agentId,
|
|
|
|
|
invocationSource: "assignment",
|
|
|
|
|
triggerDetail: "system",
|
|
|
|
|
status: "running",
|
|
|
|
|
contextSnapshot: { issueId: previousIssue.id },
|
|
|
|
|
startedAt: new Date("2026-03-20T12:01:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
await db
|
|
|
|
|
.update(issues)
|
|
|
|
|
.set({
|
|
|
|
|
checkoutRunId: liveHeartbeatRunId,
|
|
|
|
|
executionRunId: liveHeartbeatRunId,
|
|
|
|
|
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(issues.id, previousIssue.id));
|
|
|
|
|
await db.insert(issueInboxArchives).values({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: previousIssue.id,
|
|
|
|
|
userId,
|
|
|
|
|
archivedAt: new Date("2026-03-20T12:02:00.000Z"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" }, { userId });
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("skipped");
|
|
|
|
|
expect(run.linkedIssueId).toBe(previousIssue.id);
|
|
|
|
|
await expect(
|
|
|
|
|
db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)),
|
|
|
|
|
).resolves.toHaveLength(0);
|
|
|
|
|
await expect(
|
|
|
|
|
db.select().from(issueReadStates).where(eq(issueReadStates.issueId, previousIssue.id)),
|
|
|
|
|
).resolves.toEqual([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
companyId,
|
|
|
|
|
issueId: previousIssue.id,
|
|
|
|
|
userId,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const inboxIssues = await issueSvc.list(companyId, {
|
|
|
|
|
touchedByUserId: userId,
|
|
|
|
|
inboxArchivedByUserId: userId,
|
|
|
|
|
includeRoutineExecutions: true,
|
|
|
|
|
});
|
|
|
|
|
expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
|
|
|
|
});
|
|
|
|
|
|
[codex] Harden heartbeat scheduling and runtime controls (#4223)
## Thinking Path
> - Paperclip orchestrates AI agents through issue checkout, heartbeat
runs, routines, and auditable control-plane state
> - The runtime path has to recover from lost local processes, transient
adapter failures, blocked dependencies, and routine coalescing without
stranding work
> - The existing branch carried several reliability fixes across
heartbeat scheduling, issue runtime controls, routine dispatch, and
operator-facing run state
> - These changes belong together because they share backend contracts,
migrations, and runtime status semantics
> - This pull request groups the control-plane/runtime slice so it can
merge independently from board UI polish and adapter sandbox work
> - The benefit is safer heartbeat recovery, clearer runtime controls,
and more predictable recurring execution behavior
## What Changed
- Adds bounded heartbeat retry scheduling, scheduled retry state, and
Codex transient failure recovery handling.
- Tightens heartbeat process recovery, blocker wake behavior, issue
comment wake handling, routine dispatch coalescing, and
activity/dashboard bounds.
- Adds runtime-control MCP tools and Paperclip skill docs for issue
workspace runtime management.
- Adds migrations `0061_lively_thor_girl.sql` and
`0062_routine_run_dispatch_fingerprint.sql`.
- Surfaces retry state in run ledger/agent UI and keeps related shared
types synchronized.
## Verification
- `pnpm exec vitest run
server/src/__tests__/heartbeat-retry-scheduling.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/routines-service.test.ts`
- `pnpm exec vitest run src/tools.test.ts` from `packages/mcp-server`
## Risks
- Medium risk: this touches heartbeat recovery and routine dispatch,
which are central execution paths.
- Migration order matters if split branches land out of order: merge
this PR before branches that assume the new runtime/routine fields.
- Runtime retry behavior should be watched in CI and in local operator
smoke tests because it changes how transient failures are resumed.
> 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
- OpenAI Codex, GPT-5-based coding agent runtime, shell/git tool use
enabled. Exact hosted model build and context window are not exposed in
this Paperclip heartbeat environment.
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-21 12:24:11 -05:00
|
|
|
it("does not coalesce live routine runs with different resolved variables", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
|
|
|
|
const variableRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "pre-pr for {{branch}}",
|
|
|
|
|
description: "Create a pre-PR from {{branch}}",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
variables: [
|
|
|
|
|
{ name: "branch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const first = await svc.runRoutine(variableRoutine.id, {
|
|
|
|
|
source: "manual",
|
|
|
|
|
variables: { branch: "feature/a" },
|
|
|
|
|
});
|
|
|
|
|
const second = await svc.runRoutine(variableRoutine.id, {
|
|
|
|
|
source: "manual",
|
|
|
|
|
variables: { branch: "feature/b" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(first.status).toBe("issue_created");
|
|
|
|
|
expect(second.status).toBe("issue_created");
|
|
|
|
|
expect(first.linkedIssueId).toBeTruthy();
|
|
|
|
|
expect(second.linkedIssueId).toBeTruthy();
|
|
|
|
|
expect(first.linkedIssueId).not.toBe(second.linkedIssueId);
|
|
|
|
|
|
|
|
|
|
const routineIssues = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: issues.id,
|
|
|
|
|
title: issues.title,
|
|
|
|
|
originFingerprint: issues.originFingerprint,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.originId, variableRoutine.id));
|
|
|
|
|
|
|
|
|
|
expect(routineIssues).toHaveLength(2);
|
|
|
|
|
expect(routineIssues.map((issue) => issue.title).sort()).toEqual([
|
|
|
|
|
"pre-pr for feature/a",
|
|
|
|
|
"pre-pr for feature/b",
|
|
|
|
|
]);
|
|
|
|
|
expect(new Set(routineIssues.map((issue) => issue.originFingerprint)).size).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 11:38:57 -05:00
|
|
|
it("interpolates routine variables into the execution issue and stores resolved values", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
|
|
|
|
const variableRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
2026-04-07 16:31:14 -05:00
|
|
|
title: "repo triage for {{repo}}",
|
2026-04-02 11:38:57 -05:00
|
|
|
description: "Review {{repo}} for {{priority}} bugs",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
variables: [
|
|
|
|
|
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
|
|
|
|
{ name: "priority", label: null, type: "select", defaultValue: "high", required: true, options: ["high", "low"] },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
2026-04-07 16:31:14 -05:00
|
|
|
expect(variableRoutine.variables.map((variable) => variable.name)).toEqual(["repo", "priority"]);
|
2026-04-02 11:38:57 -05:00
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(variableRoutine.id, {
|
|
|
|
|
source: "manual",
|
|
|
|
|
variables: { repo: "paperclip" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const storedIssue = await db
|
2026-04-07 16:31:14 -05:00
|
|
|
.select({ title: issues.title, description: issues.description })
|
2026-04-02 11:38:57 -05:00
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, run.linkedIssueId!))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
const storedRun = await db
|
|
|
|
|
.select({ triggerPayload: routineRuns.triggerPayload })
|
|
|
|
|
.from(routineRuns)
|
|
|
|
|
.where(eq(routineRuns.id, run.id))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
|
2026-04-07 16:31:14 -05:00
|
|
|
expect(storedIssue?.title).toBe("repo triage for paperclip");
|
2026-04-02 11:38:57 -05:00
|
|
|
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
|
|
|
|
|
expect(storedRun?.triggerPayload).toEqual({
|
|
|
|
|
variables: {
|
|
|
|
|
repo: "paperclip",
|
|
|
|
|
priority: "high",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("attaches the selected execution workspace to manually triggered routine issues", async () => {
|
|
|
|
|
const { companyId, projectId, routine, svc } = await seedFixture();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
await db
|
|
|
|
|
.update(projects)
|
|
|
|
|
.set({
|
|
|
|
|
executionWorkspacePolicy: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
defaultMode: "shared_workspace",
|
|
|
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.where(eq(projects.id, projectId));
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
sharedWorkspaceKey: "routine-primary",
|
|
|
|
|
});
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Routine worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, {
|
|
|
|
|
source: "manual",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const storedIssue = await db
|
|
|
|
|
.select({
|
|
|
|
|
projectWorkspaceId: issues.projectWorkspaceId,
|
|
|
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
|
|
|
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, run.linkedIssueId!))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
|
|
|
|
|
expect(storedIssue).toEqual({
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
[codex] Respect manual workspace runtime controls (#4125)
## Thinking Path
> - Paperclip orchestrates AI agents inside execution and project
workspaces
> - Workspace runtime services can be controlled manually by operators
and reused by agent runs
> - Manual start/stop state was not preserved consistently across
workspace policies and routine launches
> - Routine launches also needed branch/workspace variables to default
from the selected workspace context
> - This pull request makes runtime policy state explicit, preserves
manual control, and auto-fills routine branch variables from workspace
data
> - The benefit is less surprising workspace service behavior and fewer
manual inputs when running workspace-scoped routines
## What Changed
- Added runtime-state handling for manual workspace control across
execution and project workspace validators, routes, and services.
- Updated heartbeat/runtime startup behavior so manually stopped
services are respected.
- Auto-filled routine workspace branch variables from available
workspace context.
- Added focused server and UI tests for workspace runtime and routine
variable behavior.
- Removed muted gray background styling from workspace pages and cards
for a cleaner workspace UI.
## Verification
- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec vitest run server/src/__tests__/routines-service.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/RoutineRunVariablesDialog.test.tsx`
- Result: 55 tests passed, 21 skipped. The embedded Postgres routines
tests skipped on this host with the existing PGlite/Postgres init
warning; workspace-runtime and UI tests passed.
## Risks
- Medium risk: this touches runtime service start/stop policy and
heartbeat launch behavior.
- The focused tests cover manual runtime state, routine variables, and
workspace runtime reuse paths.
> 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
- OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and
GitHub workflow, exact runtime context window not exposed in this
session.
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why targeted component/service verification
is sufficient here
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 10:39:37 -05:00
|
|
|
it("auto-populates workspaceBranch from a reused isolated workspace", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
|
|
|
|
const projectWorkspaceId = randomUUID();
|
|
|
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
|
|
|
|
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
|
await db
|
|
|
|
|
.update(projects)
|
|
|
|
|
.set({
|
|
|
|
|
executionWorkspacePolicy: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
defaultMode: "shared_workspace",
|
|
|
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.where(eq(projects.id, projectId));
|
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
|
|
|
id: projectWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
name: "Primary workspace",
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
sharedWorkspaceKey: "routine-primary",
|
|
|
|
|
});
|
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
|
|
|
id: executionWorkspaceId,
|
|
|
|
|
companyId,
|
|
|
|
|
projectId,
|
|
|
|
|
projectWorkspaceId,
|
|
|
|
|
mode: "isolated_workspace",
|
|
|
|
|
strategyType: "git_worktree",
|
|
|
|
|
name: "Routine worktree",
|
|
|
|
|
status: "active",
|
|
|
|
|
providerType: "git_worktree",
|
|
|
|
|
branchName: "pap-1634-routine-branch",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const branchRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "Review {{workspaceBranch}}",
|
|
|
|
|
description: "Use branch {{workspaceBranch}}",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
variables: [
|
|
|
|
|
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(branchRoutine.id, {
|
|
|
|
|
source: "manual",
|
|
|
|
|
executionWorkspaceId,
|
|
|
|
|
executionWorkspacePreference: "reuse_existing",
|
|
|
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const storedIssue = await db
|
|
|
|
|
.select({ title: issues.title, description: issues.description })
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, run.linkedIssueId!))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
const storedRun = await db
|
|
|
|
|
.select({ triggerPayload: routineRuns.triggerPayload })
|
|
|
|
|
.from(routineRuns)
|
|
|
|
|
.where(eq(routineRuns.id, run.id))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
|
|
|
|
|
expect(storedIssue?.title).toBe("Review pap-1634-routine-branch");
|
|
|
|
|
expect(storedIssue?.description).toBe("Use branch pap-1634-routine-branch");
|
|
|
|
|
expect(storedRun?.triggerPayload).toEqual({
|
|
|
|
|
variables: {
|
|
|
|
|
workspaceBranch: "pap-1634-routine-branch",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 10:19:52 -05:00
|
|
|
it("runs draft routines with one-off agent and project overrides", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
|
|
|
|
const draftRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId: null,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "draft dispatch",
|
|
|
|
|
description: "Pick defaults at run time",
|
|
|
|
|
assigneeAgentId: null,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "paused",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(draftRoutine.id, {
|
|
|
|
|
source: "manual",
|
|
|
|
|
projectId,
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
expect(run.linkedIssueId).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
const storedIssue = await db
|
|
|
|
|
.select({
|
|
|
|
|
projectId: issues.projectId,
|
|
|
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
|
|
|
})
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.id, run.linkedIssueId!))
|
|
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
|
|
|
|
|
expect(storedIssue).toEqual({
|
|
|
|
|
projectId,
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects enabling automation for routines without a default agent", async () => {
|
|
|
|
|
const { companyId, svc } = await seedFixture();
|
|
|
|
|
const draftRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId: null,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "draft routine",
|
|
|
|
|
description: null,
|
|
|
|
|
assigneeAgentId: null,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "paused",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.update(draftRoutine.id, { status: "active" }, {}),
|
|
|
|
|
).rejects.toThrow(/default agent required/i);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 11:38:57 -05:00
|
|
|
it("blocks schedule triggers when required variables do not have defaults", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
|
|
|
|
const variableRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "repo triage",
|
|
|
|
|
description: "Review {{repo}}",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
variables: [
|
|
|
|
|
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.createTrigger(variableRoutine.id, {
|
|
|
|
|
kind: "schedule",
|
|
|
|
|
label: "daily",
|
|
|
|
|
cronExpression: "0 10 * * *",
|
|
|
|
|
timezone: "UTC",
|
|
|
|
|
}, {}),
|
|
|
|
|
).rejects.toThrow(/require defaults for required variables/i);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 12:09:02 -05:00
|
|
|
it("treats malformed stored defaults as missing when validating schedule triggers", async () => {
|
|
|
|
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
|
|
|
|
const variableRoutine = await svc.create(
|
|
|
|
|
companyId,
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
goalId: null,
|
|
|
|
|
parentIssueId: null,
|
|
|
|
|
title: "ship check",
|
|
|
|
|
description: "Review {{approved}}",
|
|
|
|
|
assigneeAgentId: agentId,
|
|
|
|
|
priority: "medium",
|
|
|
|
|
status: "active",
|
|
|
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
|
|
|
catchUpPolicy: "skip_missed",
|
|
|
|
|
variables: [
|
|
|
|
|
{ name: "approved", label: null, type: "boolean", defaultValue: true, required: true, options: [] },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(routines)
|
|
|
|
|
.set({
|
|
|
|
|
variables: [
|
|
|
|
|
{
|
|
|
|
|
name: "approved",
|
|
|
|
|
label: null,
|
|
|
|
|
type: "boolean",
|
|
|
|
|
defaultValue: "definitely",
|
|
|
|
|
required: true,
|
|
|
|
|
options: [],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
.where(eq(routines.id, variableRoutine.id));
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.createTrigger(variableRoutine.id, {
|
|
|
|
|
kind: "schedule",
|
|
|
|
|
label: "daily",
|
|
|
|
|
cronExpression: "0 10 * * *",
|
|
|
|
|
timezone: "UTC",
|
|
|
|
|
}, {}),
|
|
|
|
|
).rejects.toThrow(/require defaults for required variables/i);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 16:15:32 -05:00
|
|
|
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture({
|
|
|
|
|
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
|
|
|
|
const issueId =
|
|
|
|
|
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
|
|
|
|
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
|
|
|
|
null;
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
|
if (!issueId) return null;
|
|
|
|
|
const queuedRunId = randomUUID();
|
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
|
|
|
id: queuedRunId,
|
|
|
|
|
companyId: routine.companyId,
|
|
|
|
|
agentId: wakeupAgentId,
|
|
|
|
|
invocationSource: wakeupOpts.source ?? "assignment",
|
|
|
|
|
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
|
|
|
|
status: "queued",
|
|
|
|
|
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
|
|
|
|
});
|
|
|
|
|
await db
|
|
|
|
|
.update(issues)
|
|
|
|
|
.set({
|
|
|
|
|
executionRunId: queuedRunId,
|
|
|
|
|
executionLockedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(issues.id, issueId));
|
|
|
|
|
return { id: queuedRunId };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [first, second] = await Promise.all([
|
|
|
|
|
svc.runRoutine(routine.id, { source: "manual" }),
|
|
|
|
|
svc.runRoutine(routine.id, { source: "manual" }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect([first.status, second.status].sort()).toEqual(["coalesced", "issue_created"]);
|
|
|
|
|
expect(first.linkedIssueId).toBeTruthy();
|
|
|
|
|
expect(second.linkedIssueId).toBeTruthy();
|
|
|
|
|
expect(first.linkedIssueId).toBe(second.linkedIssueId);
|
|
|
|
|
|
|
|
|
|
const routineIssues = await db
|
|
|
|
|
.select({ id: issues.id })
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.originId, routine.id));
|
|
|
|
|
|
|
|
|
|
expect(routineIssues).toHaveLength(1);
|
|
|
|
|
});
|
2026-03-20 16:26:29 -05:00
|
|
|
|
2026-03-20 16:40:27 -05:00
|
|
|
it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture({
|
|
|
|
|
wakeup: async () => {
|
|
|
|
|
throw new Error("queue unavailable");
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
|
|
|
|
|
|
|
|
|
expect(run.status).toBe("failed");
|
|
|
|
|
expect(run.failureReason).toContain("queue unavailable");
|
|
|
|
|
expect(run.linkedIssueId).toBeNull();
|
|
|
|
|
|
|
|
|
|
const routineIssues = await db
|
|
|
|
|
.select({ id: issues.id })
|
|
|
|
|
.from(issues)
|
|
|
|
|
.where(eq(issues.originId, routine.id));
|
|
|
|
|
|
|
|
|
|
expect(routineIssues).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 16:26:29 -05:00
|
|
|
it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const { trigger, secretMaterial } = await svc.createTrigger(
|
|
|
|
|
routine.id,
|
|
|
|
|
{
|
|
|
|
|
kind: "webhook",
|
|
|
|
|
signingMode: "hmac_sha256",
|
|
|
|
|
replayWindowSec: 300,
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(trigger.publicId).toBeTruthy();
|
|
|
|
|
expect(secretMaterial?.webhookSecret).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
const payload = { ok: true };
|
|
|
|
|
const rawBody = Buffer.from(JSON.stringify(payload));
|
|
|
|
|
const timestampSeconds = String(Math.floor(Date.now() / 1000));
|
|
|
|
|
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
|
|
|
|
|
.update(`${timestampSeconds}.`)
|
|
|
|
|
.update(rawBody)
|
|
|
|
|
.digest("hex")}`;
|
|
|
|
|
|
|
|
|
|
const run = await svc.firePublicTrigger(trigger.publicId!, {
|
|
|
|
|
signatureHeader: signature,
|
|
|
|
|
timestampHeader: timestampSeconds,
|
|
|
|
|
rawBody,
|
|
|
|
|
payload,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(run.source).toBe("webhook");
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
expect(run.linkedIssueId).toBeTruthy();
|
|
|
|
|
});
|
2026-03-27 23:15:55 -03:00
|
|
|
|
|
|
|
|
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const { trigger, secretMaterial } = await svc.createTrigger(
|
|
|
|
|
routine.id,
|
|
|
|
|
{
|
|
|
|
|
kind: "webhook",
|
|
|
|
|
signingMode: "github_hmac",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const payload = { action: "opened", pull_request: { number: 1 } };
|
|
|
|
|
const rawBody = Buffer.from(JSON.stringify(payload));
|
|
|
|
|
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
|
|
|
|
|
.update(rawBody)
|
|
|
|
|
.digest("hex")}`;
|
|
|
|
|
|
|
|
|
|
const run = await svc.firePublicTrigger(trigger.publicId!, {
|
|
|
|
|
hubSignatureHeader: signature,
|
|
|
|
|
rawBody,
|
|
|
|
|
payload,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(run.source).toBe("webhook");
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects invalid signature for github_hmac signing mode", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const { trigger } = await svc.createTrigger(
|
|
|
|
|
routine.id,
|
|
|
|
|
{
|
|
|
|
|
kind: "webhook",
|
|
|
|
|
signingMode: "github_hmac",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const rawBody = Buffer.from(JSON.stringify({ ok: true }));
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
svc.firePublicTrigger(trigger.publicId!, {
|
|
|
|
|
hubSignatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000",
|
|
|
|
|
rawBody,
|
|
|
|
|
payload: { ok: true },
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts any request with none signing mode", async () => {
|
|
|
|
|
const { routine, svc } = await seedFixture();
|
|
|
|
|
const { trigger } = await svc.createTrigger(
|
|
|
|
|
routine.id,
|
|
|
|
|
{
|
|
|
|
|
kind: "webhook",
|
|
|
|
|
signingMode: "none",
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const run = await svc.firePublicTrigger(trigger.publicId!, {
|
|
|
|
|
payload: { event: "error.created" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(run.source).toBe("webhook");
|
|
|
|
|
expect(run.status).toBe("issue_created");
|
|
|
|
|
});
|
2026-03-20 07:21:38 -05:00
|
|
|
});
|