Skip to content

specs: Add spec 083 and the samples store - #1

Open
SCcagg5 wants to merge 31 commits into
stack/00-basefrom
stack/01-g1
Open

SCcagg5 wants to merge 31 commits into
stack/00-basefrom
stack/01-g1

Conversation

@SCcagg5

@SCcagg5 SCcagg5 commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Part of a stack of 31 pull requests that depend on each other. They must be reviewed and merged in order: each PR builds on the previous one and its diff only contains its own changes.

All PRs of the stack
# PR
1 specs: Add spec 083 and the samples store
2 ir: Add run state fields for ephemeral runs and reused outputs
3 runtime: Bound continuous steps and carry outputs of selected steps
4 api: Add step samples, events, availability and selected-step runs
5 test(ui): add YAML fixtures for the document model tests
6 ui(lib): add shared UI kit, status helpers and right-panel shell
7 ui(canvas): add canvas engine core, toolbars and editor helpers
8 ui(i18n): add workspace message catalogs, first batch
9 ui(canvas): add canvas Flow view, step run panel and workflow panel
10 ui(editor): add YAML document model, canvas model and run source logic
11 ui(editor): Add edit session, YAML format and quick fixes, run actions, tab strip
12 ui(editor): Add canvas graph model, Add step panel, issues popover, run bar
13 ui(canvas): Add layered layout, dependency edges, status styles, reference picker
14 ui(canvas): Add export, flow builder, upstream data panel, output capture
15 ui(canvas): Add node cards, launchers, inspector sections, client issues
16 ui(editor): Add visual editor, step inspector and YAML file panel
17 ui(lists): Open runs on the workflow page; add tab and schedule helpers
18 ui(workspace): Add workspace URL state, legacy redirects and run list links
19 ui(workspace): Add workspace data layer, run facts and Workflow panel
20 ui(workspace): Add run start controls, visible runs and reuse source
21 ui(workspace): Add data provider, bar identity, continuous panel and step list
22 ui(logs): Add run actions cluster, cached logs and log popout page
23 ui(workspace): Add Activity dock, step run view and workflow page
24 test(workspace): Add integration tests for the workspace shell and step view
25 ui(pages): Retire classic run pages and open runs on the workflow page
26 test(editor): Add integration tests for the editor, inspector and run overlay
27 test(ui): Add repository-wide guard tests and ESLint boundary rules
28 e2e: Add workspace helpers and fixtures, update existing specs
29 e2e: Add workspace specs, part 1 of 2
30 e2e: Add workspace specs, part 2 of 2
31 examples: Add the workspace demo workflows

Final render

Screenshots of the result once the whole stack is merged (demo data). PRs 5 to 27 build this UI step by step; the backend PRs 1 to 4 and the e2e PRs 28 to 30 support it.

Workflow page: graph of a finished run, run bar, Activity tab
A run of one step that reuses the outputs of an earlier run (dashed green outlines)
Activity panel: timeline of runs on a real time axis
Activity panel: logs with the cached lines of reused steps
A run in progress with a repeating step
Full-screen step view
Whole-file YAML panel with an Open step lens above each step
Continuous run: a step that repeats every second for years
Dark theme

Stack 1/31. Base branch: main. Review and merge in order; this PR builds on the previous one.
Size: 15 files changed, 3112 insertions(+).
Checked: Go build, vet and tests of the changed packages, on top of the previous PR.

Summary

This PR adds specs/083-continuous-runs.md and a new package, internal/samples. The package stores the executions of a step that repeats without a limit as samples. It rolls them up into minute, hour and day tiers and answers queries for samples, events and availability.

Why

A step that repeats every second for years cannot write a full status record each time. It needs a bounded store and a defined contract. This PR is the base of the stack: PR 3 writes to the store through the runner, and PR 4 exposes it through the REST API.

Changes

  • Spec
    • specs/083-continuous-runs.md: defines continuous steps, samples, tiers, retention, events, bounds and the API queries. specs/README.md lists it.
  • Contract (internal/samples/contract.go)
    • Sample, Tier, Bucket, Series, Event, Availability, and the Recorder and Reader interfaces.
    • Dir(attemptDir, stepName) gives the store directory of one step.
  • Files and format (files.go, doc.go)
    • Append-only text files: raw/ by hour, minute/ by day, hour/ by month, day/ by year, and events.jsonl.
    • A raw line is a compact array of offset, duration and exit code.
  • Aggregates (agg.go)
    • agg holds count, failures, min, max, sum and a log histogram with 16 bins per octave. Two aggregates add bin by bin, so percentiles stay within about 2.2%.
  • Write path (recorder.go, rollup.go, events.go)
    • OpenRecorder repairs the files, then accepts samples. Record, Flush and Close close each minute, hour and day. Roll-ups are idempotent.
    • prune removes a file when its tier retention has passed. The tracker turns outages into events.
  • Read path (reader.go)
    • OpenReader gives Samples, Events (cursor paging) and Availability (windows and streak). It builds the open bucket from the finer tier.
  • backfill.go: Backfill writes the old tiers in bulk, used for synthetic data.

How to review

  1. Read specs/083-continuous-runs.md, then the package comment in internal/samples/doc.go.
  2. Read contract.go and agg.go.
  3. Read recorder.go and rollup.go, then reader.go.
  4. Skim the tests.

Pay most attention to these places:

  • Crash repair and idempotent roll-up (repair, putBuilt): a crash must lose at most one minute and never double-count.
  • Percentile accuracy and bucket boundaries in agg.go and Reader.Samples (the open bucket).

Tests

  • internal/samples/store_test.go: percentile accuracy, merge equals whole, idempotent roll-up, retention, crash repair, events, outage across restart, empty store, bounds.
  • internal/samples/reader_test.go: tiered samples, availability, old failure streak.
  • internal/samples/helpers_test.go: shared helpers.

Screenshots

No visible UI change in this PR.
The Continuous tab that shows this data appears in a later UI PR of the stack.

This was referenced Oct 28, 2026
An OpenRouter account that allows only zero-data-retention endpoints
cannot reach a model whose one such endpoint carries a regional
surcharge, since OpenRouter passes over that endpoint unless the
request names it. Dagu had no way to send OpenRouter's provider
routing object.

An llm block and each model entry now take provider_order, the
upstream providers OpenRouter tries first. The OpenRouter provider
sends it as provider.order on every request, so chat, agent, browser,
computer, and xlsx steps and model fallbacks all carry it. A model
entry's own order replaces the shared one. allow_fallbacks keeps
OpenRouter's default, as the account's data policy still filters
every fallback endpoint.

Only OpenRouter routes among upstream providers, so validation
refuses provider_order for any other provider, and a provider named
by a value reference is refused once it resolves.
Choosing in an AppKit combo box failed when its list was already open,
as a click on the box's own button leaves it. Expanding the box ran
AXShowMenu, which opens the text field's context menu, not the list.
With the list closed, Escape dismissed that menu and typing still set
the box. With the list open, the context menu covered it, and after
Escape the box kept the focus but never took the typed option.

Expanding now opens a combo box through AXExpanded, or its button when
that cannot be set, and leaves an element that is already open as it
is. An AppKit pop-up button closes its menu when pressed again, and
Chromium's open list box offers no press, so a choice made in either
now comes from the open list. UI Automation's Expand skips an element
that reports itself expanded too.

A scheduler test that read the restart time before the restart function
had sent it now waits for the time itself.
A recording made on a row whose field already showed that row's value
never entered it, and every row shares the recording, since it is keyed
by the instruction and not by the values. A loop's later rows replayed
it and left the field as the first row had it.

A recording now keeps a salted digest of the value of each variable its
instruction names that its actions never type, choose, or find an
element by. A run where such a value differs, or a recording that keeps
no digest, is not replayed: the model works the act and records afresh,
and ai: never fails with the reason. Browser acts follow the same rule.
Their cache entries become objects, and a list an older engine kept
still reads. A row-text match on an element counts as using the
variable. Sessions keep the digests through an export. computer cache
show names the variables a pin would leave alone, or says a recording
is too old to tell.

A conformance poll that failed on one transient run read now reads
again; the read race is tracked in dagucloud#3425.
OpenRouter sends 200 as soon as the upstream provider accepts a
request, so a provider failure during generation arrives as an error
object in the body with no choices. Chat dropped that object and
returned a non-retryable "no choices in response" with status 0, so a
single transient upstream failure failed the whole step.

The error object, at the top level or on the first choice, now becomes
an APIError with its code and message. Any server error or rate limit
is retried; other codes are not, and a context-length error, found by
its message or by OpenRouter's typed error_type, stays one. An error
chunk mid-stream ends the stream with the same error. An answer with no
choices and no error is retried as transient.
A session command that refused its input after reading the page, such
as an element operation on an element with no name, returned without
parking the browser. The session's record stayed running under the
command's process, which then exited, so the session's watchdog took it
for a session whose command died and closed its browser. The next
command found the session ended, and what the page held, such as a
search's conditions, was lost.

A command that attached to the browser and neither parked nor retired
it now leaves the session waiting for the next command when it ends.
The write gate answered a blocked page load with a "Not sent" page,
which replaced the document the form was filled in on, in the main
frame and in frames alike. A screenshot meant to show the person what
waits for approval showed only that page, and the operation run again
with --allow-write found no form to submit, so the approved write
could not be sent.

A blocked load a page started, by a form or a link, now fails with
Aborted. The browser cancels it without committing an error page, so
the frame keeps its document; Chrome does this for a form posted in
the main frame, into a frameset frame, and inside an iframe. A load
counts as the page's when it is not a GET, names a Referer, or was
sent under the no-referrer policy. A GET the browser loads on its
own, as a goto does, still gets the "Not sent" page: the browser
runtime's goto waits for a page that a cancelled load never brings.

A rehearsing session's result now names a write it blocked in a
warning, since the page no longer shows it, and the do help says a
held form is sent with the values it holds when its write is allowed.
A fixed text or selector check, in expect or when, read only the top
document. A site built of frames keeps everything a person reads in its
frames, and its top document is a frameset, whose body has no text, so
expect {text} and when {selector} never held there: an expect waited out
its whole window and failed with "the page text does not contain", and
a sign-in guarded by a selector was always skipped.

Both checks now read the page's document and the documents of the frames
it shows, nested ones too. A frame hidden by its size, display, or
visibility is left out, a body that is not rendered gives no text, and
a frame from another site cannot be read.
The write gate reports the start of the answer to each write it sends,
which the browser hands over as bytes. Those bytes were read as UTF-8
whatever the answer's charset, so a site that answers in Shift_JIS or
EUC-JP, as many older Japanese sites do, showed only replacement
characters and stray letters where its text was.

The bytes are now decoded from the charset the answer's content type
names. Secrets are hidden in the bytes as sent and again in the decoded
text, so one sent in another charset than the one named stays hidden.
An answer with no charset, or one not known, reads as before.
A form posted into a frame, as sites built of frames search and open a
record, keeps the frame's old document until the answer has loaded.
Waiting for what an action started watched only the top document's
requests and changes, so it saw nothing of that load and ended at once.
The next operation read the frame as it was before the action, and a
session command showed the page before the search and reported the
write it sent only with the next command.

The browser now reports which frames of its pages are loading a
document, cross-origin frames under them included, and the wait goes on
while any frame of any of its pages loads. That also covers a press that
opens a window in front, as a "searching" notice does, and the wait
reads the page in front each time, since such a window closes once the
answer loads. A navigation can begin a moment after the action that
starts it returns, so the page is now watched for the quiet period after
every action; before, the period could run out while the model was still
choosing the element.
A goto to an address answered with a download or with no content
waited out the whole operation timeout and then failed with "Navigation
start timed out", though the download was saved. The browser runtime's
goto ignores the error Page.navigate reports and waits for a navigation
event that a load without a document never sends, and it took the
address for the page's while the page stayed where it was.

Goto now loads the address over a DevTools connection of its own to the
page. It returns once the new document is parsed, the browser's error
page included; at once when the site answered without a document, a
download first seen to begin so the goto reports it; and it fails when
the browser cancelled the load before any answer, as when another load
replaced it or the write gate held it.

With goto no longer waiting on a cancelled load, the write gate cancels
every page load it holds in place, whoever started it, instead of
answering some with a "Not sent" page.

Fixes dagucloud#3433
On a site built of framesets, wait {selector} never held: the runtime's
waitForSelector looks for a CSS selector in the top document only, and
the element was on screen in a frame. An upload without into likewise
counted file inputs in the top document only, so the only file input of
a frameset page was reported missing.

A CSS selector wait now polls the frame-aware selector check until it
matches or the timeout ends, with the same error as before, and its
reads end with the wait's own timeout. A selector that hops into frames
with >>, or an XPath, still goes to the runtime as written. The selector
check also matches inside open shadow roots, which the runtime's wait
pierced, so wait and expect/when {selector} agree. An upload without
into finds the only file input of the page and the frames it shows. A
frame from another origin cannot be read and is left out, as the step
reference now says.
Forms on older sites are laid out in tables that label a field with the
cell before it, not with a label element. Such a control has no
accessible name, so the session outline showed a bare select or
textbox, and an element operation refused it as having no name a step
could find it by again; every field of such a form needed a model call.

A control the tree leaves unnamed now takes the label cell of its table
row: the nearest cell before it that has text and holds no control or
button, in data and layout tables alike, trying the enclosing row when
the nearest has none. Controls of one role that one cell labels, as the
two fields of a date range, are numbered in order. A control no row
labels takes its name attribute, or its id, read through frames of the
page's origin and open shadow roots. The outline, element operations,
and replay identification all read these names, so they agree. A target
recorded with no name is compared among the names the tree itself
gives, so recordings made before rows named controls still replay.
A search on a site built of frames can open a small "searching" window
and post into a frame of the page, whose answer closes the window once
it loads. The browser shows the window in front, and every operation
read the page in front, so when the answer took longer than the five
seconds an action waits for its page to settle, the operations after
the search read the window: an expect failed on "communicating with the
server", an extract read nothing, and an act found no element.

The frame load watcher now also tells which page opened each window and
when each page's frames began and stopped loading. A window the last
action opened while making its opener load a document is held:
operations work on the opener, and the action waits, up to its own
timeout, until the window closes or stays open half a second past the
load. A held window stays held through later actions while its opener
loads, and once let go it is never held again. A window still open
then, such as a list of regions to tick and register, is the page
operations work on, as is one opened without a load in its opener. Acts
and observes name the page they work on rather than leave it to the
runtime's page in front.
A browser step keeps a record of its browser under an ID made from the
DAG run and the step name, and closes the browser a record under its ID
names when it starts, as one an earlier attempt left open. The items of
a foreach run their body steps under the same DAG run and step names, so
an item starting while another item's browser was open closed that
browser, and the other item failed with "stagehand CDP connection
closed" at its next operation.

A body step's environment now tells which foreach item, nested items
included, it runs in, and the browser record ID includes that item. An
item's own retries and resumes still find its record.
@yohamta0
yohamta0 force-pushed the stack/01-g1 branch 2 times, most recently from 4767ba6 to ac51000 Compare October 31, 2026 09:21
SCcagg5 and others added 6 commits October 31, 2026 19:34
Name every tier and event kind in switches, bound the histogram bin
conversion, and drop an unused parameter. The crash test now releases
the handles of the dropped recorder, as the operating system does for
a crashed process, so Windows can remove the temporary directory.
Give each step its own store directory, end a cut line before the next
append, keep the outage failure count across a restart, close backfill
files on every return and reject sub-millisecond intervals, cover
every duration with a histogram bin, and report a bad events cursor as
ErrInvalidCursor. Spec 083 now states the clock rollback rule, the
outage rule across restarts, and the precision of the streak.
Backfill kept an hour and a day that were still open at End in memory
and never wrote them. Without a raw tier the reader cannot rebuild
them, so the hour and day tiers lost the end of the history, for good
once the finer files were past retention. Backfill now writes the open
buckets, as Recorder.Close does.

Its stopped event also carries the failed count of an open outage, so
that a Recorder opened afterwards continues the count instead of
restarting from the last repeated failed event.

Record clamped a backward sample only to the start of the open minute,
so a stored time could still be earlier than the previous sample and
the raw buckets came out of order. A sample before the newest one now
takes the newest time. The spec states this rule and the failed count
of repeated failed and outage stopped events.
Two steps whose names differ only in case got directories that a
case-insensitive file system (APFS, NTFS) treats as one, so both
recorders wrote and pruned the same files. A kept directory name is now
lower case, and any name with an upper-case letter gets the hash.

The streak of an availability query loaded every sample of a raw hour
before it scanned them, so its memory grew with the sample rate. It now
keeps only the last failure and the count after it.

The package doc gave an old histogram range, and spec 083 claimed a
raw-tier disk bound that a fast or failing step could exceed; both now
state the actual limits.
A recorder opened on an existing store forgot its newest sample. A
minute that a flush wrote before a crash, and that had not ended when
the store opened again, was never aggregated, so every tier left it
out. A clock that stepped back across the restart stored samples out
of time order. The recorder now restores the newest stored sample,
bounds the next one by it, and opens its minute again.

A crash writes no stopped event, so the outage count fell back to the
last failed event, which can be an hour old. The count now adds the
failed samples that the raw tier holds after that event. A stopped
event is no earlier than the newest sample, so a clean restart counts
none twice.

The 24 hours availability window started at the next whole minute and
dropped up to 59 seconds of samples. It now starts 24 hours before now,
to the second.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants