493 lines
21 KiB
TypeScript
493 lines
21 KiB
TypeScript
// PR node handlers for the unified PR-entity review loop (U3).
|
|
//
|
|
// Three first-class node kinds whose handlers own the PR side effects and emit
|
|
// outcomes the graph routes on:
|
|
// - pr-create : open (or reuse) the PR and write the entity to `open` /`failed`
|
|
// - pr-respond : run the review-response loop body (U5 fills the real body;
|
|
// U3 delegates to an injected callback defaulting to a no-op)
|
|
// - pr-merge : tool-side merge with `expectedHeadOid` (reconcile, U4,
|
|
// corroborates the terminal `merged` write — the node never does)
|
|
//
|
|
// All handlers are idempotent, fast (no indefinite waits — those are holds, U4),
|
|
// and fail-closed. The engine NEVER imports the dashboard GitHubClient: every
|
|
// GitHub side effect is an injected callback wired from the CLI composition layer
|
|
// (mirroring how `createGroupPr`/`syncGroupPr` are wired). That keeps the engine
|
|
// free of the dashboard dependency (FN-3049: static imports only, no dashboard
|
|
// client) and unit-testable with fakes.
|
|
|
|
import {
|
|
isPrEntityActionable,
|
|
isPrEntityAutoMergeReady,
|
|
type PrEntity,
|
|
type PrEntityCreateInput,
|
|
type PrEntityUpdate,
|
|
type TaskDetail,
|
|
type WorkflowIrNode,
|
|
} from "@fusion/core";
|
|
|
|
import type { WorkflowNodeHandler } from "./workflow-graph-executor.js";
|
|
import {
|
|
runPrResponseRun,
|
|
type PrResponseRunDeps,
|
|
type PrResponseRunStore,
|
|
type PrReviewThread,
|
|
type PrPushResult,
|
|
} from "./pr-response-run.js";
|
|
import { makePrResponseAgentRunner, makePrResponseGitOps } from "./pr-response-run-ops.js";
|
|
|
|
/**
|
|
* The narrow slice of the store the PR node handlers need. Declared structurally
|
|
* (not as the full `TaskStore`) so the engine stays decoupled from the concrete
|
|
* store and the handlers stay trivially fakeable in tests.
|
|
*/
|
|
export interface PrNodeStore extends PrResponseRunStore {
|
|
/** Create-or-reuse the single non-terminal entity for a source (AE6 idempotency). */
|
|
ensurePrEntityForSource(input: PrEntityCreateInput): PrEntity;
|
|
getPrEntity(id: string): PrEntity | null;
|
|
getActivePrEntityBySource(sourceType: PrEntity["sourceType"], sourceId: string): PrEntity | null;
|
|
updatePrEntity(id: string, patch: PrEntityUpdate): PrEntity;
|
|
}
|
|
|
|
/**
|
|
* Resolve the single live PR entity backing a task: prefer the task-keyed entity,
|
|
* then fall back to the branch-group entity. Branch-group PR entities are keyed by
|
|
* the branch GROUP id (sourceId = branch_groups.id, per migration 113), which the
|
|
* task carries on `branchContext.groupId` — NOT the task id. Falling back on
|
|
* `task.id` can never match a branch-group entity, so a shared-mode task would
|
|
* spuriously resolve to no-entity.
|
|
*/
|
|
function resolveActivePrEntity(store: PrNodeStore, task: TaskDetail): PrEntity | null {
|
|
const taskEntity = store.getActivePrEntityBySource("task", task.id);
|
|
if (taskEntity) return taskEntity;
|
|
const groupId = task.branchContext?.groupId;
|
|
if (!groupId) return null;
|
|
return store.getActivePrEntityBySource("branch-group", groupId);
|
|
}
|
|
|
|
/** Identity of the PR an entity is created for, resolved from the task + node. */
|
|
export type PrSourceDescriptor = PrEntityCreateInput;
|
|
|
|
/** Input for the injected `createPr` callback (the dashboard GitHubClient wrapper). */
|
|
export interface PrCreateCallInput {
|
|
task: TaskDetail;
|
|
node: WorkflowIrNode;
|
|
entity: PrEntity;
|
|
}
|
|
|
|
/** Result of a successful PR creation — the GitHub-mirror fields the node persists. */
|
|
export interface PrCreateCallResult {
|
|
prNumber: number;
|
|
prUrl: string;
|
|
/** Resolved head commit OID, persisted so `pr-merge` can pass `expectedHeadOid`. */
|
|
headOid?: string;
|
|
}
|
|
|
|
/** Input for the injected `mergePr` callback. */
|
|
export interface PrMergeCallInput {
|
|
task: TaskDetail;
|
|
node: WorkflowIrNode;
|
|
entity: PrEntity;
|
|
/** The head OID the merge is gated on (defeats the push/merge race, U2/U6). */
|
|
expectedHeadOid?: string;
|
|
}
|
|
|
|
/**
|
|
* Discriminated result of the injected `mergePr` callback. The callback wraps the
|
|
* dashboard `mergePr`, which throws `PrStaleHeadError` on a head-moved race; the
|
|
* wrapper catches it and returns `{ status: "stale-head" }` so the engine never
|
|
* imports the dashboard error class. Any other failure should be thrown so the
|
|
* handler classifies it as a benign retryable outcome.
|
|
*/
|
|
export type PrMergeCallResult =
|
|
| { status: "merged-requested" }
|
|
| { status: "stale-head" };
|
|
|
|
/** Input for the injected `respond` callback (U5 implements the real body). */
|
|
export interface PrRespondCallInput {
|
|
task: TaskDetail;
|
|
node: WorkflowIrNode;
|
|
entity: PrEntity;
|
|
context: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Result of the injected `respond` callback. `outcome` is the routing value the
|
|
* `pr-respond` node emits (drives the bounded-rework edge back to await-review):
|
|
* - "fixed" : a fix was pushed; loop back to await-review
|
|
* - "disagreed-only" : nothing actionable / all threads disagreed; leave open
|
|
*/
|
|
export interface PrRespondCallResult {
|
|
value: "fixed" | "disagreed-only";
|
|
contextPatch?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Dependencies the PR node handlers close over. All injected from the CLI
|
|
* composition layer where importing the dashboard GitHubClient IS allowed; the
|
|
* engine receives only plain callbacks + a structural store accessor.
|
|
*/
|
|
export interface PrNodeDeps {
|
|
/** Structural store accessor (the engine already owns the store instance). */
|
|
getStore(): PrNodeStore;
|
|
/**
|
|
* Resolve the PR source identity for a `pr-create` node from the task + node.
|
|
* The CLI wiring derives sourceType/sourceId (task id or branch-group id),
|
|
* repo, and head/base branch from the task's branch-naming + tracking config.
|
|
*/
|
|
resolvePrSource(task: TaskDetail, node: WorkflowIrNode): Promise<PrSourceDescriptor> | PrSourceDescriptor;
|
|
/** Open the PR on GitHub. Throws on failure (the node records `failed`). */
|
|
createPr(input: PrCreateCallInput): Promise<PrCreateCallResult>;
|
|
/** Merge the PR tool-side with `expectedHeadOid`. Returns a discriminated result. */
|
|
mergePr(input: PrMergeCallInput): Promise<PrMergeCallResult>;
|
|
/**
|
|
* Run the review-response body (U5). Defaults to a no-op returning
|
|
* `disagreed-only` when omitted, so U3 ships a routable-but-inert pr-respond.
|
|
*/
|
|
respond?: (input: PrRespondCallInput) => Promise<PrRespondCallResult>;
|
|
/** Optional audit sink, called with a stable reason on every routable failure. */
|
|
audit?: (reason: string, detail: string) => void;
|
|
}
|
|
|
|
/**
|
|
* The CLI-injected slice of {@link PrNodeDeps}: only the GitHub side-effect
|
|
* callbacks (which close over the dashboard `GitHubClient`) plus the source
|
|
* resolver and audit sink. The engine binds `getStore` itself (it owns the store
|
|
* instance) via {@link buildPrNodeDeps}, so the CLI layer never needs a store
|
|
* reference. Mirrors how `createGroupPr`/`syncGroupPr` are injected as plain
|
|
* callbacks from the CLI composition layer.
|
|
*/
|
|
export interface PrNodeGithubOps {
|
|
resolvePrSource: PrNodeDeps["resolvePrSource"];
|
|
createPr: PrNodeDeps["createPr"];
|
|
mergePr: PrNodeDeps["mergePr"];
|
|
/**
|
|
* Pre-built respond callback (rarely used directly; tests/specialized wiring).
|
|
* Prefer {@link respondOps}, which lets the engine bind the store + audit.
|
|
*/
|
|
respond?: PrNodeDeps["respond"];
|
|
/**
|
|
* The CLI-injected GitHub/git/agent ops backing the U5 review-response run.
|
|
* When present, {@link buildPrNodeDeps} constructs the `respond` callback from
|
|
* these + the engine-owned store, so the CLI layer never holds a store
|
|
* reference. The slice excludes `entity`/`store`/`audit`/`signal`, which the
|
|
* engine supplies per run.
|
|
*/
|
|
respondOps?: PrRespondGithubOps;
|
|
audit?: PrNodeDeps["audit"];
|
|
}
|
|
|
|
/**
|
|
* The CLI-injected slice for the U5 review-response run: the GitHub-client thread
|
|
* ops (which close over the dashboard `GitHubClient`, kept out of the engine) and
|
|
* a `getCwd` resolver mapping an entity to its PR-branch worktree path. The
|
|
* engine builds the git ops + agent runner itself ({@link buildRespondCallback}
|
|
* via {@link makePrResponseGitOps}/{@link makePrResponseAgentRunner}), so the CLI
|
|
* layer never holds the store/settings/session-helper concerns. Optional
|
|
* overrides (bot denylist, secret scanner, cap) pass through.
|
|
*/
|
|
export interface PrRespondGithubOps {
|
|
getReviewThreads: PrResponseRunDeps["getReviewThreads"];
|
|
getViewerLogin: PrResponseRunDeps["getViewerLogin"];
|
|
checkPrStillOpen: PrResponseRunDeps["checkPrStillOpen"];
|
|
replyToThread: PrResponseRunDeps["replyToThread"];
|
|
resolveThread: PrResponseRunDeps["resolveThread"];
|
|
/** Resolve the PR-branch worktree path for an entity (drives git ops + agent). */
|
|
getCwd: (entity: PrEntity) => string;
|
|
/** Resolve the task id used for the agent session / token accounting. */
|
|
getTaskId: (entity: PrEntity) => string;
|
|
/** Optional bot-denylist override (default `*[bot]`). */
|
|
isBot?: PrResponseRunDeps["isBot"];
|
|
/** Optional secret-scanner override. */
|
|
scanSecrets?: PrResponseRunDeps["scanSecrets"];
|
|
/** Optional iteration-cap override (R8). */
|
|
maxResponseRounds?: number;
|
|
}
|
|
|
|
/**
|
|
* Build the `respond` callback (U5) from the engine-owned store + CLI-injected
|
|
* GitHub ops. Assembles the git ops + mutating-agent runner here (engine-side,
|
|
* with store/settings/session helpers). Detached-turn safe:
|
|
* {@link runPrResponseRun} never throws, so this maps its result to the node's
|
|
* `{ value }` shape (the routing value the `pr-respond` node emits).
|
|
*/
|
|
export function buildRespondCallback(
|
|
getStore: () => PrNodeStore,
|
|
ops: PrRespondGithubOps,
|
|
audit?: PrNodeDeps["audit"],
|
|
): NonNullable<PrNodeDeps["respond"]> {
|
|
const gitOps = makePrResponseGitOps(ops.getCwd);
|
|
return async ({ entity }) => {
|
|
const store = getStore();
|
|
// The engine owns a concrete TaskStore behind the structural PrNodeStore; the
|
|
// agent runner + git ops need its settings + worktree. Resolve at run time.
|
|
const fullStore = store as unknown as import("@fusion/core").TaskStore;
|
|
const settings = await fullStore.getSettings();
|
|
const taskId = ops.getTaskId(entity);
|
|
const cwd = ops.getCwd(entity);
|
|
const runAgent = makePrResponseAgentRunner(settings, taskId, cwd);
|
|
|
|
const result = await runPrResponseRun({
|
|
entity,
|
|
store,
|
|
getReviewThreads: ops.getReviewThreads,
|
|
getViewerLogin: ops.getViewerLogin,
|
|
checkPrStillOpen: ops.checkPrStillOpen,
|
|
replyToThread: ops.replyToThread,
|
|
resolveThread: ops.resolveThread,
|
|
runAgent: ({ prompt, systemPrompt, threads, signal }) =>
|
|
runAgent({ prompt, systemPrompt, threads, signal }),
|
|
getChangedContent: gitOps.getChangedContent,
|
|
getWorktreeHeadOid: gitOps.getWorktreeHeadOid,
|
|
fetchAndFastForwardPush: gitOps.fetchAndFastForwardPush,
|
|
isBot: ops.isBot,
|
|
scanSecrets: ops.scanSecrets,
|
|
maxResponseRounds: ops.maxResponseRounds,
|
|
audit: audit ? (reason, detail) => audit(reason, detail) : undefined,
|
|
});
|
|
return { value: result.value };
|
|
};
|
|
}
|
|
|
|
// Touch imported types so they participate in the public surface (re-exported via
|
|
// index.ts) without an unused-import diagnostic when only referenced indirectly.
|
|
export type { PrReviewThread, PrPushResult };
|
|
|
|
/**
|
|
* Assemble full {@link PrNodeDeps} from the engine-owned store + the CLI-injected
|
|
* GitHub ops. Used by the runtime/executor wiring so the CLI layer stays free of
|
|
* any store reference and the engine never imports the dashboard client.
|
|
*/
|
|
export function buildPrNodeDeps(getStore: () => PrNodeStore, ops: PrNodeGithubOps): PrNodeDeps {
|
|
// U5: when the CLI injects `respondOps`, build the real review-response run
|
|
// callback here (the engine binds the store + audit). An explicit `respond`
|
|
// takes precedence (tests/specialized wiring); absent both → inert default.
|
|
const respond = ops.respond
|
|
?? (ops.respondOps ? buildRespondCallback(getStore, ops.respondOps, ops.audit) : undefined);
|
|
return {
|
|
getStore,
|
|
resolvePrSource: ops.resolvePrSource,
|
|
createPr: ops.createPr,
|
|
mergePr: ops.mergePr,
|
|
respond,
|
|
audit: ops.audit,
|
|
};
|
|
}
|
|
|
|
function classifyError(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/**
|
|
* Build the three PR node handlers from injected deps. Mirrors the seam-injection
|
|
* pattern (`createStepReviewHandler` / `createParseStepsHandler`): the engine
|
|
* graph layer stays engine-agnostic and unit-testable with fakes.
|
|
*/
|
|
export function createPrNodeHandlers(deps: PrNodeDeps): Record<
|
|
"pr-create" | "pr-respond" | "pr-merge",
|
|
WorkflowNodeHandler
|
|
> {
|
|
const audit = (reason: string, detail: string): void => {
|
|
try {
|
|
deps.audit?.(reason, detail);
|
|
} catch {
|
|
// Audit must never affect the run.
|
|
}
|
|
};
|
|
|
|
// ── pr-create ──────────────────────────────────────────────────────────────
|
|
// Ensure the entity in `creating`, call GitHub, flip to `open` on success or
|
|
// `failed` (routable, NOT a thrown error) on failure. Re-entry on an already
|
|
// open entity is a no-op emitting value:"open" (AE6 create-or-reuse idempotency).
|
|
const prCreate: WorkflowNodeHandler = async (node, ctx) => {
|
|
const store = deps.getStore();
|
|
|
|
let source: PrSourceDescriptor;
|
|
try {
|
|
source = await deps.resolvePrSource(ctx.task, node);
|
|
} catch (err) {
|
|
const detail = `pr-create node '${node.id}' could not resolve PR source: ${classifyError(err)}`;
|
|
audit("pr-create-source-error", detail);
|
|
// No entity yet → fail closed with a routable outcome.
|
|
return { outcome: "failure", value: "source-error" };
|
|
}
|
|
|
|
// Create-or-reuse the single live entity (the store enforces the partial
|
|
// unique index, so re-entry never mints a second entity).
|
|
const entity = store.ensurePrEntityForSource({
|
|
...source,
|
|
state: source.state ?? "creating",
|
|
});
|
|
|
|
// Idempotent re-entry: an already-open entity with a persisted PR is a no-op.
|
|
if (entity.state === "open" && entity.prNumber != null) {
|
|
return { outcome: "success", value: "open" };
|
|
}
|
|
|
|
// Ensure the row is in `creating` before the side effect (so a crash mid-flight
|
|
// leaves a recoverable state, not a stale `failed`).
|
|
const creating = entity.state === "creating" ? entity : store.updatePrEntity(entity.id, { state: "creating" });
|
|
|
|
let created: PrCreateCallResult;
|
|
try {
|
|
created = await deps.createPr({ task: ctx.task, node, entity: creating });
|
|
} catch (err) {
|
|
const reason = classifyError(err);
|
|
audit("pr-create-failed", `pr-create node '${node.id}' creation failed: ${reason}`);
|
|
// Failure is a ROUTABLE outcome — the graph routes on value:"failed". Record
|
|
// the classified reason and the failed state; never throw.
|
|
store.updatePrEntity(creating.id, { state: "failed", failureReason: reason });
|
|
return { outcome: "success", value: "failed" };
|
|
}
|
|
|
|
store.updatePrEntity(creating.id, {
|
|
state: "open",
|
|
prNumber: created.prNumber,
|
|
prUrl: created.prUrl,
|
|
headOid: created.headOid ?? null,
|
|
});
|
|
return { outcome: "success", value: "open" };
|
|
};
|
|
|
|
// ── pr-merge ───────────────────────────────────────────────────────────────
|
|
// Merge tool-side with `expectedHeadOid` from the entity. Does NOT write the
|
|
// terminal `merged` state — the reconcile (U4) corroborates that from GitHub.
|
|
// A stale-head race emits value:"stale-head" leaving the entity open; a clean
|
|
// merge request emits value:"merged-requested".
|
|
const prMerge: WorkflowNodeHandler = async (node, ctx) => {
|
|
const store = deps.getStore();
|
|
const entity = resolveActivePrEntity(store, ctx.task);
|
|
|
|
if (!entity) {
|
|
audit("pr-merge-no-entity", `pr-merge node '${node.id}' found no live PR entity for task ${ctx.task.id}`);
|
|
return { outcome: "failure", value: "no-entity" };
|
|
}
|
|
|
|
// Unverified entities (imported legacy state GitHub has not corroborated) are
|
|
// a hard gate (R19): never merge on stale state — emit a benign outcome.
|
|
if (!isPrEntityActionable(entity)) {
|
|
audit("pr-merge-not-actionable", `pr-merge node '${node.id}' entity ${entity.id} not actionable (unverified/terminal)`);
|
|
return { outcome: "success", value: "not-actionable" };
|
|
}
|
|
|
|
let result: PrMergeCallResult;
|
|
try {
|
|
result = await deps.mergePr({
|
|
task: ctx.task,
|
|
node,
|
|
entity,
|
|
expectedHeadOid: entity.headOid,
|
|
});
|
|
} catch (err) {
|
|
// A non-stale merge error is benign/retryable — never throw out of the
|
|
// handler, and never write `merged`. Route a routable failure value.
|
|
const reason = classifyError(err);
|
|
audit("pr-merge-error", `pr-merge node '${node.id}' merge failed: ${reason}`);
|
|
return { outcome: "failure", value: "merge-error" };
|
|
}
|
|
|
|
if (result.status === "stale-head") {
|
|
// The head moved since we read `expectedHeadOid`; leave the entity open so a
|
|
// re-evaluation merges against the new head. Never write `merged`.
|
|
return { outcome: "success", value: "stale-head" };
|
|
}
|
|
|
|
// Merge requested cleanly. Do NOT write `merged` here — reconcile corroborates.
|
|
return { outcome: "success", value: "merged-requested" };
|
|
};
|
|
|
|
// ── pr-respond ─────────────────────────────────────────────────────────────
|
|
// Delegate to the injected `respond` callback (U5 implements the real body).
|
|
// Defaults to a no-op returning value:"disagreed-only". Increments the entity's
|
|
// responseRounds (the R8 iteration-cap counter, survives restart).
|
|
const prRespond: WorkflowNodeHandler = async (node, ctx) => {
|
|
const store = deps.getStore();
|
|
const entity = resolveActivePrEntity(store, ctx.task);
|
|
|
|
if (!entity) {
|
|
audit("pr-respond-no-entity", `pr-respond node '${node.id}' found no live PR entity for task ${ctx.task.id}`);
|
|
return { outcome: "failure", value: "no-entity" };
|
|
}
|
|
|
|
// Unverified/terminal entities are not responded to (R19 hard gate).
|
|
if (!isPrEntityActionable(entity)) {
|
|
audit("pr-respond-not-actionable", `pr-respond node '${node.id}' entity ${entity.id} not actionable (unverified/terminal)`);
|
|
return { outcome: "success", value: "not-actionable" };
|
|
}
|
|
|
|
// Bump the rework-cycle counter (R8 cap backing; persisted). Forward the
|
|
// POST-update entity so runPrResponseRun's cap check (`responseRounds > cap`)
|
|
// sees this round's count — passing the stale pre-increment entity fires the
|
|
// cap one round too late.
|
|
const updatedEntity = store.updatePrEntity(entity.id, { responseRounds: entity.responseRounds + 1 });
|
|
|
|
if (!deps.respond) {
|
|
// U3 default: inert but routable. U5 wires the real review-response run.
|
|
return { outcome: "success", value: "disagreed-only" };
|
|
}
|
|
|
|
let result: PrRespondCallResult;
|
|
try {
|
|
result = await deps.respond({ task: ctx.task, node, entity: updatedEntity, context: ctx.context });
|
|
} catch (err) {
|
|
const reason = classifyError(err);
|
|
audit("pr-respond-error", `pr-respond node '${node.id}' response run failed: ${reason}`);
|
|
return { outcome: "failure", value: "respond-error" };
|
|
}
|
|
|
|
return { outcome: "success", value: result.value, contextPatch: result.contextPatch };
|
|
};
|
|
|
|
return {
|
|
"pr-create": prCreate,
|
|
"pr-respond": prRespond,
|
|
"pr-merge": prMerge,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Auto-merge gate handler (U6, R10). Placed after the approval step. It
|
|
* re-evaluates the LIVE PR entity each time (never trusts a cached/SSE copy) and
|
|
* routes:
|
|
*
|
|
* - `outcome:auto-on` → toward `pr-merge`, when {@link isPrEntityAutoMergeReady}
|
|
* (opted in + approved + all checks concluded success + mergeable clean +
|
|
* verified).
|
|
* - `outcome:auto-off` → park for a manual-release merge, for EVERY non-ready
|
|
* case: not opted in, pending/failed checks, UNKNOWN/conflicting mergeability,
|
|
* unverified entity, or no live entity at all. The gate never blocks the run.
|
|
*
|
|
* Reuses the gate-routing contract (`{ outcome: "success", value }` consumed by
|
|
* `outcome:` edges in shouldTraverseEdge) rather than forking a parallel routing
|
|
* mechanism. The store/entity lookup is injected via {@link PrNodeDeps} so the
|
|
* engine stays dashboard-import-free. The `auto-merge ready` predicate lives in
|
|
* @fusion/core so the gate, the dashboard, and the reconcile share one
|
|
* definition and cannot drift.
|
|
*/
|
|
export function createAutoMergeGateHandler(deps: Pick<PrNodeDeps, "getStore" | "audit">): WorkflowNodeHandler {
|
|
const audit = (reason: string, detail: string): void => {
|
|
try {
|
|
deps.audit?.(reason, detail);
|
|
} catch {
|
|
// Audit must never affect the run.
|
|
}
|
|
};
|
|
return async (node, ctx) => {
|
|
const store = deps.getStore();
|
|
const entity = resolveActivePrEntity(store, ctx.task);
|
|
|
|
if (!entity) {
|
|
// No live entity → cannot auto-merge; park for manual handling (never block).
|
|
audit("auto-merge-gate-no-entity", `auto-merge gate '${node.id}' found no live PR entity for task ${ctx.task.id}`);
|
|
return { outcome: "success", value: "auto-off" };
|
|
}
|
|
|
|
// Re-fetch authoritative state: the entity row IS the live copy here (store
|
|
// read), so pending checks / UNKNOWN mergeable / unverified / not-opted-in all
|
|
// fall to auto-off via the shared predicate.
|
|
if (isPrEntityAutoMergeReady(entity)) {
|
|
return { outcome: "success", value: "auto-on" };
|
|
}
|
|
return { outcome: "success", value: "auto-off" };
|
|
};
|
|
}
|