The issue recorder, and sending a recording
6.8 The issue recorder — filmopen_recorder; sending a recording — filmopen_support
Section titled “6.8 The issue recorder — filmopen_recorder; sending a recording — filmopen_support”A recording is a tape of everything a person does in the app — every tap, drag, scroll, key, typed text, draft change, navigation, tab, route, setting and log event — one line per event, with a JPEG of the whole view after each action and the project’s text files as they were before the first action and as they are after the last: a report a person can hand to support, and a support agent can replay through the MCP control on a copy of the same files (§4.3, scripts/replay_tape.py).
Starting and stopping. A person presses the rail’s ? (nav.help, §9.1), then Record an issue (support.record) on the support dialog’s Get Support tab; the dialog closes, and the ? turns into a red stop button while the recording runs (pressing it stops the recording and, off the web, opens Describe the problem, below) and is disabled with a tooltip while one is starting or stopping, or while the last words the person said are awaited; the agent layer’s record_start and record_stop start and stop one as well (§4.3), at the screen’s pixels and quality 90, naming no build and opening no microphone, and their stop opens no dialog and lists nothing. Recorder.start(container, {appVersion, build, commit, builtAt, pictures}) settles the open drafts, opens a report folder (or keeps the tape in memory where the platform writes no files at all — the web build), writes the tape’s start line, environment.json and the project’s text files (and the library’s, when the project has one), then takes every door below. Recorder.stop({reason}) reads the boxes and the state a last time, waits for the open drafts and the last picture, gives every door back, and writes stop, the project’s files as they now are and the log of this run and the one before. While it records, the log is verbose: start calls AppLog.forceVerbose(true), and the recording’s end — the person’s stop, before Describe the problem opens, the app’s exit, an agent’s record_stop, a stop that fails — and a start that fails call forceVerbose(false), so that the dialog and the send log at the setting’s level, logged log.verbose.forced {on, reason, threshold, settingVerbose, envLevel}. It changes the threshold in memory alone: the verbose logs setting and the preferences are never touched, so a crash while recording leaves nothing to undo, and a level FILMOPEN_LOG_LEVEL pins wins throughout. Nothing runs before start, and every door start takes is given back in stop, and only the doors that were taken — a door start never reached because an earlier one failed is never released either.
The doors, all Flutter’s own bindings or the state’s seams (§7.1), so that no product widget learns that recording exists:
- the pointer router’s global route (
GestureBinding.pointerRouter.addGlobalRoute): a tap, a drag (a click that moved more than 8 logical pixels) and a coalesced scroll; - the platform dispatcher’s semantics-action callback: a screen reader’s tap and an agent’s, which reach the app the same way rather than as a pointer — the recorder wraps the callback rather than the semantics owner, so whatever wraps it since still hears every action, and its own tap is marked
via: semantics; - the hardware keyboard, for a key that is not text (
Enter,Escape,Tab, the arrows, a shortcut with a modifier); - the boxes under
valueIdentifiers(§4.3), read before every click, 300 ms after the last key or draft change, at each picture and atstop— an obscured, read-only or hidden box is never read, and a box that has just appeared is read silently unless it already holds the focus and text; DraftObservers(§7.1): every draft change, with its value;RouteEvents(§7.1): every route pushed, popped, replaced or removed — a page, a dialog, a popup menu, a sheet; aDropdownMenuor aMenuAnchoropens in an overlay, not a route, and is heard only by the tap that opened it;container.listenonbrowserProvider,navIndexProviderandsettingsProvider: a navigation, a change of page and a changed setting;- the app log, as a
LogSink; - a
LifecycleHook(§7) that stops the recording (reason: exit) and completes its folder before the app exits.
The tape (tape.jsonl, one JSON object per line, LF, appended and flushed event by event so a crash leaves every line before it readable) carries seq (1, 2, …), t (milliseconds since start), at (RFC 3339 with milliseconds, UTC) and kind on every line:
kind |
Carries |
|---|---|
start |
the app version, platform, OS, view, locale, theme, viewer, development mode, the open project (its name, source and paths as they are), the selection, the page, the tab; where the recording was started with them, the build, its commit and when it was built (build, commit, builtAt); the screen’s physical size (display), where one is known |
tap |
id / label / within / role / action / rect / point / button; press: long past a long press’s time; via: semantics for a screen reader’s or an agent’s tap; resolved: false for a click neither an identifier nor a label names |
drag |
from, to, and the identified node at from |
scroll |
the point, dx, dy (wheel ticks at one control within 300 ms coalesced into one line) and the node under the pointer |
key |
the key, its modifiers, and the focused box’s identifier when it is one of valueIdentifiers |
text |
an identifier of valueIdentifiers and the box’s whole value |
draft |
the stem, the key (null for a change that touched several — a copy-left — and for a keystroke or a speaker chosen in a block, whose text travels under the block’s own identifier; blocks, with the whole list as the value, for a block added, removed, moved up or down, or moved to another scene) and, when the key is not null, the value |
navigate |
the selection, the node id and the page |
tab |
the tab bar’s selected tab |
route |
the change (push/pop/replace/remove), the kind (dialog/menu/sheet/page/other, from the route’s type) and the route’s name |
setting |
which setting changed and its new value |
log |
the log entry’s event, level, data, error and stack, as the log has them |
shot |
the file (shots/0001.jpg), the seq of the action it follows, and the view |
state |
the selection, the node id, the page, the tab, the stems being edited, the last log event and whether a dialog is open |
stop |
the reason (stopped/exit) and the event count |
within is the identifier of a node above the control rather than of the control itself, which a row of chips gives — whichever chip was tapped — and a replay therefore reads as naming only, acting by label instead. A pull-down’s or a menu anchor’s items open in an overlay, not a route, so no route scope stops the climb from them and their within can name an identifier far above them in the tree; that is expected, not a fault in the climb.
Events that happen while a pointer is down are written after the click that follows it, for at most 5 s — a button’s callback runs, and can close its dialog, before the pointer router hands the recorder the pointer’s up; a press held past a long press’s time is marked press: long, since replay has no long press and leaves it to a person. The picture and the state line that follow an action are read at the same moment, once the frame and its animations have settled; nothing is written after stop.
The report folder (reportsDirectory, §6.4; <YYMMDDHHMMSS> in local time, -2 for a second recording in the same second) holds tape.jsonl; shots/0001.jpg, … (a JPEG of the whole view — AutomationHost.windowKey — as the recording starts and after every action, encoded on another isolate with the image package: a person’s recording at the view’s logical size and quality 80, PictureSize.person, so that a long one travels, and an agent’s at the screen’s pixels and quality 90); environment.json (the start event’s data — the build that recorded: the app’s version as given; build and commit as given; builtAt where it reads as a moment, else left out; display, the screen’s physical size, where one is known — plus startedAt, the log file’s path and, written at stop, logRuns: the starts log.jsonl holds, each {at, version}, its lines and bytes, how many of the log’s files it came from, thisRunFrom — the byte where this run’s own app.started begins — and incomplete where the start before this one was no longer in the log); project/, the project’s text files as they were at start; library/, the shared library’s text files, when the project has one; project-after/, the same files as they are at stop; and log.jsonl, the app log from the second-last app.started on — this run and the one before, since the problem reported may be the crash that ended the one before — read from the rotated filmopen.1.jsonl and then filmopen.jsonl, whole and unchanged, copied a chunk at a time so that it is never held whole; nothing is capped. The starts are found and the bytes copied in one synchronous stretch, and its own log lines are written only once the copy is done, so that no line of the app’s can roll the file over between the two; a copy that fails writes those lines, then recorder.log.failed {path, file, stage: find | copy, ms} with the error, and leaves no log file open. That stretch holds the window while it reads — a second or more for a long day’s log, under the stop’s busy state —, a cost accepted for the bytes found being the bytes copied. Every build on the machine writes one log file, so a run is what follows an app.started, whichever window wrote it; a start already rotated away is not recovered, and incomplete says so. It logs recorder.log.collect, recorder.log.read {file, from} and recorder.log.collected {path, rotated, runs, starts, lines, bytes, files, thisRunFrom, incomplete, reason, ms} — at warning, reason: no_log_file, where there is no log file. A person’s recording may also hold description.txt and commentary.jsonl (below). On the web the recorder keeps its tape in memory and writes nothing at all.
What a report holds. A typed value travels only under valueIdentifiers (the account popup’s boxes never reach the tape), and the shared dictation popup’s dictation.text, which an agent reads (§9.6), is not taped, since the box it fills is read again as that file’s field: tape.dart‘s isValueIdentifier is the one gate. An obscured, read-only or hidden box is never read, and a live dictation’s partial words and its captions are not taped. The recording’s spoken commentary travels in commentary.jsonl, where Send what I said sends it; the log events that carry the person’s words are whole on the tape and in log.jsonl, as in the app’s log, since a report keeps the log whole: a dictation’s end and a refused target, the words a refused commentary line lost, the voice conversation’s turns and its messages at release (what the person said and the assistant’s spoken answers, §6.11a), and the commentary logged once a report is described. Everything else is written as it is: the paths the recorder writes, every text, label and key on the tape, the log’s data, errors and stacks — the account’s and the plug-ins’ events included —, and the project’s and the library’s files, a manifest’s sync among them; the pictures show what the screen showed. A credential is kept out of the log where it is made (§13), so no file of a report carries one. The person’s consent to share what the folder holds is asked where it is sent (below): a report leaves the machine only on the person’s Send, signed in, though what the person says while the recording’s microphone is on — ticked in Get Support or turned on at the rail — goes to their speech service as they say it (§9.6).
A recording that can be sent (filmopen_support; the recorder names no widget and sends nothing, and filmopen_support, the rail and lib/main.dart, which follows whether a recording runs into the state’s recordingProvider, are the only product code that name it). After its stop the person describes the recording, and keeps it on this device, saves it to send once signed in, or sends it to FilmOpen support. The web keeps no recording once it stops, and sends none.
The flash. RecordingFlash, around the app’s window (lib/app.dart), marks every picture the recorder writes (Recorder.shots, the count of pictures written): a white layer like a camera’s flash — 85 % white over a light window and 55 % over a dark one, held for 60 ms and gone by 340 ms, and no second flash until one is over, so that the window never flashes more than three times a second; where the platform asks for no animation (Linux can; Windows does not pass its setting on, so there the flash always moves), a still layer at 40 % for 150 ms, and none again until the flash’s length has passed — drawn outside the window’s boundary, so that it is never in a recording’s picture nor an agent’s page_shot (an agent’s screenshot, which paints the whole view as the person sees it, can catch one), ignoring the pointer, so that it never takes a click, and left out of the semantics tree; nothing flashes while nothing records.
What the person says (RecordingCommentary, CommentaryButton). While a recording runs the rail shows a microphone (nav.record.mic; not on the web) — off, connecting, or listening in the recording’s red with the time spoken and the cost in its tooltip — which turns a dictation session (§9.6) off and on, on dictation’s selected funding route — the person’s own speech key, or FilmOpen credit for OpenAI’s where no key is stored, where the tooltip’s cost reads Cost unknown until the receipt; a busy refusal keeps the kind of microphone owner that refused it in this recording, even after that owner releases or another capture acquires it; Get Support’s Transcribe what I say (support.record.voice, remembered as support.voice), ticked, starts it with the recording. A message says when the microphone comes on (The microphone is on: what you say now is written beside the pictures.), and while it is on a caption near the bottom of the window (RecordingCaption) shows what is being transcribed as it comes — Listening… what you say will show here. until the first words, then the words of the turn in progress, or of the last one said, as much of their end as two lines hold at the caption’s width, from a word’s start — so that the person sees what goes into the commentary, a noise’s words included. It stands beside the rail, whose microphone and stop stay in sight, and clear of a message of two lines at the bottom whatever the text’s scale. It is drawn with the flash, outside the window a recording’s picture and an agent’s page_shot paint, and has no semantics and takes no click: the words are in no picture of the recording, and no agent meets them in the semantics tree — though an agent’s screenshot, which paints the whole view as the person sees it, shows them. What is said goes to commentary.jsonl at the folder’s top — a line the folder refuses turns the microphone off, saying so (What you say could not be written beside the pictures, so the microphone went off. Press to try again., the code not_written), rather than listening on for words that would be lost — one line {t, text} per stretch of speech: the dictation commits the turn in progress at every picture (cut, §9.6), so that the words said between two pictures stand between them; a stretch’s t, on the tape’s clock, is when it began — the microphone’s start, the commit before it or the picture before it, whichever came last — never before the line above it; a stretch over 1 000 characters is cut at a space into lines of the same t; at 2 000 lines the commentary is full and the microphone goes off, saying so. The rail’s stop waits up to five seconds for the last words before the recording closes, disabled meanwhile, saying so; the session ends with the recording, however it stops, or after twenty minutes without speech, and an end that is not the person’s own is said in a message, in the commentary’s own words. While the recording runs each stretch logs support.commentaryWritten {path, t, lines, characters, notWritten?, text?, ms}, text the words of the pieces past the line limit, joined by a space, a stretch the folder refuses support.commentaryNotWritten {path, t, text, error, ms}, the words lost with it, and a stretch flushed after the folder is full support.commentaryNotWritten {path, t, text, reason: full, ms}; the dictation’s end and these lines carry the words whole, in the log and on the tape alike, the whole commentary enters the log when the recording is kept or sent (below), and none enters the usage log, whose records name their purpose, support.commentary. Transcript, report and Usage finalization are bounded and happen once, and every end joins the one under way. The microphone’s stop is dictation’s (§9.6): one that fails or does not answer in three seconds is logged with its error and the microphone released, the rail’s microphone is off and says so (mic_unavailable), and the next press opens a new one; the button goes when nothing records.
Describe the problem (ReportFlow, DescribeDialog). The rail’s stop hands the folder the recorder wrote to ReportFlow.instance.afterStop. The recording is listed as not sent from then on, and the dialog opens: the description (support.report.description, at most 4 000 characters, said as well as typed through the app’s microphone, support.report.description.dictate, §9.6, what is dictated going in as typed text does), written to description.txt at the folder’s top, UTF-8, as typed, however the dialog closes, and deleted where it is blank, logged as report.descriptionWritten {stamp, description | erased, commentary?, sendWords?, ms} — the description and what was said, whole — or, refused, report.descriptionNotWritten {stamp, description, error, ms} — and before anything is sent: Send writes it first, and a folder refusing the write or the erase keeps the dialog open, saying so (The description could not be saved, so nothing was sent. Try again, or keep the recording on this device., support.report.not-saved), with nothing sent, so that no stale description and no erased words ever travel; where the commentary holds words, What you said (support.report.said), read-only, with Send what I said (support.report.send-said), ticked, kept with the recording as it is ticked; the consent (support.report.consent) — what is sent, the shared library’s text files among it; that it goes to a FilmOpen server, where an AI, Anthropic’s Claude, reads it to find the problem; that anything made public about it holds none of the person’s content; that it is deleted once the problem is reproduced on made-up data, and after thirty days at the latest; that Delete my report is on the website — above the buttons until Don’t show this again (support.report.consent-seen, remembered as support.consentSeen), behind What is sent? (support.report.what) afterwards. Signed in — as the rail’s profile reads it, AccountCardModel.signedInGiven: the account’s accountSignedIn hint, which starts nothing, until the account knows better — Keep on this device (support.report.keep) and Send (support.report.send), disabled, saying why, while the description is blank and no words are to be sent; signed out, Save (support.report.save) and Sign in (support.report.sign-in), which saves the recording and opens the account’s popup on Settings — the one sign-in opener, AccountOpener (§6.6) — saying at its top Recording saved on this device. Sign in, then send it from Get Support., where a person signs in. Off a phone, where a file browser can open the recording’s folder, Review files before sending (support.report.review) at the bottom of the dialog opens it — the door Show in folder uses — so that the person may look at a picture or mark it up first, logged report.review.start and report.review.open {stamp, folder, outcome, ms}; a folder the file browser does not open is said in the dialog, with its path (The folder could not be opened here. It is …, support.report.review.refused); on a phone, whose folder is the app’s own, it is not offered, logged report.review.hidden {stamp, reason, platform}. Send zips the folder as it then is. Keep says Recording kept on this device. and Save Recording saved on this device. Sign in, then send it from Get Support., each with Show in folder; dismissed — a tap beside it, Escape, or the description’s microphone leaving for its speech provider’s page under Settings → Plugins where no speech key is kept — the dialog keeps the recording as Keep or Save would. Cancel (support.report.cancel, signed in and out, its tooltip saying what it does) asks first — Discard this recording?, No (support.report.discard.no) and Yes (support.report.discard.yes); No, Escape or a tap beside the question leaves the dialog as it was — and on Yes discards the recording: its folder with everything in it — the pictures, the tape, what was said, the description — never what a link there leads to, its zip, and its receipt with a ticket’s id and number, so that nothing of it stays on this device; nothing typed is written first, and Recording discarded. says so. Only a folder with a report’s name is deleted. One a program holds a file of stays listed, to discard again (The recording could not all be deleted. It stays under Get Support; try again there.). A ticket the website made at an earlier attempt stays on the website, which has no way to withdraw one. Get Support lists the recordings the rail’s stop handed to Describe the problem and that are not sent — one the app’s exit or an agent’s record_stop ended has no receipt, and is not listed — newest first by when each was made, each with Send (support.unsent.<stamp>), which opens the same dialog with its description; one whose folder is gone is forgotten, and the person told. What became of each recording — kept, saved, pending or sent, whether what was said goes with it, the ticket’s id and number, the zip’s hash — is its receipt, remembered as support.receipts by the folder’s stamp; a receipt holds no word of the person’s.
The zip (ReportZip). Send zips the recording on another isolate: only what a support box takes — at the top tape.jsonl, environment.json, log.jsonl, description.txt and, where Send what I said is ticked, commentary.jsonl; shots/NNNN.jpg; and the text files (.json .jsonl .txt .md .fountain .csv .yaml .yml .srt .vtt) of project/, project-after/ and library/ — with plain relative paths — the recorder’s own rule for what it writes (isReportPath) and its snapshot’s text extensions (isSnapshotPath), so the two never disagree — never a link (the listing follows none and lists none; a file it lists is read as it is), each entry at one fixed time and in a fixed order, so that the same folder gives the same bytes. A box takes at most 5 000 entries; one past them is left out, and counted and named in the log. Every file goes whole, however large — the two runs’ log above all —, and the zip’s size is the one bound. It is built from disk as a stream, so that memory stays flat whatever its size: into <stamp>.zip.part beside the folder, each file read a mebibyte at a time, deflated at level 1 (the fastest; on the web, where a recording is in memory only, by the pure-Dart deflate of the archive package at the same level, its input held whole) and its CRC computed as it goes, its local header written over with its CRC and sizes once it is done (the box takes no data descriptor), the hash read back from the file; past the bound it stops there, the temporary file removed, and the size it reached is the one the message gives. Done, it is renamed onto <stamp>.zip beside the folder — never through a link at either place, which goes, what it led to untouched —; a zip that will not be sent (no tape in it, or over the bound) is deleted, with a .part an app stopped mid-build left behind, each tried apart and each refusal logged (report.zipNotDeleted {stamp, path, error}, then report.zipDeleted {stamp, deleted, refused, ms}); a recording forgotten because its folder is gone has its zip deleted so too. The zip is what the person can open to see what goes, and it stays only while a send could not be made now, or after one answered as sent though no box took it then (below). Over 300 MB (kReportZipLimit, megabytes of 1 048 576 bytes, against a runaway upload, far above two busy runs’ logs zipped) nothing is sent, and the message gives the size, logged report.tooLarge {stamp, bytes, bound}. The tape is the recording: a folder without it — deleted or emptied since — is forgotten, and the person told; one there and not read now is not sent now, to try again. A file of the folder that cannot be read — not UTF-8 where it is text, or the system refusing it — logs report.fileNotRead {stamp, path, reason, error} (a file that is not there is an answer, not a failure); what the zip’s isolate logs comes back with the zip, or with its failure, and is written to the app’s log. Every stage is logged, each event with the recording’s stamp: report.zip.start {stamp, folder, commentary}, report.zip.listed {listed, chosen — each path with its size —, skipped, total, ms}, report.zip.read {path, bytes, packed, bytesWritten, ms} for each chosen file once it is written — bytes and packed null for one that could not be opened, which is left out —, report.zip.compress {entries, unpacked, left, ms} before the directory and report.zip.done {entries, unpacked, bytes, bytesWritten, bound, over, sha256, path, ms} — stopped past the bound, over: true with stopped, the file being written, null where it was the directory —, or report.zip.failed {stage (list | open | read | compress), path, bytesWritten, ms} with the error, its temporary file removed. A disk too full for the temporary file fails so, with the system’s error.
The ticket and the upload (HttpReportSender, which the rail installs with the build’s facts). On Send, signed in, the flow asks the account for the session’s token (SupportAccount.accessToken, bounded at fifteen seconds); none — no session, or one the account’s server refuses with a 4xx other than a limit on its requests — is the sign-in, with nothing asked of anyone. Then POST {kFilmOpenSiteUrl}/v1/support/tickets with Authorization: Bearer <token> and exactly {app, build, commit, platform, size, sha256, locale} — the build that sends, the zip’s size and SHA-256, the app’s language — answered 201 {id, number, boxes: [{name, url}], uploadToken, expiresAt}; the ticket is kept with the recording at once, so that a window closed during the upload leaves a retry under it. Then PUT {box.url}/reports/{id} with the upload token, Content-Length and the zip, read from its file in pieces as the connection pulls them, to the boxes in the order named, three at the most: 2xx or 409 (the box holds it already) is sent; a connection failure, a time-out (ten seconds to connect, a minute without progress), a redirect or a server’s error is the next box’s turn; any other refusal ends the attempt (413: too large). A box is contacted only where the website named it, over https, with a host and no user, query or fragment; no redirect is followed; the session’s token goes to the website alone, the upload token to a named box alone. From the website, which has thirty seconds to answer, 401 is the sign-in, 413 too large, and anything else — too many requests at the moment (429) among them — could not be sent now; no limit of recordings a day is said. A retry of the same zip first asks POST …/tickets/{id}/upload-token: 409 there is sent; 401 and 413 there end as on a new ticket; a 2xx without a box, or a refusal but 401, 413 and 429, makes the ticket again, once; a redirect, a server’s error, a 429 or no answer keeps it, to ask again. A zip changed since its ticket — its description, or the tick — gets a ticket of its own. What the website answers is checked before it is used: the id’s shape (it goes into two addresses), a printable upload token, the boxes’ addresses.
The endings. Sent: the receipt is sent, with the ticket’s id and number, so that Get Support lists nothing of the recording. Where a box took it in this attempt — its 2xx to the upload (SendAnswer.uploaded) — it leaves this device as a discarded one does (ReportFiles.discard): its folder with everything in it, never what a link there leads to, and its zip; only a folder with a report’s name — so that it ties up none of the person’s storage; the log says which and whether all went (report.discarded {stamp, path, whole}), as at Cancel. A send answered as sent without that — a box’s 409, which holds it already, or the website’s 409 already-sent on a retry, which it answers for every ticket no longer waiting for its upload, an expired or a refused one among them — deletes nothing: the folder and its zip stay, since neither answer says that a box holds these bytes. The message: Ticket 8,877 was created. — the ticket’s number as the website writes it, its digits grouped by threes with commas in both languages (ticketNumberText), or the ticket’s id where the website gave no number, and Your ticket was created. where it gave neither — with View on the website (support.ticket.view), which opens the ticket’s own page, {kFilmOpenSiteUrl}/account/support/?ticket=<id> (supportTicketUrl, the id URL-encoded; the support page itself where the website gave no id), and, for an administrator — the session’s app_metadata.role is admin, as the account already knows it; the website still decides who sees the page — Open as administrator (support.ticket.admin), which opens {kFilmOpenSiteUrl}/admin/support/?ticket=<id>, logged report.link.offered {stamp, ticket, admin}, report.link.open {ticket, which, url} and report.link.opened {ticket, url, outcome, ms}; where some of a recording a box took could not be deleted, Some of the recording’s files could not be deleted from this device. follows. Could not be sent now: The recording couldn’t be sent now. It stays on this device, under Get Support, to send later., and the recording stays pending, with its zip; Try again sends it as it then stands. The sign-in: the account’s popup, through the one opener (AccountOpener, §6.6), saying at its top Recording saved on this device. Sign in, then send it from Get Support. Too large: said, and the recording kept. Sending the recording… stays until one of these takes its place; one send at a time for a recording, whose Send in the list is disabled meanwhile, saying why. Keep, Save and Sign in delete a zip an earlier attempt kept. The log holds report.sent {id, bytes, box}, report.failed {code}, report.boxFailed {box, code} and codes like them; each request as report.request {url, body} before its token is set, each answer as report.answered {url, status, body, ms} with its body whole, the upload token and the box URLs included, each upload as report.upload {url, bytes} and report.uploaded {url, code, ms}, and a failure with its error; the account’s session token, a request header, is never logged. A ticket made again is logged with the old ticket’s id and its code (report.ticketAgain {id, code}); an answer that is no JSON object is logged as its text; the recorder’s start and stop name the folder by its path (recorder.started, recorder.stopped).
Replay and comparison (scripts/replay_tape.py <folder>, reusing scripts/agent_walk.py’s MCP client): copies project/ to a temp folder, launches the agent build there at the recorded size, theme and locale (the language the tape names is the one shown, English alone while FilmOpen is in development), sets the recorded viewer and selection, then walks the tape in order — a tap by identifier or by label, a tap in a field: row followed by a draft change as set_field (a chip chosen or cleared), a tab as set_tab, every keyed draft change as set_field with the value it carried (a box scrolled out of view gives no text line) — a structured value (a relationship row) as a manual step — a text in a form’s box that left no draft (a height typed as abc) as set_field with that text, a text elsewhere as tap then enter_text, a navigation made only where the app is not already, a setting nobody clicked for as set_viewer, set_locale or set_theme, and a key, a drag, a scroll, a long press or a click resolved: false as a manual step — printed, never failed. Each state line is a checkpoint: once the lines up to its action are replayed and any autosave has landed, the state is compared (selection, page, the forms open, the tab), then the picture is taken and compared. A standalone or tap-consumed recorded tab waits for its exact recorded selection only when the tape proves a preceding matching entity creation and a later project-opened read after the latest Create action. The existing twenty-second readiness bound applies before the tab action; an unrelated selection or ambiguous event sequence supplies no guessed dependency, and a genuine tab refusal remains a failure. At the end, the writes and failures the tape’s log lines hold must be in the replay’s log, and the replay’s log must hold no error the tape does not; the last value each keyed draft change left must be in the replayed file; and the files must equal project-after/, key by key, the header’s clock (created, updated) and every picked_at / set_at aside. The pictures are compared by packages/filmopen_recorder/bin/compare_shots.dart (pure Dart on the image package, so replay needs no Python imaging library): a pair under 1 % of pixels whose largest channel difference exceeds 16 is same, else different, drawn side by side (person, agent, the difference in red, the tap’s position ringed) into <out>/compare/. Known limits: the manual steps (a key, a drag, a scroll, a long press, development mode) are printed, never replayed; a picture’s measure does not see a typed value (one field’s text is about 0.1 % of the view, under the 1 % threshold — the fields and the files prove the values); replay resolves against the machine’s own shared library, not a copy of the one recorded; a frame-idle checkpoint can still capture an asynchronous Create dialog while busy, which a faster replay cannot reconstruct after creation finishes. State and painted pixels are captured before JPEG encoding finishes, so moving the state read after encoding would not preserve their pairing.
Where the tape’s first assistant offers name stock fo-assistant and its always ask action lacks credentials with a provider, replay waits for the wide dock’s existing assistant.readiness row after restoring the viewer and before restoring the recorded selection. That row means the catalog and provider route have published; restoring a form earlier can briefly show more of its cards and write a media visit. The wait uses the existing twenty-second readiness bound and settles frames before restoration. A tool refusal, malformed answer or expired bound fails setup. The compact dock is a button with no inline body and needs no row wait. The numbered recorder plan waits for the same state before recording and scrolls a displayed tree up to its project-root row: a previous project’s scroll can survive a project change, and the start state carries no scroll offset. Whole-picture, state and file comparisons retain their original checks.
The numbered runner retains a failed replay’s complete input report folder, including tape, pictures, environment, snapshots and log, before removing its owned application home. It retains the child’s exact stdout, stderr and exit status as well. Cleanup preserves these failure artifacts and a nonzero replay remains a failed acceptance. Each child replay uses a new directory within the selected output folder, so repeating it preserves earlier complete input and byte streams without merging reports.