Skip to content

Commit 0d26156

Browse files
fix(flow-designer): pre-release stale locks in open_flow and deduplicate subflow variables
Two fixes: 1. open_flow now pre-releases any existing locks before acquiring. The OAuth service account's own stale lock from a previous session caused safeEdit(create) to always return canEdit=false with empty debug. Added better debug capture (raw GraphQL keys, errors, null result detection). On retry, aggressive cleanup of both sys_hub_flow_safe_edit and sys_hub_flow_lock tables with longer delays (1.5s + 2s). 2. buildSubflowInputsForInsert now deduplicates sys_hub_flow_variable records by both sys_id and name, preventing 'Duplicate key var__m_sys_hub_flow_variable_<id>' GraphQL errors. Added ORDER BY to variable queries and schemaless/schemalessValue to input value objects to match UI format exactly. Added input_ids and output_names to debug output for troubleshooting. Co-authored-by: snow-flow-agent[bot] <snow-flow-agent[bot]@users.noreply.github.com>
1 parent b62a236 commit 0d26156

File tree

1 file changed

+101
-23
lines changed

1 file changed

+101
-23
lines changed

packages/opencode/src/servicenow/servicenow-mcp-unified/tools/flow-designer/snow_manage_flow.ts

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -568,13 +568,23 @@ async function acquireFlowEditingLock(
568568
flowId +
569569
'"}) { createResult { canEdit id editingUserDisplayName __typename } __typename } __typename } __typename } }'
570570
var resp = await client.post("/api/now/graphql", { variables: {}, query: mutation })
571+
var gqlErrors = resp.data?.errors
572+
if (gqlErrors && gqlErrors.length > 0) {
573+
debug.graphql_errors = gqlErrors.map(function (e: any) {
574+
return e.message || JSON.stringify(e)
575+
})
576+
}
571577
var result = resp.data?.data?.global?.snFlowDesigner?.safeEdit?.createResult
572-
debug.graphql_response = result
573-
if (result?.canEdit !== true && result?.canEdit !== "true") {
574-
var editingUser = result?.editingUserDisplayName || "another user"
578+
debug.graphql_response = result || null
579+
debug.graphql_raw_keys = resp.data?.data ? Object.keys(resp.data.data) : null
580+
if (result?.canEdit === true || result?.canEdit === "true") {
581+
debug.graphql_canEdit = true
582+
} else if (result) {
583+
var editingUser = result.editingUserDisplayName || "another user"
575584
return { success: false, error: "Flow is locked by " + editingUser, debug }
585+
} else {
586+
debug.graphql_result_null = true
576587
}
577-
debug.graphql_canEdit = true
578588
} catch (e: any) {
579589
debug.graphql_error = e.message
580590
}
@@ -4869,14 +4879,24 @@ async function buildSubflowInputsForInsert(
48694879
try {
48704880
var inpResp = await client.get("/api/now/table/sys_hub_flow_variable", {
48714881
params: {
4872-
sysparm_query: "flow=" + subflowSysId + "^variable_type=input",
4882+
sysparm_query: "flow=" + subflowSysId + "^variable_type=input^ORDERBYorder",
48734883
sysparm_fields:
48744884
"sys_id,name,label,internal_type,mandatory,default_value,order,max_length,hint,read_only,extended,data_structure,reference,reference_display,ref_qual,choice_option,table_name,column_name,use_dependent,dependent_on,show_ref_finder,local,attributes,sys_class_name",
48754885
sysparm_display_value: "false",
48764886
sysparm_limit: 50,
48774887
},
48784888
})
4879-
inputParams = inpResp.data.result || []
4889+
var rawInputs = inpResp.data.result || []
4890+
var seenIds: Record<string, boolean> = {}
4891+
var seenNames: Record<string, boolean> = {}
4892+
for (var ri = 0; ri < rawInputs.length; ri++) {
4893+
var rid = str(rawInputs[ri].sys_id)
4894+
var rname = str(rawInputs[ri].name)
4895+
if (seenIds[rid] || seenNames[rname]) continue
4896+
seenIds[rid] = true
4897+
seenNames[rname] = true
4898+
inputParams.push(rawInputs[ri])
4899+
}
48804900
} catch (e: any) {
48814901
console.warn(
48824902
"[snow_manage_flow] sys_hub_flow_variable input query failed for flow=" + subflowSysId + ": " + (e.message || ""),
@@ -4885,13 +4905,23 @@ async function buildSubflowInputsForInsert(
48854905
try {
48864906
var outResp = await client.get("/api/now/table/sys_hub_flow_variable", {
48874907
params: {
4888-
sysparm_query: "flow=" + subflowSysId + "^variable_type=output",
4908+
sysparm_query: "flow=" + subflowSysId + "^variable_type=output^ORDERBYorder",
48894909
sysparm_fields: "sys_id,name,label,internal_type,mandatory,order,reference,attributes",
48904910
sysparm_display_value: "false",
48914911
sysparm_limit: 50,
48924912
},
48934913
})
4894-
outputParams = outResp.data.result || []
4914+
var rawOutputs = outResp.data.result || []
4915+
var seenOutIds: Record<string, boolean> = {}
4916+
var seenOutNames: Record<string, boolean> = {}
4917+
for (var ro = 0; ro < rawOutputs.length; ro++) {
4918+
var roid = str(rawOutputs[ro].sys_id)
4919+
var roname = str(rawOutputs[ro].name)
4920+
if (seenOutIds[roid] || seenOutNames[roname]) continue
4921+
seenOutIds[roid] = true
4922+
seenOutNames[roname] = true
4923+
outputParams.push(rawOutputs[ro])
4924+
}
48954925
} catch (e: any) {
48964926
console.warn(
48974927
"[snow_manage_flow] sys_hub_flow_variable output query failed for flow=" +
@@ -4943,15 +4973,16 @@ async function buildSubflowInputsForInsert(
49434973
var inputs = inputParams.map(function (rec: any) {
49444974
var paramType = str(rec.internal_type) || "string"
49454975
var name = str(rec.name)
4976+
var varId = str(rec.sys_id)
49464977
var userVal = resolvedInputs[name] || ""
49474978
return {
4948-
id: str(rec.sys_id),
4979+
id: varId,
49494980
name: name,
49504981
children: [],
49514982
displayValue: { value: "" },
4952-
value: { value: userVal },
4983+
value: { schemaless: false, schemalessValue: "", value: userVal },
49534984
parameter: {
4954-
id: str(rec.sys_id),
4985+
id: varId,
49554986
label: str(rec.label) || name,
49564987
name: name,
49574988
type: paramType,
@@ -5140,6 +5171,12 @@ async function addSubflowCallViaGraphQL(
51405171
outputs: built.outputs.length,
51415172
resolved: built.resolvedInputs,
51425173
missing: built.missingMandatory,
5174+
input_ids: built.inputs.map(function (inp: any) {
5175+
return inp.id + ":" + inp.name
5176+
}),
5177+
output_names: built.outputs.map(function (out: any) {
5178+
return out.name
5179+
}),
51435180
}
51445181

51455182
const subPatch: any = {
@@ -8049,6 +8086,7 @@ export async function execute(args: any, context: ServiceNowContext): Promise<To
80498086
case "open_flow": {
80508087
var openFlowId = await resolveFlowId(client, args.flow_id)
80518088
var openSummary = summary()
8089+
var openDebug: any = {}
80528090

80538091
// Step 1: Load flow data via processflow GET (same as UI)
80548092
try {
@@ -8057,8 +8095,22 @@ export async function execute(args: any, context: ServiceNowContext): Promise<To
80578095
/* best-effort — flow data load is not critical for lock acquisition */
80588096
}
80598097

8060-
// Step 2: Acquire editing lock via safeEdit create mutation + REST fallback
8098+
// Step 2: Pre-release any existing locks (the OAuth service account may hold a stale lock
8099+
// from a previous session, and safeEdit(create) returns canEdit=false for the SAME user's lock)
8100+
var preRelease = await releaseFlowEditingLock(client, openFlowId)
8101+
openDebug.pre_release = {
8102+
success: preRelease.success,
8103+
safe_edit_cleaned: preRelease.debug?.safe_edit_records_cleaned,
8104+
flow_lock_cleaned: preRelease.debug?.flow_lock_records_cleaned,
8105+
}
8106+
// Brief delay to let ServiceNow propagate the lock deletion
8107+
await new Promise(function (resolve) {
8108+
setTimeout(resolve, 1500)
8109+
})
8110+
8111+
// Step 3: Acquire editing lock via safeEdit create mutation + REST fallback
80618112
var lockResult = await acquireFlowEditingLock(client, openFlowId)
8113+
openDebug.acquire = lockResult.debug
80628114
if (lockResult.success) {
80638115
openSummary
80648116
.success("Flow opened for editing (lock acquired)")
@@ -8070,7 +8122,7 @@ export async function execute(args: any, context: ServiceNowContext): Promise<To
80708122
flow_id: openFlowId,
80718123
editing_session: true,
80728124
lock_acquired_at: new Date().toISOString(),
8073-
lock_debug: lockResult.debug,
8125+
lock_debug: openDebug,
80748126
lock_warning:
80758127
"IMPORTANT: Editing lock is held. You MUST call close_flow with flow_id='" +
80768128
openFlowId +
@@ -8082,28 +8134,53 @@ export async function execute(args: any, context: ServiceNowContext): Promise<To
80828134
)
80838135
}
80848136

8085-
// Step 3: Lock failed — auto-release ghost lock and retry once
8086-
// releaseFlowEditingLock now also cleans sys_hub_flow_lock table
8087-
openSummary.line("Lock acquisition failed, attempting ghost lock cleanup...")
8088-
await releaseFlowEditingLock(client, openFlowId)
8089-
// Brief delay to let ServiceNow propagate the lock deletion before re-acquiring
8137+
// Step 4: Lock still failed — try force cleanup of ALL lock tables + longer delay + retry
8138+
openSummary.line("Lock acquisition failed after pre-release, attempting aggressive cleanup...")
8139+
openDebug.first_attempt_error = lockResult.error
8140+
// Aggressively clean all lock tables (same as force_unlock)
8141+
try {
8142+
var seResp = await client.get("/api/now/table/sys_hub_flow_safe_edit", {
8143+
params: { sysparm_query: "document_id=" + openFlowId, sysparm_fields: "sys_id", sysparm_limit: 50 },
8144+
})
8145+
var seRecs = seResp.data?.result || []
8146+
for (var sei = 0; sei < seRecs.length; sei++) {
8147+
try {
8148+
await client.delete("/api/now/table/sys_hub_flow_safe_edit/" + seRecs[sei].sys_id)
8149+
} catch (_) {}
8150+
}
8151+
openDebug.force_safe_edit_deleted = seRecs.length
8152+
} catch (_) {}
8153+
try {
8154+
var flResp = await client.get("/api/now/table/sys_hub_flow_lock", {
8155+
params: { sysparm_query: "flow=" + openFlowId, sysparm_fields: "sys_id", sysparm_limit: 50 },
8156+
})
8157+
var flRecs = flResp.data?.result || []
8158+
for (var fli = 0; fli < flRecs.length; fli++) {
8159+
try {
8160+
await client.delete("/api/now/table/sys_hub_flow_lock/" + flRecs[fli].sys_id)
8161+
} catch (_) {}
8162+
}
8163+
openDebug.force_flow_lock_deleted = flRecs.length
8164+
} catch (_) {}
8165+
// Longer delay after aggressive cleanup
80908166
await new Promise(function (resolve) {
8091-
setTimeout(resolve, 1000)
8167+
setTimeout(resolve, 2000)
80928168
})
80938169
var retryResult = await acquireFlowEditingLock(client, openFlowId)
8170+
openDebug.retry = retryResult.debug
80948171
if (retryResult.success) {
80958172
openSummary
8096-
.success("Flow opened for editing (ghost lock cleared)")
8173+
.success("Flow opened for editing (stale lock cleared)")
80978174
.field("Flow", openFlowId)
80988175
.line("You can now use add_action, add_flow_logic, etc. Call close_flow when done.")
80998176
return createSuccessResult(
81008177
{
81018178
action: "open_flow",
81028179
flow_id: openFlowId,
81038180
editing_session: true,
8104-
ghost_lock_cleared: true,
8181+
stale_lock_cleared: true,
81058182
lock_acquired_at: new Date().toISOString(),
8106-
lock_debug: retryResult.debug,
8183+
lock_debug: openDebug,
81078184
lock_warning:
81088185
"IMPORTANT: Editing lock is held. You MUST call close_flow with flow_id='" +
81098186
openFlowId +
@@ -8122,7 +8199,8 @@ export async function execute(args: any, context: ServiceNowContext): Promise<To
81228199
return createErrorResult(
81238200
"Cannot open flow for editing: " +
81248201
(retryResult.error || "lock acquisition failed") +
8125-
(retryResult.debug ? " | debug: " + JSON.stringify(retryResult.debug) : ""),
8202+
" | debug: " +
8203+
JSON.stringify(openDebug),
81268204
)
81278205
}
81288206

0 commit comments

Comments
 (0)