Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { defineActionDescriptor } from '@objectstack/spec/automation';

/**
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
* the approvals reject door a property of `plugin-approvals`, or of
* `resumeInternal` itself?
*
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
* answered 500 with:
*
* ...run 'run_...' could not be resumed and is now stranded: resume of run
* '...' failed: Node 'mark_rejected' failed:
* update_record(crm_leave_request) failed: Record ... not found
*
* Nothing in THIS file touches approvals. The flow below is a plain pausing
* node with `resumeAuthority: 'any'` continued through the generic
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
* serves. If the strand reproduces here, the strand is the engine's, and the
* word "stranded" is only the approvals-side error prose wrapped around it.
*
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
* `traverseNext` runs any downstream node. So a downstream node that throws
* throws with the pause already gone — there is nothing left to resume, and no
* engine verb puts it back.
*
* These are CHARACTERIZATION assertions: they describe what the engine does
* today, including the part that is the defect. A repair for #13807 SHOULD
* turn them red; that is the point of pinning them now, so the repair has to
* state which of these facts it changed.
*/

function silentLogger() {
return {
info() {}, warn() {}, error() {}, debug() {},
child() { return silentLogger(); },
} as any;
}

/**
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
* — it is what makes this fixture a NON-approvals reproduction rather than a
* re-run of the approvals path under another name.
*/
const openPauser: NodeExecutor = {
type: 'pauser',
descriptor: defineActionDescriptor({
type: 'pauser', version: '1.0.0', name: 'pauser',
supportsPause: true, resumeAuthority: 'any',
}),
async execute() {
return { success: true, suspend: true, correlation: 'test:hold' };
},
};

/**
* Stands in for `mark_rejected`: a downstream write-back node whose target row
* was deleted while the run was parked. The message shape mirrors the report so
* the reproduction is legible next to it.
*/
const deletedRowWriter: NodeExecutor = {
type: 'write_back',
async execute() {
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
},
};

/** The control's downstream node: the same position, but it succeeds. */
const healthyWriter: NodeExecutor = {
type: 'write_back_ok',
async execute() { return { success: true }; },
};

const flowWith = (writerType: string) => ({
name: 'writeback_flow',
label: 'Write-back Flow',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'hold', type: 'pauser', label: 'Hold' },
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'hold' },
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
{ id: 'e3', source: 'mark_rejected', target: 'end' },
],
});

describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
engine.registerNodeExecutor(openPauser);
engine.registerNodeExecutor(deletedRowWriter);
engine.registerNodeExecutor(healthyWriter);
});

it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back'));

const paused = await engine.execute('writeback_flow');
expect(paused.status).toBe('paused');
const runId = paused.runId!;
expect(await engine.hasSuspendedRun(runId)).toBe(true);

// The resume that reproduces the report: the downstream node throws.
const failed = await engine.resume(runId);
expect(failed.success).toBe(false);
expect(failed.error).toContain('not found in crm_leave_request');

// 1. The suspension is GONE — consumed before the node ever ran.
expect(await engine.hasSuspendedRun(runId)).toBe(false);

// 2. The run is recorded terminal-failed, not paused.
expect((await engine.getRun(runId))?.status).toBe('failed');

// 3. Re-resuming is refused: there is no pause left to continue.
const retry = await engine.resume(runId);
expect(retry.success).toBe(false);
expect(retry.code).toBe('RUN_NOT_FOUND');

// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
// consume, so it cannot even tidy the run away.
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
});

/**
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
* if the same assertions can SEE a suspension that survived. A resume refused
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
* signal) is the engine's own example of that: the pause stays live and the
* legitimate continuation still lands.
*/
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
expect(refused.success).toBe(false);
expect(refused.code).toBe('INVALID_SIGNAL');

// The same probes that read `false` above read `true` here — so they are
// measuring the suspension, not returning a constant.
expect(await engine.hasSuspendedRun(runId)).toBe(true);

const ok = await engine.resume(runId);
expect(ok.success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
});

/**
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
* with no suspension — so "no suspension" alone does not identify the strand.
* What separates them is the terminal status, and that a completed run is a
* finished one rather than a run with work left that nothing can reach.
*/
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));

const paused = await engine.execute('writeback_flow');
const runId = paused.runId!;

expect((await engine.resume(runId)).success).toBe(true);
expect(await engine.hasSuspendedRun(runId)).toBe(false);
expect((await engine.getRun(runId))?.status).toBe('completed');
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
});
});
Loading