Merge pull request #1575 from Runfusion/feature/workflow-owned-merge-s02-merge-request-projection

refactor(workflow): S02 merge request projection onto work items
This commit is contained in:
gsxdsm
2026-06-11 07:51:58 -07:00
committed by GitHub
5 changed files with 255 additions and 2 deletions

View File

@@ -0,0 +1,46 @@
---
title: "S02: merge request projection onto work items"
type: refactor
status: draft-stack-handoff
date: 2026-06-09
slice: S02
milestone: "Foundation"
origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md
stack_base: feature/workflow-owned-merge-retry-scheduling-plan
---
# S02: merge request projection onto work items
## Stack Role
This draft PR reserves the S02 review slot in the workflow-owned merge,
retry, scheduling, and recovery migration stack. It is intentionally a handoff
artifact, not the completed implementation for this slice.
## Milestone
Foundation
## Depends On
S1 workflow work-item schema and store API.
## Goal
Project existing merge request records into workflow work-item state so dashboards and schedulers can dual-read before cutover.
## Expected File Scope
packages/core/src/store.ts; packages/core/src/task-merge.ts; packages/core/src/types.ts; merge-request and dual-observe tests.
## Expected Tests
Projection tests for queued/running/retrying/manual-required/succeeded/exhausted states, hard cancel cancellation, and restart idempotency.
## Exit Gate
Every merge request state has a lossless workflow work-item equivalent.
## Full Plan
See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`.

View File

@@ -69,6 +69,87 @@ describe("TaskStore merge request record + completion handoff marker", () => {
expect(store.transitionMergeRequestState(taskId, "succeeded", { now: "2026-05-30T00:00:05.000Z" }).state).toBe("succeeded");
});
it("projects merge request states onto workflow work items", async () => {
const cases = [
{ mergeState: "queued", workState: "runnable", kind: "merge" },
{ mergeState: "running", workState: "running", kind: "merge" },
{ mergeState: "retrying", workState: "retrying", kind: "merge" },
{ mergeState: "manual-required", workState: "manual-required", kind: "manual-hold" },
{ mergeState: "succeeded", workState: "succeeded", kind: "merge" },
{ mergeState: "exhausted", workState: "exhausted", kind: "merge" },
{ mergeState: "cancelled", workState: "cancelled", kind: "merge" },
] as const;
for (const { mergeState, workState, kind } of cases) {
const taskId = await createTask();
store.upsertMergeRequestRecord(taskId, {
state: mergeState,
attemptCount: 3,
lastError: mergeState === "manual-required" ? "needs human" : "last failure",
now: "2026-05-30T00:00:00.000Z",
});
const item = store.projectMergeRequestToWorkflowWorkItem(taskId, {
now: "2026-05-30T00:00:01.000Z",
});
expect(item).toMatchObject({
runId: `merge-request:${taskId}`,
taskId,
nodeId: "builtin.merge.request",
kind,
state: workState,
attempt: 3,
});
}
});
it("projects merge requests idempotently across restart-style replays", async () => {
const taskId = await createTask();
store.upsertMergeRequestRecord(taskId, {
state: "retrying",
attemptCount: 2,
lastError: "network reset",
now: "2026-05-30T00:00:00.000Z",
});
const first = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:01.000Z" });
const second = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:02.000Z" });
expect(second?.id).toBe(first?.id);
expect(store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] })).toHaveLength(1);
expect(second).toMatchObject({ state: "retrying", attempt: 2, lastError: "network reset" });
});
it("cancels stale manual-hold projection when the same merge request succeeds", async () => {
const taskId = await createTask();
store.upsertMergeRequestRecord(taskId, {
state: "manual-required",
attemptCount: 1,
lastError: "needs human",
now: "2026-05-30T00:00:00.000Z",
});
const hold = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:01.000Z" });
store.upsertMergeRequestRecord(taskId, {
state: "succeeded",
attemptCount: 1,
lastError: null,
now: "2026-05-30T00:00:02.000Z",
});
const merge = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:03.000Z" });
expect(merge).toMatchObject({ kind: "merge", state: "succeeded" });
expect(store.getWorkflowWorkItem(hold?.id ?? "")).toMatchObject({
kind: "manual-hold",
state: "cancelled",
lastError: "superseded-by-merge-request-projection",
});
expect(store.listWorkflowWorkItemsForTask(taskId).filter((item) => item.state !== "cancelled")).toEqual([
expect.objectContaining({ id: merge?.id, kind: "merge", state: "succeeded" }),
]);
});
it("rejects invalid merge-request transitions", async () => {
const taskId = await createTask();
store.upsertMergeRequestRecord(taskId, { state: "queued" });
@@ -112,4 +193,33 @@ describe("TaskStore merge request record + completion handoff marker", () => {
expect(store.getMergeRequestRecord(taskId)?.state).toBe("cancelled");
expect(store.getCompletionHandoffAcceptedMarker(taskId)).toBeNull();
});
it("cancels active workflow merge work on user hard-cancel from in-review to todo", async () => {
const taskId = await createTask();
await store.moveTask(taskId, "todo");
await store.moveTask(taskId, "in-progress");
await store.handoffToReview(taskId, {
ownerAgentId: "agent-test",
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-test" },
});
store.setCompletionHandoffAcceptedMarker(taskId, { source: "executor:fn_task_done" });
const mergeWork = store.upsertWorkflowWorkItem({
runId: "run-merge",
taskId,
nodeId: "builtin.merge.request",
kind: "merge",
state: "running",
leaseOwner: "worker-a",
leaseExpiresAt: "2026-05-30T00:05:00.000Z",
});
await store.moveTask(taskId, "todo", { moveSource: "user" });
expect(store.getWorkflowWorkItem(mergeWork.id)).toMatchObject({
state: "cancelled",
leaseOwner: null,
leaseExpiresAt: null,
lastError: "cancelled-by-user-hard-cancel",
});
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
@@ -7149,6 +7149,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
});
}
}
this.cancelActiveWorkflowWorkItemsForTask(id, {
kinds: ["merge", "manual-hold"],
now: movedAt,
lastError: "cancelled-by-user-hard-cancel",
});
this.clearCompletionHandoffAcceptedMarker(id);
}
if (toColumn === "done") {
@@ -8854,6 +8859,19 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "exhausted";
}
private workflowStateForMergeRequestState(state: MergeRequestState): WorkflowWorkItemState {
const states: Record<MergeRequestState, WorkflowWorkItemState> = {
queued: "runnable",
running: "running",
retrying: "retrying",
succeeded: "succeeded",
exhausted: "exhausted",
cancelled: "cancelled",
"manual-required": "manual-required",
};
return states[state];
}
private rowToWorkflowWorkItem(row: WorkflowWorkItemRow): WorkflowWorkItem {
return {
id: row.id,
@@ -8962,6 +8980,43 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return row ? this.rowToMergeRequestRecord(row) : null;
}
projectMergeRequestToWorkflowWorkItem(
taskId: string,
opts: MergeRequestWorkflowProjectionOptions = {},
): WorkflowWorkItem | null {
return this.db.transactionImmediate(() => {
const record = this.getMergeRequestRecord(taskId);
if (!record) return null;
const state = this.workflowStateForMergeRequestState(record.state);
const kind = record.state === "manual-required" ? "manual-hold" : "merge";
const item = this.upsertWorkflowWorkItem({
runId: opts.runId ?? `merge-request:${taskId}`,
taskId,
nodeId: opts.nodeId ?? "builtin.merge.request",
kind,
state,
attempt: record.attemptCount,
lastError: record.lastError,
blockedReason: record.state === "manual-required" ? record.lastError ?? "manual merge required" : null,
now: opts.now ?? record.updatedAt,
});
this.cancelActiveWorkflowWorkItemsForTask(taskId, {
kinds: [kind === "manual-hold" ? "merge" : "manual-hold"],
now: opts.now ?? record.updatedAt,
lastError: "superseded-by-merge-request-projection",
});
this.insertRunAuditEventRow({
taskId,
runId: item.runId,
domain: "database",
mutationType: "mergeRequest:workflow-projection",
target: item.id,
metadata: { taskId, mergeRequestState: record.state, workflowState: item.state, workItemKind: item.kind },
});
return item;
});
}
upsertWorkflowWorkItem(input: WorkflowWorkItemUpsertInput): WorkflowWorkItem {
return this.db.transactionImmediate(() => {
const existing = this.db
@@ -9083,6 +9138,42 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return row ? this.rowToWorkflowWorkItem(row) : null;
}
listWorkflowWorkItemsForTask(taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): WorkflowWorkItem[] {
const conditions = ["taskId = ?"];
const params: unknown[] = [taskId];
if (opts.kinds?.length) {
conditions.push(`kind IN (${opts.kinds.map(() => "?").join(", ")})`);
params.push(...opts.kinds);
}
const rows = this.db
.prepare(
`SELECT *
FROM workflow_work_items
WHERE ${conditions.join(" AND ")}
ORDER BY createdAt ASC, id ASC`,
)
.all(...params) as WorkflowWorkItemRow[];
return rows.map((row) => this.rowToWorkflowWorkItem(row));
}
cancelActiveWorkflowWorkItemsForTask(
taskId: string,
opts: { kinds?: WorkflowWorkItemKind[]; now?: string; lastError?: string | null } = {},
): WorkflowWorkItem[] {
return this.db.transactionImmediate(() => {
const activeStates: WorkflowWorkItemState[] = ["runnable", "running", "held", "retrying", "manual-required"];
const items = this.listWorkflowWorkItemsForTask(taskId, opts).filter((item) => activeStates.includes(item.state));
return items.map((item) =>
this.transitionWorkflowWorkItem(item.id, "cancelled", {
now: opts.now,
leaseOwner: null,
leaseExpiresAt: null,
lastError: opts.lastError ?? item.lastError ?? "cancelled-by-user-hard-cancel",
}),
);
});
}
listDueWorkflowWorkItems(filter: WorkflowWorkItemDueFilter = {}): WorkflowWorkItem[] {
const now = filter.now ?? new Date().toISOString();
const includeExpiredRunning = !filter.states || filter.states.includes("running");

View File

@@ -160,6 +160,12 @@ export interface WorkflowWorkItemDueFilter {
states?: WorkflowWorkItemState[];
}
export interface MergeRequestWorkflowProjectionOptions {
runId?: string;
nodeId?: string;
now?: string;
}
export interface MergeQueueEntry {
taskId: string;
enqueuedAt: string;