Skip to content

Commit 607d914

Browse files
authored
Merge pull request #451 from webbrain-one/dev
Enhance saved workflows with safe replay and management features
2 parents 24b1a17 + bf5d764 commit 607d914

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
@@ -470,6 +470,35 @@ job. A short queue drains best-effort through the active provider using the
470470
existing cost allowance guard; cost exhaustion skips extraction silently, and
471471
other failures retry once.
472472

473+
### Saved workflows (`agent/workflows.js`)
474+
475+
Saved workflows are compiled artifacts, not serialized trace events. The
476+
background reads the newest successful trace in the active conversation and
477+
normalizes its replayable actions into `webbrain-workflow/1`, stored under
478+
`wb_saved_workflows_v1`. Compilation removes historical element references,
479+
action CSS selectors, coordinates, query strings, fragments, and typed values. Every typed field
480+
value becomes a declared runtime parameter; unsupported or failed actions are
481+
skipped and reported to the user as save warnings.
482+
483+
Each compiled step contains semantic target metadata (role, accessible name,
484+
label, field identity, link, or placeholder), an expected postcondition, and
485+
the origin/path family observed before that action. `/workflow --run <id>`
486+
collects parameters in an ephemeral side-panel form. The replay executor then:
487+
488+
1. checks the current origin/path family before every step;
489+
2. reads a fresh accessibility tree and resolves exactly one semantic match;
490+
3. calls `_executeToolBatch()` so the existing permission, form-submit,
491+
verification, abort, and action-normalization gates remain authoritative;
492+
4. validates the saved postcondition; and
493+
5. either continues deterministically, delegates a known-safe mismatch to the
494+
normal Agent, or stops when a state-changing action has an unknown outcome.
495+
496+
Replay does not set `currentRunId`, because ordinary tool tracing would retain
497+
runtime values. It creates a separate run containing sanitized notes and
498+
redacted UI tool events. Runtime parameter values are also omitted from the
499+
fallback prompt and user-memory extraction. Chrome and Firefox ship identical
500+
workflow schema/compiler code and the same replay policy.
501+
473502
### Scheduled Tasks (`scheduler.js`)
474503

475504
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';
@@ -12602,6 +12613,212 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1260212613
};
1260312614
}
1260412615

12616+
async replaySavedWorkflow(tabId, workflow, parameters = {}, onUpdate = () => {}, runOptions = {}) {
12617+
if (this._runningTabs.has(tabId)) throw new Error('An agent run is already in progress for this tab.');
12618+
if (!workflow?.id || !Array.isArray(workflow.steps) || !workflow.steps.length) {
12619+
throw new Error('Saved workflow is missing or invalid.');
12620+
}
12621+
await this._hydrate(tabId);
12622+
// Align pre-run cleanup with processMessage so a prior Act turn cannot
12623+
// leak click-AX CDP fallbacks, plan guards, or active-skill state into
12624+
// deterministic replay (or the reverse on the next turn).
12625+
this._resetActiveSkillsForRun(tabId, { refreshPrompt: false });
12626+
this._clearRunLoopState(tabId);
12627+
this._clickAxCdpFallbacks?.delete(tabId);
12628+
this.abortFlags.delete(tabId);
12629+
this._prepareClarificationAuthorizationForRun(tabId);
12630+
this.permissions.beginTurn(tabId);
12631+
this.conversationModes.set(tabId, 'act');
12632+
const completionRunToken = this._beginCompletionInvariant(tabId);
12633+
this._runningTabs.add(tabId);
12634+
const startUrl = await this._currentUrl(tabId);
12635+
const conversationId = await this.ensureConversationId(tabId, 'act');
12636+
const traceRunId = await trace.startRun({
12637+
conversationId,
12638+
userMessage: `Run saved workflow: ${workflow.name}`,
12639+
tabUrl: startUrl,
12640+
mode: 'act',
12641+
model: this.providerManager?.getActive?.()?.model || '',
12642+
providerId: this.providerManager?.activeProviderId || '',
12643+
});
12644+
let traceStatus = 'workflow_stopped';
12645+
let finalContent = '';
12646+
let matchedSteps = 0;
12647+
12648+
const finishStopped = (reason, stepIndex = 0) => {
12649+
const summary = `Saved workflow "${workflow.name}" stopped safely at step ${stepIndex + 1}: ${reason}.`;
12650+
onUpdate('tool_result', {
12651+
name: 'done',
12652+
result: { success: false, done: true, outcome: 'failed', summary, workflowReplay: true },
12653+
});
12654+
traceStatus = 'workflow_stopped';
12655+
finalContent = summary;
12656+
return { status: 'stopped', summary, reason, stepIndex, matchedSteps };
12657+
};
12658+
12659+
try {
12660+
if (!workflowUrlMatches(workflow.start, startUrl)) {
12661+
trace.recordNote(traceRunId, 0, 'workflow_replay_start_miss', {
12662+
workflowId: workflow.id,
12663+
expectedOrigin: workflow.start?.origin || '',
12664+
});
12665+
return finishStopped('the current page is outside the saved origin or URL family', 0);
12666+
}
12667+
12668+
for (let index = 0; index < workflow.steps.length; index++) {
12669+
if (this._checkAbort(tabId)) return finishStopped('stopped by the user', index);
12670+
const step = workflow.steps[index];
12671+
const stepUrl = await this._currentUrl(tabId);
12672+
if (step.scope && !workflowUrlMatches(step.scope, stepUrl)) {
12673+
const reason = 'page scope mismatch';
12674+
trace.recordNote(traceRunId, index + 1, 'workflow_replay_scope_miss', {
12675+
workflowId: workflow.id,
12676+
stepId: step.id,
12677+
tool: step.tool,
12678+
expectedOrigin: step.scope.origin,
12679+
expectedPathFamily: step.scope.pathFamily,
12680+
});
12681+
traceStatus = 'workflow_fallback';
12682+
finalContent = `Deterministic replay paused at step ${index + 1}; continuing with the agent.`;
12683+
return {
12684+
status: 'fallback',
12685+
reason,
12686+
stepIndex: index,
12687+
matchedSteps,
12688+
prompt: workflowFallbackPrompt(workflow, index, reason),
12689+
};
12690+
}
12691+
let executionArgs;
12692+
try {
12693+
executionArgs = resolveWorkflowArgs(step.args, parameters);
12694+
} catch (error) {
12695+
return finishStopped(error?.message || 'a required runtime parameter is missing', index);
12696+
}
12697+
12698+
let targetMatch = null;
12699+
if (step.target) {
12700+
const treeResult = await this.executeTool(tabId, 'get_accessibility_tree', {
12701+
filter: 'all',
12702+
maxChars: 60000,
12703+
});
12704+
const treeText = typeof treeResult === 'string'
12705+
? treeResult
12706+
: treeResult?.pageContent || treeResult?.tree || treeResult?.content || '';
12707+
const candidates = parseAccessibilityTreeDescriptors(treeText);
12708+
targetMatch = findWorkflowTarget(step.target, candidates);
12709+
if (targetMatch.status !== 'matched') {
12710+
trace.recordNote(traceRunId, index + 1, 'workflow_replay_target_miss', {
12711+
workflowId: workflow.id,
12712+
stepId: step.id,
12713+
tool: step.tool,
12714+
match: targetMatch.status,
12715+
});
12716+
const reason = `semantic target ${targetMatch.status}`;
12717+
traceStatus = 'workflow_fallback';
12718+
finalContent = `Deterministic replay paused at step ${index + 1}; continuing with the agent.`;
12719+
return {
12720+
status: 'fallback',
12721+
reason,
12722+
stepIndex: index,
12723+
matchedSteps,
12724+
prompt: workflowFallbackPrompt(workflow, index, reason),
12725+
};
12726+
}
12727+
if (['click_ax', 'set_checked', 'type_ax', 'set_field', 'scroll'].includes(step.tool)) {
12728+
executionArgs.ref_id = targetMatch.candidate.refId;
12729+
}
12730+
}
12731+
12732+
const toolCall = {
12733+
id: `workflow_${workflow.id}_${step.id}_${Date.now()}`,
12734+
type: 'function',
12735+
function: { name: step.tool, arguments: JSON.stringify(executionArgs) },
12736+
};
12737+
const messages = [{ role: 'assistant', content: null, tool_calls: [toolCall] }];
12738+
let rawResult = null;
12739+
const replayUpdate = (type, data = {}) => {
12740+
if (type === 'tool_result' && data?.name === step.tool) rawResult = data.result;
12741+
if (type === 'tool_call' && data?.name === step.tool) {
12742+
onUpdate(type, { ...data, args: redactWorkflowArgsForTelemetry(step.args, executionArgs), workflowReplay: true });
12743+
return;
12744+
}
12745+
if (type === 'tool_result' && data?.name === step.tool) {
12746+
onUpdate(type, { ...data, result: redactWorkflowResultForTelemetry(step.tool, data.result), workflowReplay: true });
12747+
return;
12748+
}
12749+
if (type === 'clarify') {
12750+
onUpdate(type, redactWorkflowClarifyForTelemetry(data));
12751+
return;
12752+
}
12753+
onUpdate(type, { ...(data && typeof data === 'object' ? data : {}), workflowReplay: true });
12754+
};
12755+
const beforeUrl = stepUrl;
12756+
const batch = await this._executeToolBatch(
12757+
tabId,
12758+
[toolCall],
12759+
messages,
12760+
replayUpdate,
12761+
this.providerManager?.getActive?.(),
12762+
null,
12763+
new Set([step.tool]),
12764+
index + 1,
12765+
runOptions,
12766+
);
12767+
const afterUrl = await this._currentUrl(tabId);
12768+
const validation = validateWorkflowStepResult(step.expected, rawResult, { beforeUrl, afterUrl, tool: step.tool });
12769+
trace.recordNote(traceRunId, index + 1, 'workflow_replay_step', {
12770+
workflowId: workflow.id,
12771+
stepId: step.id,
12772+
tool: step.tool,
12773+
targetMatch: targetMatch?.status || 'not_applicable',
12774+
targetScore: targetMatch?.score || 0,
12775+
validation: validation.ok ? 'passed' : validation.reason,
12776+
});
12777+
12778+
if (batch?.action === 'abort') return finishStopped('stopped by the user', index);
12779+
if (!validation.ok) {
12780+
if (validation.outcomeUnknown || rawResult?.denied || rawResult?.cancelled) {
12781+
return finishStopped(validation.reason, index);
12782+
}
12783+
traceStatus = 'workflow_fallback';
12784+
finalContent = `Deterministic replay paused at step ${index + 1}; continuing with the agent.`;
12785+
return {
12786+
status: 'fallback',
12787+
reason: validation.reason,
12788+
stepIndex: index,
12789+
matchedSteps,
12790+
prompt: workflowFallbackPrompt(workflow, index, validation.reason),
12791+
};
12792+
}
12793+
matchedSteps += 1;
12794+
}
12795+
12796+
const summary = `Saved workflow "${workflow.name}" completed ${matchedSteps} step${matchedSteps === 1 ? '' : 's'} with deterministic replay.`;
12797+
trace.recordNote(traceRunId, workflow.steps.length + 1, 'workflow_replay_complete', {
12798+
workflowId: workflow.id,
12799+
matchedSteps,
12800+
modelFallbacks: 0,
12801+
estimatedLlmCallsSaved: matchedSteps,
12802+
});
12803+
onUpdate('tool_result', {
12804+
name: 'done',
12805+
result: { success: true, done: true, outcome: 'success', summary, workflowReplay: true },
12806+
});
12807+
traceStatus = 'done';
12808+
finalContent = summary;
12809+
return { status: 'completed', summary, matchedSteps, estimatedLlmCallsSaved: matchedSteps };
12810+
} finally {
12811+
await trace.endRun(traceRunId, { status: traceStatus, finalContent });
12812+
this.currentCostState.delete(tabId);
12813+
this._planExecutionGuards.delete(tabId);
12814+
this._resetActiveSkillsForRun(tabId);
12815+
this._runningTabs.delete(tabId);
12816+
this._clearRunLoopState(tabId);
12817+
this._clickAxCdpFallbacks?.delete(tabId);
12818+
this._clearCompletionInvariant(tabId, completionRunToken);
12819+
}
12820+
}
12821+
1260512822
async executeTool(tabId, name, args, onUpdate = null, executionContext = null) {
1260612823
if (name === 'load_skill') {
1260712824
return this._loadSkillForRun(tabId, args || {});

0 commit comments

Comments
 (0)