Skip to content

Commit bf5d764

Browse files
authored
Merge pull request #443 from alectimison-maker/feat/saved-workflows
feat: add guarded saved workflow replay
2 parents d3b12cf + 71919f3 commit bf5d764

45 files changed

Lines changed: 3839 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ WebBrain accepts slash commands as the first thing on a line in the input box. T
336336
| `/memory` | Show saved user memory |
337337
| `/memory --add <text>` | Save a user preference to memory |
338338
| `/memory --forget <id>` | Forget a saved memory by ID |
339+
| `/workflow` | List saved workflows and their IDs |
340+
| `/workflow --save <name>` | Compile the latest successful traced run into a reusable, value-free workflow |
341+
| `/workflow --run <id>` | Run a saved workflow in Act mode, collecting any runtime parameters locally |
342+
| `/workflow --delete <id>` | Delete a saved workflow |
339343
| `/allow-api` | **Per-conversation API mutation override.** Lifts the UI-first restriction so the agent may use POST/PUT/PATCH/DELETE via `fetch_url` when UI is failing. Badge appears while active; clears on `/reset`. |
340344
| `/dangerously-skip-permissions` | **Global permission-prompt bypass.** Turns off `Ask before consequential actions` without opening Settings. WebBrain will act without per-site prompts until you re-enable the setting. |
341345
| `/compact` | Force context compaction for the current conversation |
@@ -373,6 +377,19 @@ the originating run tab before saving the after screenshot. If the recording
373377
or initial screenshot cannot be started and saved, the run is not sent.
374378
Standalone `/record` and `/screenshot` keep their existing behavior.
375379

380+
Saved workflows use a separate `webbrain-workflow/1` schema; they are not raw
381+
trace replays. Historical `ref_id` values, action CSS selectors, coordinates,
382+
query strings, fragments, and typed field values are excluded. Typed values become runtime
383+
parameters, and each action is bound to the recorded origin and URL family.
384+
At run time WebBrain resolves a fresh accessibility-tree target and executes
385+
through the normal Act permission, submit-confirmation, and verification gates.
386+
Ambiguous targets fail closed. If an action may already have happened but its
387+
result is unknown, replay stops instead of retrying it. Runtime parameter values
388+
are not saved to the workflow, conversation, user memory, replay trace, or Agent
389+
fallback prompt; they are still delivered to the target page by the requested
390+
browser action. The original opt-in source trace remains separate and can
391+
contain raw tool arguments until the user deletes that trace.
392+
376393
The default UI-first rule exists because API actions are invisible (you don't see what's being sent), often require separate auth tokens you may not have configured, and can have a much larger blast radius than a visible mis-click. Only use `/allow-api` when you've decided you want that tradeoff for a specific job.
377394

378395
## Keyboard Shortcuts

docs/architecture.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,35 @@ job. A short queue drains best-effort through the active provider using the
432432
existing cost allowance guard; cost exhaustion skips extraction silently, and
433433
other failures retry once.
434434

435+
### Saved workflows (`agent/workflows.js`)
436+
437+
Saved workflows are compiled artifacts, not serialized trace events. The
438+
background reads the newest successful trace in the active conversation and
439+
normalizes its replayable actions into `webbrain-workflow/1`, stored under
440+
`wb_saved_workflows_v1`. Compilation removes historical element references,
441+
action CSS selectors, coordinates, query strings, fragments, and typed values. Every typed field
442+
value becomes a declared runtime parameter; unsupported or failed actions are
443+
skipped and reported to the user as save warnings.
444+
445+
Each compiled step contains semantic target metadata (role, accessible name,
446+
label, field identity, link, or placeholder), an expected postcondition, and
447+
the origin/path family observed before that action. `/workflow --run <id>`
448+
collects parameters in an ephemeral side-panel form. The replay executor then:
449+
450+
1. checks the current origin/path family before every step;
451+
2. reads a fresh accessibility tree and resolves exactly one semantic match;
452+
3. calls `_executeToolBatch()` so the existing permission, form-submit,
453+
verification, abort, and action-normalization gates remain authoritative;
454+
4. validates the saved postcondition; and
455+
5. either continues deterministically, delegates a known-safe mismatch to the
456+
normal Agent, or stops when a state-changing action has an unknown outcome.
457+
458+
Replay does not set `currentRunId`, because ordinary tool tracing would retain
459+
runtime values. It creates a separate run containing sanitized notes and
460+
redacted UI tool events. Runtime parameter values are also omitted from the
461+
fallback prompt and user-memory extraction. Chrome and Firefox ship identical
462+
workflow schema/compiler code and the same replay policy.
463+
435464
### Scheduled Tasks (`scheduler.js`)
436465

437466
The scheduler lets the agent defer work to a future browser session using the browser's `alarms` API. It lives in `src/chrome/src/agent/scheduler.js` (and the Firefox mirror) and is instantiated as `ScheduledJobManager` in the background script.

docs/privacy-and-data-flow.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,30 @@ When enabled (Settings → Display → "Record traces"), every agent run is writ
117117

118118
The Traces page (`ui/traces.html`) reads from local IndexedDB only. Export produces a JSON blob saved to the user's Downloads folder. **No trace data ever leaves the browser.**
119119

120+
### Saved Workflows
121+
122+
`/workflow --save <name>` locally compiles the latest successful trace into a
123+
separate `webbrain-workflow/1` record in browser local storage
124+
(`wb_saved_workflows_v1`). The saved record contains action names, sanitized
125+
arguments, semantic target descriptors, URL origin/path families,
126+
postconditions, and parameter descriptors. It does not contain typed field
127+
values, raw historical `ref_id` values, action CSS selectors, coordinates, URL query strings, or URL
128+
fragments.
129+
130+
`/workflow --run <id>` collects declared values in a temporary side-panel form
131+
and sends them directly to the background replay executor. The values are not
132+
written to the workflow, chat text, retry payload, user memory, replay trace,
133+
or Agent fallback prompt. They necessarily reach the active page when the
134+
requested field action runs. A source trace is a separate opt-in record and may
135+
still contain the original raw tool arguments; saving a workflow does not
136+
delete or redact that source trace.
137+
138+
Replay traces contain workflow/step IDs, semantic match status and score,
139+
postcondition status, fallback status, and estimated model calls saved. They do
140+
not contain runtime parameter values or freshly resolved element references.
141+
If deterministic replay cannot safely continue, a fallback Agent receives only
142+
saved metadata and must ask the user again for any still-needed value.
143+
120144
### Settings
121145

122146
Provider configs (API keys, base URLs, model selections) are stored in `chrome.storage.local`. API keys are in plaintext — this is a personal-computer tool and the storage is sandboxed by the browser. The extension has no mechanism to exfiltrate these keys.

docs/security-model.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ The traces page (`ui/traces.html`) reads from local IndexedDB only. Export produ
167167

168168
---
169169

170+
## Saved-Workflow Replay Boundary
171+
172+
Saved workflows deliberately do not replay raw trace calls. Compilation uses an
173+
allowlist, replaces every typed value with a runtime parameter, discards raw
174+
references, action CSS selectors, coordinates and URL query or fragment data, and binds each action to
175+
an origin/path family plus a semantic target and postcondition.
176+
177+
Before an action, replay must be on the recorded URL family and find one
178+
unambiguous target in a fresh accessibility tree. The action is dispatched
179+
through the same capability-by-origin permission gate, submit confirmation,
180+
form validation, and trusted-event path as a normal Act run. A known pre-action
181+
mismatch can be delegated to the Agent with no parameter values. A failed or
182+
unverified state-changing action whose dispatch cannot be disproved is treated
183+
as outcome-unknown and is never automatically retried. Replay telemetry and UI
184+
events redact runtime values and fresh `ref_id` values.
185+
186+
---
187+
170188
## Firefox Differences
171189

172190
Firefox has no CDP (`debugger` permission), so:

src/chrome/src/agent/agent.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ import { repairAssistantDisplayText, sanitizeText as sanitizePlannerText } from
4646
import { buildCustomSkillsPrompt, buildSkillLoaderDefinition, buildSkillToolDefinitions, buildSkillToolRegistry, getEligibleCustomSkills, getEligibleSkillCatalog, normalizeCustomSkills } from './skills.js';
4747
import { publicMediaUrlNeedsExplicitTarget } from './public-media-url.js';
4848
import { USER_MEMORY_DEFAULT_MAX_PROMPT_CHARS, formatUserMemoryPrompt, normalizeUserMemoryMaxPromptChars, normalizeUserMemoryStore } from './user-memory.js';
49+
import {
50+
findWorkflowTarget,
51+
parseAccessibilityTreeDescriptors,
52+
redactWorkflowArgsForTelemetry,
53+
redactWorkflowClarifyForTelemetry,
54+
redactWorkflowResultForTelemetry,
55+
resolveWorkflowArgs,
56+
validateWorkflowStepResult,
57+
workflowFallbackPrompt,
58+
workflowUrlMatches,
59+
} from './workflows.js';
4960
import { mergeRedactionFrameRegions, mapRegionsToImage, pixelateDataUrl } from './screenshot-redaction.js';
5061
import { buildTrustedRuntimeContext, stripTrustedRuntimeContext } from './runtime-context.js';
5162
import { resolveSavedDownload } from '../download-result.js';
@@ -12381,6 +12392,212 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1238112392
};
1238212393
}
1238312394

12395+
async replaySavedWorkflow(tabId, workflow, parameters = {}, onUpdate = () => {}, runOptions = {}) {
12396+
if (this._runningTabs.has(tabId)) throw new Error('An agent run is already in progress for this tab.');
12397+
if (!workflow?.id || !Array.isArray(workflow.steps) || !workflow.steps.length) {
12398+
throw new Error('Saved workflow is missing or invalid.');
12399+
}
12400+
await this._hydrate(tabId);
12401+
// Align pre-run cleanup with processMessage so a prior Act turn cannot
12402+
// leak click-AX CDP fallbacks, plan guards, or active-skill state into
12403+
// deterministic replay (or the reverse on the next turn).
12404+
this._resetActiveSkillsForRun(tabId, { refreshPrompt: false });
12405+
this._clearRunLoopState(tabId);
12406+
this._clickAxCdpFallbacks?.delete(tabId);
12407+
this.abortFlags.delete(tabId);
12408+
this._prepareClarificationAuthorizationForRun(tabId);
12409+
this.permissions.beginTurn(tabId);
12410+
this.conversationModes.set(tabId, 'act');
12411+
const completionRunToken = this._beginCompletionInvariant(tabId);
12412+
this._runningTabs.add(tabId);
12413+
const startUrl = await this._currentUrl(tabId);
12414+
const conversationId = await this.ensureConversationId(tabId, 'act');
12415+
const traceRunId = await trace.startRun({
12416+
conversationId,
12417+
userMessage: `Run saved workflow: ${workflow.name}`,
12418+
tabUrl: startUrl,
12419+
mode: 'act',
12420+
model: this.providerManager?.getActive?.()?.model || '',
12421+
providerId: this.providerManager?.activeProviderId || '',
12422+
});
12423+
let traceStatus = 'workflow_stopped';
12424+
let finalContent = '';
12425+
let matchedSteps = 0;
12426+
12427+
const finishStopped = (reason, stepIndex = 0) => {
12428+
const summary = `Saved workflow "${workflow.name}" stopped safely at step ${stepIndex + 1}: ${reason}.`;
12429+
onUpdate('tool_result', {
12430+
name: 'done',
12431+
result: { success: false, done: true, outcome: 'failed', summary, workflowReplay: true },
12432+
});
12433+
traceStatus = 'workflow_stopped';
12434+
finalContent = summary;
12435+
return { status: 'stopped', summary, reason, stepIndex, matchedSteps };
12436+
};
12437+
12438+
try {
12439+
if (!workflowUrlMatches(workflow.start, startUrl)) {
12440+
trace.recordNote(traceRunId, 0, 'workflow_replay_start_miss', {
12441+
workflowId: workflow.id,
12442+
expectedOrigin: workflow.start?.origin || '',
12443+
});
12444+
return finishStopped('the current page is outside the saved origin or URL family', 0);
12445+
}
12446+
12447+
for (let index = 0; index < workflow.steps.length; index++) {
12448+
if (this._checkAbort(tabId)) return finishStopped('stopped by the user', index);
12449+
const step = workflow.steps[index];
12450+
const stepUrl = await this._currentUrl(tabId);
12451+
if (step.scope && !workflowUrlMatches(step.scope, stepUrl)) {
12452+
const reason = 'page scope mismatch';
12453+
trace.recordNote(traceRunId, index + 1, 'workflow_replay_scope_miss', {
12454+
workflowId: workflow.id,
12455+
stepId: step.id,
12456+
tool: step.tool,
12457+
expectedOrigin: step.scope.origin,
12458+
expectedPathFamily: step.scope.pathFamily,
12459+
});
12460+
traceStatus = 'workflow_fallback';
12461+
finalContent = `Deterministic replay paused at step ${index + 1}; continuing with the agent.`;
12462+
return {
12463+
status: 'fallback',
12464+
reason,
12465+
stepIndex: index,
12466+
matchedSteps,
12467+
prompt: workflowFallbackPrompt(workflow, index, reason),
12468+
};
12469+
}
12470+
let executionArgs;
12471+
try {
12472+
executionArgs = resolveWorkflowArgs(step.args, parameters);
12473+
} catch (error) {
12474+
return finishStopped(error?.message || 'a required runtime parameter is missing', index);
12475+
}
12476+
12477+
let targetMatch = null;
12478+
if (step.target) {
12479+
const treeResult = await this.executeTool(tabId, 'get_accessibility_tree', {
12480+
filter: 'all',
12481+
maxChars: 60000,
12482+
});
12483+
const treeText = typeof treeResult === 'string'
12484+
? treeResult
12485+
: treeResult?.pageContent || treeResult?.tree || treeResult?.content || '';
12486+
const candidates = parseAccessibilityTreeDescriptors(treeText);
12487+
targetMatch = findWorkflowTarget(step.target, candidates);
12488+
if (targetMatch.status !== 'matched') {
12489+
trace.recordNote(traceRunId, index + 1, 'workflow_replay_target_miss', {
12490+
workflowId: workflow.id,
12491+
stepId: step.id,
12492+
tool: step.tool,
12493+
match: targetMatch.status,
12494+
});
12495+
const reason = `semantic target ${targetMatch.status}`;
12496+
traceStatus = 'workflow_fallback';
12497+
finalContent = `Deterministic replay paused at step ${index + 1}; continuing with the agent.`;
12498+
return {
12499+
status: 'fallback',
12500+
reason,
12501+
stepIndex: index,
12502+
matchedSteps,
12503+
prompt: workflowFallbackPrompt(workflow, index, reason),
12504+
};
12505+
}
12506+
if (['click_ax', 'set_checked', 'type_ax', 'set_field', 'scroll'].includes(step.tool)) {
12507+
executionArgs.ref_id = targetMatch.candidate.refId;
12508+
}
12509+
}
12510+
12511+
const toolCall = {
12512+
id: `workflow_${workflow.id}_${step.id}_${Date.now()}`,
12513+
type: 'function',
12514+
function: { name: step.tool, arguments: JSON.stringify(executionArgs) },
12515+
};
12516+
const messages = [{ role: 'assistant', content: null, tool_calls: [toolCall] }];
12517+
let rawResult = null;
12518+
const replayUpdate = (type, data = {}) => {
12519+
if (type === 'tool_result' && data?.name === step.tool) rawResult = data.result;
12520+
if (type === 'tool_call' && data?.name === step.tool) {
12521+
onUpdate(type, { ...data, args: redactWorkflowArgsForTelemetry(step.args, executionArgs), workflowReplay: true });
12522+
return;
12523+
}
12524+
if (type === 'tool_result' && data?.name === step.tool) {
12525+
onUpdate(type, { ...data, result: redactWorkflowResultForTelemetry(step.tool, data.result), workflowReplay: true });
12526+
return;
12527+
}
12528+
if (type === 'clarify') {
12529+
onUpdate(type, redactWorkflowClarifyForTelemetry(data));
12530+
return;
12531+
}
12532+
onUpdate(type, { ...(data && typeof data === 'object' ? data : {}), workflowReplay: true });
12533+
};
12534+
const beforeUrl = stepUrl;
12535+
const batch = await this._executeToolBatch(
12536+
tabId,
12537+
[toolCall],
12538+
messages,
12539+
replayUpdate,
12540+
this.providerManager?.getActive?.(),
12541+
null,
12542+
new Set([step.tool]),
12543+
index + 1,
12544+
runOptions,
12545+
);
12546+
const afterUrl = await this._currentUrl(tabId);
12547+
const validation = validateWorkflowStepResult(step.expected, rawResult, { beforeUrl, afterUrl, tool: step.tool });
12548+
trace.recordNote(traceRunId, index + 1, 'workflow_replay_step', {
12549+
workflowId: workflow.id,
12550+
stepId: step.id,
12551+
tool: step.tool,
12552+
targetMatch: targetMatch?.status || 'not_applicable',
12553+
targetScore: targetMatch?.score || 0,
12554+
validation: validation.ok ? 'passed' : validation.reason,
12555+
});
12556+
12557+
if (batch?.action === 'abort') return finishStopped('stopped by the user', index);
12558+
if (!validation.ok) {
12559+
if (validation.outcomeUnknown || rawResult?.denied || rawResult?.cancelled) {
12560+
return finishStopped(validation.reason, index);
12561+
}
12562+
traceStatus = 'workflow_fallback';
12563+
finalContent = `Deterministic replay paused at step ${index + 1}; continuing with the agent.`;
12564+
return {
12565+
status: 'fallback',
12566+
reason: validation.reason,
12567+
stepIndex: index,
12568+
matchedSteps,
12569+
prompt: workflowFallbackPrompt(workflow, index, validation.reason),
12570+
};
12571+
}
12572+
matchedSteps += 1;
12573+
}
12574+
12575+
const summary = `Saved workflow "${workflow.name}" completed ${matchedSteps} step${matchedSteps === 1 ? '' : 's'} with deterministic replay.`;
12576+
trace.recordNote(traceRunId, workflow.steps.length + 1, 'workflow_replay_complete', {
12577+
workflowId: workflow.id,
12578+
matchedSteps,
12579+
modelFallbacks: 0,
12580+
estimatedLlmCallsSaved: matchedSteps,
12581+
});
12582+
onUpdate('tool_result', {
12583+
name: 'done',
12584+
result: { success: true, done: true, outcome: 'success', summary, workflowReplay: true },
12585+
});
12586+
traceStatus = 'done';
12587+
finalContent = summary;
12588+
return { status: 'completed', summary, matchedSteps, estimatedLlmCallsSaved: matchedSteps };
12589+
} finally {
12590+
await trace.endRun(traceRunId, { status: traceStatus, finalContent });
12591+
this.currentCostState.delete(tabId);
12592+
this._planExecutionGuards.delete(tabId);
12593+
this._resetActiveSkillsForRun(tabId);
12594+
this._runningTabs.delete(tabId);
12595+
this._clearRunLoopState(tabId);
12596+
this._clickAxCdpFallbacks?.delete(tabId);
12597+
this._clearCompletionInvariant(tabId, completionRunToken);
12598+
}
12599+
}
12600+
1238412601
async executeTool(tabId, name, args, onUpdate = null, executionContext = null) {
1238512602
if (name === 'load_skill') {
1238612603
return this._loadSkillForRun(tabId, args || {});

0 commit comments

Comments
 (0)