Reject projectless isolated git worktree tasks (#9231)
## Thinking Path
> - Paperclip is an open-source platform for orchestrating AI agents;
agents run inside execution workspaces that range from a shared
container to full git worktrees cloned from a project repository.
> - Isolated git-worktree workspaces require a project to determine
which repository to clone — without a project the worktree base path
cannot be computed.
> - A task pinned to `isolated_workspace` + `git_worktree` with no
project was previously accepted at creation time but failed late at
dispatch with the opaque `workspace_validation_failed` /
`git_worktree_base_agent_home` error — only after the heartbeat
attempted to provision the workspace.
> - Fail-closed validation should happen in two places: (1) explicit
create/update pins that contradict the requirement are rejected at the
HTTP layer with a structured 422; (2) rows that reach the heartbeat
dispatcher with this invalid combination (e.g. through inheritance or a
retroactively-removed project) are blocked before any heartbeat run or
adapter spawn.
> - This PR adds the shared detection policy, the create/update guard in
the issues service, and the heartbeat pre-dispatch guard, together with
focused unit tests for all three layers.
> - The benefit is deterministic early failure with a clear remediation
message instead of a late, cryptic runtime error.
## Linked Issues or Issue Description
No upstream public GitHub issue — describing the problem inline
(bug-report format).
**What happened?**
Creating an issue with `executionWorkspaceSettings: { mode:
"isolated_workspace", type: "git_worktree" }` and no `projectId` was
accepted without error. The issue then became blocked at dispatch time
with the opaque message `git_worktree_base_agent_home` /
`workspace_validation_failed` — surfaced only after the heartbeat
attempted to provision the workspace.
**Expected behavior**
The platform should reject the invalid combination at create/update time
with a structured 422 that includes a clear remediation message, before
any heartbeat resource is consumed.
**Steps to reproduce**
1. Call `POST /api/issues` (or `PATCH /api/issues/:id`) with
`executionWorkspaceSettings: { mode: "isolated_workspace", type:
"git_worktree" }` and omit `projectId` (or set it to `null`).
2. Observe: request succeeds (200/201).
3. Assign the issue to an agent and watch it enter `blocked` with a
cryptic `workspace_validation_failed` error at dispatch.
**Related prior fix** — Refs #4844 (`fix(validator): reject static cwd
combined with git_worktree strategy`) — same validation area, different
dimension (static cwd vs. missing project).
**Paperclip version / commit**
Latest `master` (pre-this-PR).
**Deployment mode**
Standard (app-global server).
## What Changed
- **`execution-workspace-policy.ts`** — new shared
`detectWorkspaceWorktreeRequiresProject` function returning a stable
`workspace_worktree_requires_project` policy violation when an isolated
git-worktree task has no project, project workspace, or reusable
execution workspace; exports canonical remediation text used by both the
HTTP guard and the heartbeat guard.
- **`issues.ts`** — create and update paths check the new policy before
persisting; explicit pins to `isolated_workspace` / `operator_branch` +
`git_worktree` with no project are rejected with a 422 including the
policy code and remediation text.
- **`heartbeat.ts`** — pre-dispatch preflight checks the same policy for
rows that reach the heartbeat with the invalid combination (e.g. through
inheritance); such rows are marked `blocked` with a skipped wakeup
request, durable issue comment, and activity log before any heartbeat
run or adapter spawn.
- **`execution-workspace-policy.test.ts`** — focused policy-layer unit
tests for detection logic and remediation text.
- **`issues-service.test.ts`** — create/update 422 guard tests for the
new policy.
- **`heartbeat-workspace-branch-containment.test.ts`** — pre-dispatch
blocking test for inherited/ambiguous invalid rows; also fixes a cleanup
race in the existing test suite.
## Verification
```sh
pnpm exec vitest run \
server/src/__tests__/execution-workspace-policy.test.ts \
server/src/__tests__/issues-service.test.ts \
server/src/__tests__/heartbeat-workspace-branch-containment.test.ts
pnpm --filter @paperclipai/server typecheck
# Targeted regression
pnpm exec vitest run \
server/src/__tests__/heartbeat-workspace-branch-containment.test.ts \
-t "blocks projectless isolated git-worktree issues before dispatch"
```
All three test files and typecheck passed locally before this PR was
opened.
## Risks
**Low risk.** The policy detection function is pure with no side
effects. The create/update guard only triggers on explicit
`isolated_workspace` or `operator_branch` + `git_worktree` pins combined
with a missing project — it does not fire on inherited settings (handled
by the heartbeat preflight), so there is no false-positive rejection
risk for valid tasks. The heartbeat guard fires before any resource is
provisioned; the only behavioral change for already-invalid rows is that
they receive a clear `blocked` status and durable comment instead of a
late cryptic error.
## Model Used
Claude Sonnet 4.6 (`claude-sonnet-4-6`), 200k context window, tool use
enabled (agentic coding). Used to implement all server-side changes and
tests in this PR.
## 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
- [x] I have run tests locally and they pass
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
11177c2665
commit
88bf71b84e
|
|
@ -3,10 +3,12 @@ import {
|
|||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
isUnrunnableWorktreeCombo,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceEnvironmentId,
|
||||
resolvePinnedIssueWorkspaceStrategyType,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
|
|
@ -37,6 +39,123 @@ describe("execution workspace policy helpers", () => {
|
|||
).toBe("isolated_workspace");
|
||||
});
|
||||
|
||||
it("centralizes unrunnable isolated worktree detection", () => {
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
},
|
||||
resolvedMode: "isolated_workspace",
|
||||
resolvedStrategy: "git_worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
},
|
||||
resolvedMode: "isolated_workspace",
|
||||
resolvedStrategy: "git_worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: "workspace-1",
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
},
|
||||
resolvedMode: "isolated_workspace",
|
||||
resolvedStrategy: "git_worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
},
|
||||
resolvedMode: "shared_workspace",
|
||||
resolvedStrategy: "git_worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
},
|
||||
resolvedMode: "agent_default",
|
||||
resolvedStrategy: "git_worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
},
|
||||
resolvedMode: "operator_branch",
|
||||
resolvedStrategy: "git_worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
},
|
||||
resolvedMode: "isolated_workspace",
|
||||
resolvedStrategy: "git_worktree",
|
||||
hasResolvablePriorSessionWorkspace: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("mirrors runtime default (project_primary) when pinned settings omit strategy type", () => {
|
||||
// Mode-only pin without explicit workspaceStrategy.type → same project_primary default as runtime.
|
||||
expect(
|
||||
resolvePinnedIssueWorkspaceStrategyType({
|
||||
mode: "isolated_workspace",
|
||||
issueSettings: { mode: "isolated_workspace" },
|
||||
}),
|
||||
).toBe("project_primary");
|
||||
// Explicit strategy type is always respected.
|
||||
expect(
|
||||
resolvePinnedIssueWorkspaceStrategyType({
|
||||
mode: "isolated_workspace",
|
||||
issueSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "git_worktree" },
|
||||
},
|
||||
}),
|
||||
).toBe("git_worktree");
|
||||
expect(
|
||||
resolvePinnedIssueWorkspaceStrategyType({
|
||||
mode: "isolated_workspace",
|
||||
issueSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "project_primary" },
|
||||
},
|
||||
}),
|
||||
).toBe("project_primary");
|
||||
});
|
||||
|
||||
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ import {
|
|||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import {
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -866,8 +871,9 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
|||
await db.delete(environmentLeases);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
// Heartbeat failure/finalization paths can emit run-linked activity after
|
||||
// the first cleanup pass observes all runs as non-active.
|
||||
// Heartbeat failure/finalization paths can emit run-linked events and
|
||||
// activity after the first cleanup pass observes all runs as non-active.
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issueComments);
|
||||
|
|
@ -889,6 +895,131 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
|||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const issueIdentifier = `${issuePrefix}-1`;
|
||||
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
issuePrefix,
|
||||
status: "active",
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Projectless isolated worktree",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
responsibleUserId: "responsible-user",
|
||||
issueNumber: 1,
|
||||
identifier: issueIdentifier,
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "git_worktree" },
|
||||
},
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
contextSnapshot: { issueId, wakeReason: "issue_assigned" },
|
||||
});
|
||||
|
||||
expect(run).toBeNull();
|
||||
expect(adapterExecute).not.toHaveBeenCalled();
|
||||
|
||||
const runRows = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns);
|
||||
expect(runRows).toEqual([]);
|
||||
|
||||
const blockedIssue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionAgentNameKey: issues.executionAgentNameKey,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(blockedIssue).toEqual({
|
||||
status: "blocked",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
});
|
||||
|
||||
const wakeup = await db
|
||||
.select({
|
||||
status: agentWakeupRequests.status,
|
||||
reason: agentWakeupRequests.reason,
|
||||
payload: agentWakeupRequests.payload,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
});
|
||||
expect(asRecord(asRecord(wakeup?.payload).heartbeatSkip)).toEqual({
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
});
|
||||
|
||||
const comment = await db
|
||||
.select({ body: issueComments.body })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(comment?.body).toContain(WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE);
|
||||
|
||||
const activity = await db
|
||||
.select({
|
||||
action: activityLog.action,
|
||||
details: activityLog.details,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(activity?.action).toBe("issue.workspace_preflight_blocked");
|
||||
expect(activity?.details).toMatchObject({
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
resolvedMode: "isolated_workspace",
|
||||
resolvedStrategy: "git_worktree",
|
||||
hasResolvablePriorSessionWorkspace: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["workspace-runtime fresh worktree reuse", "fresh_realize" as const, null],
|
||||
["workspace-runtime persisted restore", "persisted_restore" as const, "source-workspace"],
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ import {
|
|||
ISSUE_LIST_MAX_LIMIT,
|
||||
issueService,
|
||||
} from "../services/issues.ts";
|
||||
import {
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
|
|
@ -4091,6 +4096,62 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|||
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
});
|
||||
|
||||
it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await expect(svc.create(companyId, {
|
||||
title: "Projectless isolated worktree",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "git_worktree" },
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
status: 422,
|
||||
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
details: {
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reject ambiguous inherited git-worktree settings before dispatch", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Ambiguous inherited worktree",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "inherit",
|
||||
workspaceStrategy: { type: "git_worktree" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(issue.executionWorkspaceSettings).toEqual({
|
||||
mode: "inherit",
|
||||
workspaceStrategy: { type: "git_worktree" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
|
@ -4306,6 +4367,51 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|||
expect(updated?.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
});
|
||||
|
||||
it("rejects updates that pin a projectless issue to an isolated git worktree", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Workspace Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Assign then isolate",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
await expect(svc.update(issue.id, {
|
||||
assigneeAgentId: agentId,
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "git_worktree" },
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
status: 422,
|
||||
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
details: {
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export function errorHandler(
|
|||
res.status(err.status).json({
|
||||
error: err.message,
|
||||
...(typeof details?.code === "string" ? { code: details.code } : {}),
|
||||
...(typeof details?.remediation === "string" ? { remediation: details.remediation } : {}),
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,22 @@ import type {
|
|||
} from "@paperclipai/shared";
|
||||
import { asString, parseObject } from "../adapters/utils.js";
|
||||
|
||||
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
|
||||
export type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
|
||||
|
||||
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE = "workspace_worktree_requires_project";
|
||||
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION =
|
||||
"Attach a project to the task, or bind a reusable execution workspace, then retry.";
|
||||
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE =
|
||||
`This task is set to run in an isolated git worktree, but it has no project and no reusable execution workspace to create the worktree from. ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`;
|
||||
|
||||
type WorkspaceStrategyType = ExecutionWorkspaceStrategy["type"];
|
||||
|
||||
export type UnrunnableWorktreeIssueRef = {
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
};
|
||||
|
||||
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
|
|
@ -30,6 +45,58 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveEffectiveWorkspaceStrategyType(
|
||||
mode: ParsedExecutionWorkspaceMode,
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
): WorkspaceStrategyType {
|
||||
const workspaceStrategy = parseObject(config?.workspaceStrategy);
|
||||
const type = asString(workspaceStrategy.type, "");
|
||||
if (type === "project_primary" || type === "git_worktree" || type === "adapter_managed" || type === "cloud_sandbox") {
|
||||
return type;
|
||||
}
|
||||
// Default mirrors workspace-runtime.ts realizeExecutionWorkspace: missing type -> "project_primary".
|
||||
// agent_default is a metadata-only mode that never creates a worktree, so it keeps "adapter_managed".
|
||||
return mode === "agent_default" ? "adapter_managed" : "project_primary";
|
||||
}
|
||||
|
||||
export function resolvePinnedIssueWorkspaceStrategyType(input: {
|
||||
mode: ParsedExecutionWorkspaceMode;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
}): WorkspaceStrategyType {
|
||||
const strategyType = input.issueSettings?.workspaceStrategy?.type;
|
||||
if (
|
||||
strategyType === "project_primary" ||
|
||||
strategyType === "git_worktree" ||
|
||||
strategyType === "adapter_managed" ||
|
||||
strategyType === "cloud_sandbox"
|
||||
) {
|
||||
return strategyType;
|
||||
}
|
||||
// When no explicit strategy type is set, mirror the runtime default (project_primary for most
|
||||
// modes; adapter_managed for agent_default). Mode alone never implies git_worktree.
|
||||
return input.mode === "agent_default" ? "adapter_managed" : "project_primary";
|
||||
}
|
||||
|
||||
export function hasReusableExecutionWorkspaceBinding(issue: UnrunnableWorktreeIssueRef): boolean {
|
||||
return Boolean(issue.executionWorkspaceId && issue.executionWorkspacePreference === "reuse_existing");
|
||||
}
|
||||
|
||||
export function isUnrunnableWorktreeCombo(input: {
|
||||
issue: UnrunnableWorktreeIssueRef;
|
||||
resolvedMode: ParsedExecutionWorkspaceMode;
|
||||
resolvedStrategy: string | null | undefined;
|
||||
reusableExecutionWorkspaceAvailable?: boolean | null;
|
||||
hasResolvablePriorSessionWorkspace?: boolean | null;
|
||||
}): boolean {
|
||||
if (input.resolvedMode !== "isolated_workspace" && input.resolvedMode !== "operator_branch") return false;
|
||||
if (input.resolvedStrategy !== "git_worktree") return false;
|
||||
if (input.issue.projectId || input.issue.projectWorkspaceId) return false;
|
||||
const hasReusableWorkspace =
|
||||
input.reusableExecutionWorkspaceAvailable ?? hasReusableExecutionWorkspaceBinding(input.issue);
|
||||
if (hasReusableWorkspace) return false;
|
||||
return input.hasResolvablePriorSessionWorkspace !== true;
|
||||
}
|
||||
|
||||
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
documentAnnotationThreads,
|
||||
documentRevisions,
|
||||
issueDocuments,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
|
|
@ -138,10 +139,15 @@ import {
|
|||
buildExecutionWorkspaceAdapterConfig,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
isUnrunnableWorktreeCombo,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveEffectiveWorkspaceStrategyType,
|
||||
resolveExecutionWorkspaceEnvironmentId,
|
||||
resolveExecutionWorkspaceMode,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import {
|
||||
|
|
@ -1377,19 +1383,6 @@ async function hasGitPushRemote(cwd: string | null | undefined) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function resolveEffectiveWorkspaceStrategyType(
|
||||
mode: ReturnType<typeof resolveExecutionWorkspaceMode>,
|
||||
config: Record<string, unknown>,
|
||||
): string {
|
||||
const workspaceStrategy = parseObject(config.workspaceStrategy);
|
||||
// Default mirrors workspace-runtime.ts realizeExecutionWorkspace: missing type → "project_primary".
|
||||
// agent_default is a metadata-only mode that never creates a worktree, so it keeps "adapter_managed".
|
||||
return (
|
||||
readNonEmptyString(workspaceStrategy.type) ??
|
||||
(mode === "agent_default" ? "adapter_managed" : "project_primary")
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertGitWorktreeBaseWorkspaceReady(input: {
|
||||
requestedExecutionWorkspaceMode: ReturnType<typeof resolveExecutionWorkspaceMode>;
|
||||
config: Record<string, unknown>;
|
||||
|
|
@ -6242,6 +6235,39 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
return runtimeForRun?.sessionId ?? null;
|
||||
}
|
||||
|
||||
async function hasResolvableSessionWorkspaceCwd(sessionParams: Record<string, unknown> | null | undefined) {
|
||||
const cwd = readNonEmptyString(sessionParams?.cwd);
|
||||
if (!cwd || isUnsafeSessionWorkspaceCwd(cwd)) return false;
|
||||
return fs
|
||||
.stat(cwd)
|
||||
.then((stats) => stats.isDirectory())
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function hasResolvablePriorSessionWorkspaceForWake(input: {
|
||||
agent: typeof agents.$inferSelect;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
taskKey: string | null;
|
||||
explicitResumeSession: Awaited<ReturnType<typeof resolveExplicitResumeSessionOverride>> | null;
|
||||
}) {
|
||||
if (await hasResolvableSessionWorkspaceCwd(input.explicitResumeSession?.sessionParams)) return true;
|
||||
if (shouldResetTaskSessionForWake(input.contextSnapshot)) return false;
|
||||
if (!input.taskKey) return false;
|
||||
|
||||
const codec = getAdapterSessionCodec(input.agent.adapterType);
|
||||
const taskSession = await getTaskSession(
|
||||
input.agent.companyId,
|
||||
input.agent.id,
|
||||
input.agent.adapterType,
|
||||
input.taskKey,
|
||||
);
|
||||
const taskSessionParams = normalizeResumeParamsForAdapter(
|
||||
input.agent.adapterType,
|
||||
codec.deserialize(taskSession?.sessionParamsJson ?? null),
|
||||
);
|
||||
return hasResolvableSessionWorkspaceCwd(taskSessionParams);
|
||||
}
|
||||
|
||||
async function resolveExplicitResumeSessionOverride(
|
||||
agent: typeof agents.$inferSelect,
|
||||
payload: Record<string, unknown> | null,
|
||||
|
|
@ -13339,6 +13365,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const sessionBefore =
|
||||
explicitResumeSession?.sessionDisplayId ??
|
||||
await resolveSessionBeforeForWakeup(agent, effectiveTaskKey);
|
||||
let hasResolvablePriorSessionWorkspace: boolean | null = null;
|
||||
const resolveHasResolvablePriorSessionWorkspace = async () => {
|
||||
if (hasResolvablePriorSessionWorkspace !== null) return hasResolvablePriorSessionWorkspace;
|
||||
hasResolvablePriorSessionWorkspace = issueId
|
||||
? await hasResolvablePriorSessionWorkspaceForWake({
|
||||
agent,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
taskKey: effectiveTaskKey,
|
||||
explicitResumeSession,
|
||||
})
|
||||
: false;
|
||||
return hasResolvablePriorSessionWorkspace;
|
||||
};
|
||||
const continuationAttempt = readContinuationAttempt(enrichedContextSnapshot.livenessContinuationAttempt);
|
||||
|
||||
let projectId = readNonEmptyString(enrichedContextSnapshot.projectId);
|
||||
|
|
@ -13375,6 +13414,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
|
||||
enrichedContextSnapshot.projectId = projectId;
|
||||
}
|
||||
const isolatedWorkspacesEnabled = issueId
|
||||
? (await instanceSettings.getExperimental()).enableIsolatedWorkspaces
|
||||
: false;
|
||||
let queuedResponsibleUserIdPromise: Promise<string> | null = null;
|
||||
const resolveQueuedResponsibleUserId = () => {
|
||||
queuedResponsibleUserIdPromise ??= (async () => {
|
||||
|
|
@ -13520,7 +13562,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
status: issues.status,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionAgentNameKey: issues.executionAgentNameKey,
|
||||
|
|
@ -13802,6 +13850,125 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
return { kind: "skipped" as const };
|
||||
}
|
||||
|
||||
if (isolatedWorkspacesEnabled && !activeExecutionRun && issue.status !== "done" && issue.status !== "cancelled") {
|
||||
const issueSettings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings);
|
||||
const resolvedMode = resolveExecutionWorkspaceMode({
|
||||
projectPolicy: null,
|
||||
issueSettings,
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: parseObject(agent.adapterConfig),
|
||||
projectPolicy: null,
|
||||
issueSettings,
|
||||
mode: resolvedMode,
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
const resolvedStrategy = resolveEffectiveWorkspaceStrategyType(resolvedMode, workspaceManagedConfig);
|
||||
const existingExecutionWorkspaceStatus = issue.executionWorkspaceId
|
||||
? await tx
|
||||
.select({ status: executionWorkspaces.status })
|
||||
.from(executionWorkspaces)
|
||||
.where(and(
|
||||
eq(executionWorkspaces.id, issue.executionWorkspaceId),
|
||||
eq(executionWorkspaces.companyId, issue.companyId),
|
||||
))
|
||||
.then((rows) => rows[0]?.status ?? null)
|
||||
: null;
|
||||
const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
|
||||
issueExecutionWorkspaceId: issue.executionWorkspaceId,
|
||||
issueExecutionWorkspacePreference: issue.executionWorkspacePreference,
|
||||
existingExecutionWorkspaceStatus,
|
||||
});
|
||||
const hasResolvablePriorSessionWorkspace = await resolveHasResolvablePriorSessionWorkspace();
|
||||
|
||||
if (
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: issue.projectId ?? projectId ?? null,
|
||||
projectWorkspaceId: issue.projectWorkspaceId,
|
||||
executionWorkspaceId: issue.executionWorkspaceId,
|
||||
executionWorkspacePreference: issue.executionWorkspacePreference,
|
||||
},
|
||||
resolvedMode,
|
||||
resolvedStrategy,
|
||||
reusableExecutionWorkspaceAvailable: reuseRequest.existingExecutionWorkspaceAvailable,
|
||||
hasResolvablePriorSessionWorkspace,
|
||||
})
|
||||
) {
|
||||
const now = new Date();
|
||||
const issueLabel = formatIssueIdentifierLink(issue.identifier, issue.id);
|
||||
const blockedComment = [
|
||||
`Paperclip blocked ${issueLabel} before dispatch because its workspace settings are not runnable.`,
|
||||
"",
|
||||
`- Code: \`${WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE}\``,
|
||||
`- Reason: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE}`,
|
||||
`- Next action: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`,
|
||||
].join("\n");
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "blocked",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
await tx.insert(issueComments).values({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body: blockedComment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
payload: {
|
||||
...(payload ?? {}),
|
||||
issueId,
|
||||
heartbeatSkip: {
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
},
|
||||
},
|
||||
status: "skipped",
|
||||
requestedByActorType: opts.requestedByActorType ?? null,
|
||||
requestedByActorId: opts.requestedByActorId ?? null,
|
||||
idempotencyKey: opts.idempotencyKey ?? null,
|
||||
finishedAt: now,
|
||||
});
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId,
|
||||
runId: null,
|
||||
action: "issue.workspace_preflight_blocked",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
requestedReason: reason,
|
||||
source,
|
||||
triggerDetail,
|
||||
resolvedMode,
|
||||
resolvedStrategy,
|
||||
hasResolvablePriorSessionWorkspace,
|
||||
},
|
||||
});
|
||||
return { kind: "skipped" as const };
|
||||
}
|
||||
}
|
||||
|
||||
if (activeExecutionRun) {
|
||||
const executionAgent = await tx
|
||||
.select({ name: agents.name })
|
||||
|
|
|
|||
|
|
@ -69,8 +69,14 @@ import {
|
|||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
isUnrunnableWorktreeCombo,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolvePinnedIssueWorkspaceStrategyType,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
type ParsedExecutionWorkspaceMode,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js";
|
||||
|
|
@ -167,6 +173,48 @@ function applyStatusSideEffects(
|
|||
return patch;
|
||||
}
|
||||
|
||||
function workspaceWorktreeRequiresProjectDetails() {
|
||||
return {
|
||||
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
||||
};
|
||||
}
|
||||
|
||||
function assertExplicitPinnedWorktreeIssueRunnable(input: {
|
||||
projectId: string | null | undefined;
|
||||
projectWorkspaceId: string | null | undefined;
|
||||
executionWorkspaceId: string | null | undefined;
|
||||
executionWorkspacePreference: string | null | undefined;
|
||||
executionWorkspaceSettings: unknown;
|
||||
}) {
|
||||
const settings = parseIssueExecutionWorkspaceSettings(input.executionWorkspaceSettings);
|
||||
const mode = settings?.mode;
|
||||
if (mode !== "isolated_workspace" && mode !== "operator_branch") return;
|
||||
|
||||
const resolvedMode = mode as ParsedExecutionWorkspaceMode;
|
||||
if (
|
||||
isUnrunnableWorktreeCombo({
|
||||
issue: {
|
||||
projectId: input.projectId ?? null,
|
||||
projectWorkspaceId: input.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||
},
|
||||
resolvedMode,
|
||||
resolvedStrategy: resolvePinnedIssueWorkspaceStrategyType({
|
||||
mode: resolvedMode,
|
||||
issueSettings: settings,
|
||||
}),
|
||||
hasResolvablePriorSessionWorkspace: false,
|
||||
})
|
||||
) {
|
||||
throw unprocessable(
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
workspaceWorktreeRequiresProjectDetails(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readStringFromRecord(record: unknown, key: string) {
|
||||
if (!record || typeof record !== "object") return null;
|
||||
const value = (record as Record<string, unknown>)[key];
|
||||
|
|
@ -6008,6 +6056,15 @@ export function issueService(db: Db) {
|
|||
if (executionWorkspaceId) {
|
||||
await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx);
|
||||
}
|
||||
if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) {
|
||||
assertExplicitPinnedWorktreeIssueRunnable({
|
||||
projectId: issueData.projectId ?? null,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference,
|
||||
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
|
||||
});
|
||||
}
|
||||
// Self-correcting counter: use MAX(issue_number) + 1 if the counter
|
||||
// has drifted below the actual max, preventing identifier collisions.
|
||||
const [maxRow] = await tx
|
||||
|
|
@ -6223,6 +6280,15 @@ export function issueService(db: Db) {
|
|||
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
|
||||
}
|
||||
}
|
||||
if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) {
|
||||
assertExplicitPinnedWorktreeIssueRunnable({
|
||||
projectId: nextProjectId ?? null,
|
||||
projectWorkspaceId: nextProjectWorkspaceId ?? null,
|
||||
executionWorkspaceId: nextExecutionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: nextExecutionWorkspacePreference ?? null,
|
||||
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
|
||||
});
|
||||
}
|
||||
|
||||
applyStatusSideEffects(issueData.status, patch);
|
||||
if (issueData.status && issueData.status !== "done") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue