Commit Graph
5855 Commits
Author SHA1 Message Date
Reece b63078a4b1 feat(processing-folders): the beginnings of a unified file filter
The toolbar's controls each carried their own ad-hoc .filter chain, and the
text box was a name-only sub-search blind to everything the app knows about
a file. Filtering now goes through one model and one pure pass
(fileFilters.ts): text, origin, types, category — a new facet extends the
model instead of adding another chain. The text filter reaches through
classification too: typing "user guide" or "finance" finds the files so
tagged, via a per-vocabulary index on the grouping seam (core, without
classification, matches names alone). Category filter state moves into
FilesPageContext beside its siblings, so the whole filter lives in one home.

The category dropdown also shows each family's own icon in its sidebar
accent, in the list and on the selected value.
2026-08-18 15:37:47 +01:00
Reece 75170a2335 fix(processing-folders): badges get opaque backers
The origin and category badges tinted into transparency, and they sit on
top of thumbnails — whatever the page happened to render underneath bled
through and made them illegible. The tints now mix into the surface colour
instead, so every badge carries a solid, theme-aware backer.
2026-08-18 15:26:46 +01:00
Reece 56b66e588b fix(processing-folders): the badge is the category icon, not category plus labels
The card wears only the family icon(s) — the same identity as the sidebar
group and the files-page filter — with the labels named on hover rather than
each drawing its own icon. Label icons stand in only when no visible family
claims the labels (a hidden category), so a tagged file is never entirely
unmarked.
2026-08-18 15:18:21 +01:00
Reece 6a820888a7 feat(processing-folders): file badges lead with the category icon
The badge showed only label-level icons, while the sidebar speaks in
categories — so a card tagged manual + user-guide gave no visual cue that
it lives under Operations. The file's categories (the families its labels
roll up into) now lead the badge, each wearing its family icon in the same
cycled accent as its sidebar group, with the labels' icons following. The
hover names the group first and the specifics after: 'Operations — Manual,
User guide'.
2026-08-18 15:11:40 +01:00
Reece 93addc0a4f feat(processing-folders): My Files filters by classification category
The categories existed everywhere except anywhere you could act on them: the
sidebar groups by them and cards wear their labels, but the files page had
no way to see "just the Finance documents". The toolbar now carries a
category filter beside the source and type filters, offering the same
visible families in the same order the sidebar groups by, and keeping the
files whose labels fall under the chosen family.

The options come through the grouping seam (useCategoryFilterOptions): core,
which has no classification, offers none and the dropdown never renders.
2026-08-18 15:06:11 +01:00
Reece a2da7f43e3 fix(processing-folders): category badges use the vocabulary's own icons and accents
The category tag invented its own look — a generic tag glyph and the raw
label slug as text — when the classification vocabulary already gives every
label an icon and the sidebar already gives every category an accent colour.
The badge now wears exactly those: each label's own icon, tinted with the
accent its category cycles to in the sidebar (hidden-category labels wear
the Other group's neutral grey), no text, and the translated display names
on hover. Up to three icons show; a "+n" covers the rest.

The lookup lives on the grouping seam (useLabelBadges): core, which has no
classification, renders nothing; the proprietary override resolves icons
from the vocabulary and accents from the same visible-category order the
sidebar colours by, so a card's badge and its sidebar group read as one
colour.
2026-08-18 13:51:58 +01:00
Reece 0160fd7b80 feat(processing-folders): files wear their categories as a tag
A classified file's categories were invisible until you opened the details
panel or noticed which sidebar group it sat in. File cards and rows now
carry a category tag beside the origin badge — a tag glyph, the first
label, "+n" for the rest, every label on hover. Unclassified files show
nothing: absence of the tag is the "no category" state.

Disk-listed files get the same tag without ingesting anything: the lazy
thumbnail pass already holds the file's bytes, so it harvests the embedded
classification labels in the same read and caches them beside the
thumbnail. The Outputs section of a processing folder thereby shows what
each result was classified as, straight off the disk.
2026-08-17 19:30:25 +01:00
Reece 73b0450f1a perf(processing-folders): opening a disk file reuses its thumbnail and carries its labels
Opening a file from a mounted folder paid twice for work the listing had
already done. The listing's thumbnail cache and the workbench's hydration
pipeline live in different key spaces (path vs file id), so the same first
page was rasterised again on open; and the labels a processed file carries
in its own metadata were ignored, leaving it in "Other" until the sidebar's
idle backfill got around to re-reading the whole PDF minutes later.

The open path — and only the open path; ordinary uploads are untouched —
now hands the listing's cached thumbnail through a new precomputedThumbnails
add option. Hydration still parses the document (page count, rotations,
dimensions are still needed) but adopts the supplied image instead of
rendering: both variants when page one carries no rotation (the common
case, zero renders), just the display variant otherwise. And the labels are
read out of the bytes already in hand and stamped on the stub at add time,
so the file lands in its category instantly and the idle backfill skips it.
2026-08-17 19:25:17 +01:00
Reece ffa85c2a74 feat(processing-folders): a processing folder opens as Inputs / Outputs / Processing
A mount with processing attached no longer opens onto a flat listing of its
originals — with the results landing in a separate directory the folder
looked untouched, and the one place named after the source showed exactly
the files that carry no categories. It now presents as a master folder of
three fixed sections: Inputs (the originals, read straight off the watched
directory, never changed), Outputs (the processed results, read off
whatever directory the record delivers to — today a subfolder, later
wherever an output picker points), and Processing (the runs executing right
now, polled live with each document's name and step cursor).

The sections are pure presentation: no stored folder backs them, the master
IS the existing mount record, and navigation rides a `section` URL query
the pathname sync already strips on any real folder change. The breadcrumb
grows a trailing section crumb, with the master's own crumb clearing the
section. Counts on the three cards come from the two directory listings and
the live runs poll.

PolicyRunView now carries the input document's display name (the trailing
segment of a path-shaped file identity) — a client showing live runs needs
something to call them before any output exists — and the processing-folder
hook exposes the record's output directory and an active-runs listing
through the same core/proprietary seam as the rest of its API.
2026-08-17 17:41:05 +01:00
Reece b7c9065a4e perf(processing-folders): faster sweeps — wider gate, filtered polling, no reselect churn
sweepConcurrency's default rises to 6: the classification pipeline is
API-bound (one fast-model call per document, ~half a second), so it scales
nearly linearly with concurrency and 2 was the bottleneck — a 100-file sweep
drops from ~90s to ~30s while completions keep arriving steadily. The knob
stays for installs whose pipeline really is a heavyweight local engine.

The runs listing takes an optional policyId filter and delivery passes it:
following one sweep polls the endpoint every second, and the unfiltered
response re-serialized every other policy's runs each time, growing with
history. The client-side filter stays as a guard for backends that ignore
the parameter.

Delivery no longer selects what it opens: a selection isn't meaningful
across a folderful of results, and re-selecting on every batch re-rendered
the whole growing file list once a second for the length of the sweep.
2026-08-17 14:54:50 +01:00
Reece d580f565b7 perf(processing-folders): pace sweep runs and take smallest files first
A sweep fans out one run per file and dispatched all of them at once onto the
unbounded virtual-thread executor. Every run then converges on the pipeline's
slowest tool — on a desktop install, the local AI engine, which serves a
couple of requests at a time — so the whole folder sat "in progress" with
nothing visibly finishing until the end, then completed in clumps. Same total
time as pacing, with the worst possible feel.

Each sweep now carries an admission gate (policies.sweepConcurrency, default
2, 0 = unbounded): every run is still registered and reported to the caller
immediately, but only that many execute at once — parked runs sit honestly
pending on their virtual threads and start as slots free. Completions arrive
as a steady drip from the first file onward, which is also what feeds the
sweep-result delivery opening files into the workbench one after another.

The disk listing also orders a sweep smallest-file-first, so the first result
appears within seconds of approving rather than after the largest document in
the folder.
2026-08-15 15:41:54 +01:00
Reece fb6c4d45af feat(processing-folders): mount sweeps deliver their results into the workbench
"Process files in this folder…" on a mounted folder was fire-and-forget: the
backend swept, results landed in the on-disk output subfolder, and the app
showed nothing — a finished job and an unchanged screen. The Downloads offer
already solved this (poll the sweep's runs, open each run's results as it
settles); that machinery now lives in one shared delivery module the wizard
renders progress from, and the folder menu's enable and sweep actions use for
every mounted folder. Storage-backed folders stay as they are — their results
replace files in place, already visible where the user is looking.

The sweep endpoint's client now returns the backend's outcome (run ids,
already-processed count), which is what tells delivery how many runs to wait
for instead of polling for runs that were never going to start.
2026-08-15 15:13:11 +01:00
Reece 0d5f21bed8 fix(processing-folders): resolve the second review round's ten findings
Ledger integrity — track-mode settle now takes the content hash through the
same claim-gate guard the consume path uses: a file replaced mid-run settles
null instead of recording the replacement's bytes under the old gate (which
made the new content read as already-processed forever), and a file deleted
mid-run no longer throws out of the completion callback. The storage sweep
likewise survives one unreadable blob by skipping that file unclaimed rather
than aborting the whole folder on the hash supplier's exception.

Compose atomicity — the controller held the pair's "neither exists
half-configured" promise only for creates. A create against a place that
already has a processing folder now adopts the existing pair (same policy,
same ledger — so re-approving the Downloads offer sweeps only what is new
instead of composing a duplicate with an empty ledger that re-processes
everything), and a failed update validation restores the prior source instead
of leaving it mutated under an unchanged policy.

Folder placement — replaceFile itself now re-anchors the row's folder as a
fresh by-id reference before saving: the entity is often detached (runs
deliver on worker threads) and merging its dead folder proxy dropped the FK,
silently moving the file to the file-manager root. That retires the storage
sink's read-check-repair compensation (which raced concurrent moves) and
fixes the ordinary web update path, which had the same bug and no patch.

Portal isolation — a disk-backed processing folder's source shares its type
with real folder-watch sources, so the Sources overview now hides pair
sources by policy reference rather than type alone; they no longer surface as
zero-reference orphans an admin could pause out from under the editor.

Downloads offer — the local-folder mount only happens where this build can
actually read the directory, so a plain browser no longer bookmarks the
SERVER's Downloads path as a forever-empty folder; and the success copy now
matches the one-shot behaviour instead of promising a standing watch the
approve flow deliberately stands down.

Virtual folder engine — a scan's own deliveries bump the IndexedDB revision
the effect keys on; the re-fire now queues a follow-up pass instead of
cancelling the scan in flight, which stranded every file after the first
delivery until an unrelated write happened along.

Locales — en-GB restored to main (en-US is the only hand-edited locale) and
en-US re-sorted.
2026-08-15 14:29:23 +01:00
Reece 365ebaf46d fix(processing-folders): disk-run outputs open into the workbench again
A disk-backed run's outputs live on the filesystem: FolderOutputSink hands
back a synthetic fileId (nothing serves it) with the output's absolute path
in fileName, so downloading them from the storage endpoint 404s and the
Downloads offer finished having opened nothing. fetchRunOutputFile now
recognises a path-shaped name and reads the file straight off the disk via
the local-folder read seam — which only a build that can see the filesystem
provides, and on desktop the server writing the output IS this machine. Web
builds keep the storage-id path for storage-backed runs and skip disk
outputs with a clear error instead of a misleading 404.
2026-08-15 13:53:21 +01:00
Reece f715d74227 feat(processing-folders): virtual folders process their files client-side
A virtual folder's files live only in this browser's IndexedDB, where no
server-side watcher can reach them — so their processing engine runs in the
client. The folder's pipeline config lives on its own record
(FolderRecord.processing, written via virtualFolderStorage.setProcessing),
and a headless loop under PolicyAutoRunController plays the watcher: it
scans enabled virtual folders on every IndexedDB revision, uploads each
unprocessed file through an ad-hoc pipeline run (the same engine stored
policies use), and delivers the output back as a new version of the input.
The child stub inherits the input's folderId, so results stay in the folder;
classification labels are read off the output PDF and stamped on the stub so
the sidebar groups it immediately. Each (folder, file) pair dispatches once,
through the shared dispatched-marker store, with derivedFromTool as the
durable guard against the loop eating its own outputs.

The engine splits on the AI toggle exactly as org-wide Classification does:
with the engine on, files run server-side through the pipeline; with it off,
the browser-side classifier covers them — virtual processing folders now
count toward its enabledFolderIds scope.

The folder menus offer processing on every kind. A virtual folder's engine is
continuous (arrivals process on their own), so its menu carries only stop —
an explicit "process now" would have nothing to do.
2026-08-15 02:25:11 +01:00
Reece f3f2a2fddc feat(processing-folders): processing attaches to real folders, not a synthesized row
A processing folder is now an ordinary folder with a pipeline attached,
whatever its kind. The files page previously showed disk-backed processing
records as a synthesized "mounted" entry — a row with no folder behind it, no
menu, no way to remove it. That entry kind is gone; a directory-backed record
now attaches to the real mounted folder whose directory it watches, matched by
path, while server folders keep matching by storage folderId.

useProcessingFolders speaks folder records instead of bare ids: stateFor,
enable, disable, and sweep all take the FolderRecord and resolve the record
kind-appropriately. Id-only callers (the client-side classification loop) get
enabledFolderIds and anyEnabled instead of the raw map. Virtual folders
deliberately resolve to no state yet: their files live in the browser, so
their engine is client-driven and lands separately.

Mounted folders get the processing actions in their kebab (their other edit
items stay hidden — the directory owns name and lifetime), factored into one
ProcessingMenuItems shared by card and row. Removing a mount also deletes the
processing record watching its directory, which otherwise would keep running
against a folder the app no longer shows.

The Downloads offer now mounts the directory as a real local folder alongside
composing the processing record, so its results have a folder the user can
open, style, and remove like any other.
2026-08-14 23:12:53 +01:00
Reece bc6979a659 Merge branch 'folder-kinds' into processing-folders
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java
#	frontend/editor/src/core/components/filesPage/FileGrid.tsx
2026-08-14 19:00:23 +01:00
Reece 4e75e11714 fix(folders): resolve the ten confirmed review findings
Virtual membership seam — the invariant "a local file has no folderId" was
relaxed on the way into a virtual folder but never on the way out, nor taught
to the server sync. Moving a local file to the root now clears its browser-
side membership (previously the move silently did nothing and the file was
stuck), and the reconcile keeps a stub's folderId when it points at a virtual
folder — the server has never heard of that folder, so its null is not an
opinion about the membership and must not eject the file on save-to-server.

Mount flows — the disk-listing effect's bail-out branch also stands the
loading flag down, so leaving a mount mid-listing no longer strands the
skeleton over every folder; the effect depends on the stable error setter
rather than the whole folder context, whose identity changes on every
mutation including this effect's own error reporting; and landing in a fresh
mount goes through the URL, which owns folder selection — setting state
directly raced the pathname→state effect and bounced the user back to root.

Appearance — the virtual branch forwards only the fields the picker sent;
always sending both meant the store's spread persisted an undefined over
whichever field the user did not touch.

Creation gating — the empty-state CTA and the sidebar rail say their kind out
loud (on this device) at the root instead of falling through to the context's
server-preferring default, which guaranteed a 401 for guests; inside a server
folder the New-folder button carries the server-side blockers again, since
the subfolder inherits kind server and would otherwise fail at submit; and
the rail's blanket reachability disable is kind-aware, so desktop's rail
action works at all.

IndexedDB — the runtime missing-store self-heal is gone. It left databases
permanently ahead of the configured version, where a future oldVersion-gated
migration silently never fires. The cure for two schemas shipped under v10 is
the honest one: v11 declares the full schema, and the ordinary upgrade path
completes any v10 profile.

Tree sidebar — mounted folders offer "Remove (files stay on disk)" like the
grid does, instead of a greyed Delete whose tooltip claimed removal was
impossible on the one surface still showing the folder.

Thumbnails — a disk file's bytes are only read when its extension names a
type the generator can render, and the thumbnail cache is bounded by bytes
rather than entry count, since an image thumbnail's size tracks its source.

Locales — en-GB is restored to main (en-US is the only hand-edited locale;
the rest are translated separately) and en-US re-sorted so the pre-commit
gate passes.
2026-08-14 18:40:10 +01:00
Reece 0a9e07c5bd copy(folders): the disabled server option states the fact, not homework
"Ask your admin to turn it on" told a desktop user to go ask an admin who
doesn't exist about a feature the install deliberately lacks. The caption now
just says what's true; what to do about it is the reader's business.
2026-08-14 17:53:40 +01:00
Reece e8a97ff27c feat(folders): New folder is one button, and the button is the menu
The split treated "on this device" as the main act and the other two
destinations as an afterthought behind a chevron — but the three are peers,
so every click now shows all of them. One button, chevron affordance built
in, three actions each with its caption.
2026-08-14 17:49:20 +01:00
Reece 650dcc8465 feat(folders): the New-folder choice moves from a form into a split button
The dialog-as-chooser read like a settings page: three radios with paragraph
descriptions under a title ("New folder") that one of the options — adding an
existing folder — made a lie of, and a body that rearranged itself when the
disk option swapped the name input for a picker.

The choice now lives where choices belong, on the button. Plain click does
the common thing (a folder on this device); the chevron offers each action
under its true name with its minimal flow. "Add folder from this computer"
goes straight to the native picker — no dialog, no typing, the directory's
name is the folder's name, and landing inside the fresh mount is the
confirmation. The server item, when unavailable, says why in its own caption.

The name dialog goes back to being a name dialog: one input, one job, a
title that's true. The kind is chosen before it opens and rides the dialog
state; a subfolder never asks, it inherits.
2026-08-14 17:43:12 +01:00
Reece a90b66d216 feat(folders): the chooser speaks in actions, and local is local
"On this device" is the distinction that matters, not which corner of it —
once something is in Stirling, browser-store versus mounted directory is an
implementation detail. So the separate On-disk badge is retired: virtual
folders, mounted folders, and disk-listed files all wear the same Local mark,
with tooltips still saying which flavour when someone cares.

The New-folder chooser now names what the user is doing rather than where an
implementation lives: "Add an existing folder" (point Stirling at one already
on this computer), "Create a folder on this device", "Create a folder on the
server". And a disabled server option explains itself in its own description
text instead of a hover-only tooltip — the reason is the one thing the user
needs, and hover-only text hides it (and never appears on touch).
2026-08-14 17:34:16 +01:00
Reece 835ea60769 fix(files): origin badges on cards can actually show their tooltips
The badge has carried a tooltip all along, but the card's origin overlay sets
pointer-events: none so the card's own clicks and drags pass through — which
also meant the badge could never receive a hover, so the tooltip never
opened. The overlay stays transparent; the badge inside catches pointer
events again. List rows were unaffected, their badges sit inline.
2026-08-14 17:28:35 +01:00
Reece d29c044e3c fix(files): the On-disk badge keeps the neutral grey — the glyph carries the distinction 2026-08-14 17:26:21 +01:00
Reece 4d3ce35aee fix(files): the On-disk badge stops impersonating the Local one
Adding the disk origin cloned the local case wholesale — same Computer icon,
same grey — and in compact mode the icon IS the badge, so a browser-only
folder and a mounted directory wore identical marks. Disk now gets its own
glyph and tint; the Computer icon stays meaning "this browser".
2026-08-14 17:23:24 +01:00
Reece ecd72dbb19 feat(folders): folders wear the same origin badges their files do
A folder row now answers "where does this live?" the way a file row already
did: server folders wear the Cloud badge, virtual folders the Local one, and
mounted folders On disk — same component, same corner of the card, same slot
beside the name in the list. The badge grew a tooltip override because its
default hover text describes files, and a folder wearing the identical mark
deserves wording about itself.
2026-08-14 17:20:05 +01:00
Reece b77f570089 fix(folders): disk-read files carry a MIME type, so thumbnails can exist
The filesystem hands back bytes and a name, never a MIME type — but
everything downstream branches on File.type: the thumbnail generator's PDF
path, the workbench's format handling. An untyped File silently took every
"unknown format" branch, so no disk-listed file could ever render a
thumbnail (and the failure was then cached as final). The type is now
recovered from the extension.
2026-08-14 17:13:47 +01:00
Reece 94b09152c4 feat(folders): disk-listed files render real thumbnails
Same generator and the same two-at-a-time concurrency gate as stored files,
so a mounted folder's rows fill in progressively alongside everything else
instead of stampeding the disk — each render reads the file's full bytes,
which is also why results are cached by path + mtime + size (an unchanged
file never renders twice, an edited one does) with a bounded FIFO. A failed
or oversized render caches its emptiness too, so the same row doesn't retry
on every mount; the extension icon simply stays.
2026-08-14 17:10:45 +01:00
Reece 60a19f609d fix(folders): a mounted folder's listing behaves like the rest of the page
Disk-listed files now render as ordinary file cards and rows — same classes,
same layout, size · date meta, an extension fallback thumb — wearing an
"On disk" origin badge where stored files wear Local or Cloud, and their menu
action is named "Add to workspace" like everywhere else. The first cut used
its own second-class markup, which is exactly what it looked like.

The list view's sort headers now apply inside a mounted folder, and New
folder disables there with the reason ("this folder mirrors a directory on
disk — create subfolders in your file explorer") instead of offering a
dialog whose submit could only ever fail.
2026-08-14 17:06:34 +01:00
Reece 1004be8cb5 feat(folders): a mounted folder lists its directory straight off the disk
Opening a local folder now shows what the directory actually contains,
read fresh on every look — the directory is the source of truth, and
nothing is copied or ingested to produce the listing. The desktop build
reads it over the Tauri filesystem plugin (read-dir and stat join the
already-granted read-file permission); core reports the capability
absent, since a browser cannot see paths at all.

A listed file carries no stub and no storage row, so it offers none of
the stored-file actions — no select, move, rename, or delete. Its one
affordance is Open, which loads the bytes into the workbench: the only
moment anything leaves the disk, and only because the user asked to
work on it. Listing is capped at the 500 freshest files so a bulging
Downloads directory cannot drown the page; name search filters the
listing like anywhere else.
2026-08-14 16:50:30 +01:00
Reece ef76aa0dc3 fix(idb): a database missing a configured store self-heals with a version bump
Once a database's version matches the config, onupgradeneeded never fires
again — so a store added to the config under an already-opened version number
is missing forever, and every transaction naming it throws "object store was
not found" with no way out short of deleting the database. Chiefly a
dev-profile hazard (a browser that opened the schema mid-change), but the
failure is permanent wherever it happens.

On open, any configured store found absent now forces a one-version bump,
which replays the declarative store creation — that only adds what's missing
and touches no data. A database left ahead of the config this way rejects the
next ordinary open with VersionError; that error now recovers by reopening at
the database's own version. The probe happens only on that error, so the
normal path costs no extra open, which the connection-dedupe contract pins.
2026-08-14 16:44:04 +01:00
Reece d1945fe9b9 feat(folders): New folder offers all three kinds, including a directory mount
The chooser now always lists the three places a folder can live — a real
folder on this computer, this browser, the server — and an option this
install can't provide renders greyed with the reason as its tooltip, the
treatment the New folder button itself used to get. Showing the capability
disabled beats hiding it: the user learns it exists and what would unlock it.

Picking "a folder on this computer" swaps the name input for a directory
picker: the desktop build shadows the picker module with the Tauri dialog,
which can hand back a real path; a browser deliberately cannot reveal one
(the File System Access API deals in handles, not locations), so core
reports the capability absent and the option explains it needs the desktop
app. The mounted folder takes its directory's name, mounting twice hands
back the existing record, and mounts are flat — a directory's subdirectories
are the filesystem's business, not a hierarchy for the record to model.

Removing a mount removes the record and nothing else, so it skips the delete
dialog — its "what about the files?" question would be a scary lie — and the
row's menu says so: "Remove (files stay on disk)". With server-storage and
sign-in concerns now carried per-option, the New folder button itself only
stays disabled on tabs that don't show folders at all.
2026-08-14 16:33:35 +01:00
Reece 663763142a feat(folders): the New folder dialog offers where the folder should live
Creating at the root is the one place a folder's kind is genuinely a choice —
a subfolder inherits its parent's — so that is the one place the dialog asks:
on the server (synced to the account) or in this browser (this device only,
works offline). The chooser only appears when both are real options; when
server storage is off, unreachable, or the session is anonymous, the folder
can only be virtual and the dialog stays a plain name prompt.

The dialog itself stays dumb: it renders whatever kinds it is offered and
reports the pick with the name. Deciding what is offerable stays with the
view, next to the same signals that drive the New folder disabled reasons.
2026-08-14 16:23:10 +01:00
Reece b81255642c feat(folders): the file manager shows every kind with its own actions
A folder row's menu now matches what its kind can actually do. Server folders
keep the reachability gating; virtual folders never disable on it, being
browser-owned; a local folder hides rename, appearance, and delete outright —
its name, look, and lifetime belong to its directory on disk, and a disabled
item with an "offline" excuse would be the wrong explanation. The list view
names the kind (Browser folder / Local folder) where it showed "Folder".

"New folder" stays live on installs without server storage: creation there
makes a virtual folder, which is what lets a desktop user organise at all —
previously the control was disabled with advice to ask an admin for a server
feature the install deliberately lacks.

A cross-kind folder drop reads as a friendly message rather than a thrown
error, matching how the cycle guard already reports.
2026-08-14 16:16:31 +01:00
Reece 5c5297c944 feat(folders): FolderContext carries every kind and dispatches by it
The context now loads both systems of record — the server cache and the
browser-owned virtual store — into one list, and a server pull replaces only
the server rows: a pull says nothing about folders the server has never heard
of. Every mutation looks up the folder's kind and routes accordingly; virtual
mutations never touch the network, which is what makes them work offline and
on installs with no server storage.

A subtree is one kind throughout. A child is created as its parent's kind, and
a folder moves to the root or under a parent of its own kind, never across —
each kind has its own system of record, and a mixed chain would mean an
ancestry no single store can vouch for. At the root, creation defaults to
server when the install has server-backed storage and virtual otherwise, so
organising files never requires an account.

File membership follows the same ownership line: local files can move into a
virtual folder as a pure IndexedDB write — no upload, no server call — while
server files stay out of them, since their folder membership belongs to the
server and the next sync would silently snap them back. Moves into a local
folder are refused outright: its contents are whatever the directory on disk
contains, and putting a file there is a filesystem write, not a membership
change.

Local folders reject rename, recolour, move, and delete for now — their
records live with whichever feature mounts them, and nothing does yet.
2026-08-14 16:06:17 +01:00
Reece 84b3a51e4a feat(folders): folders come in kinds — server, virtual, local
Three independent features share the folder shape, and the model now says
which one a record is. `server` is a folder in app storage, synced down and
cached; `virtual` is an organisation-only folder owned by this browser's
IndexedDB, which is what lets folders exist offline and on installs with no
login or storage; `local` is a real directory on the machine, mounted
read-through with the filesystem as the source of truth. A record with no
kind reads as `server`, since every row that predates kinds is one.

Virtual folders get their own store and system of record. They cannot live in
the server folder cache — that store is wiped and rewritten from the server's
response on every sync, so a virtual row there would silently vanish — and
both write paths now refuse non-server rows loudly for the same reason. With
no server to be authoritative, the store enforces the hierarchy invariants
itself: no reparenting into a folder's own subtree, chain depth capped to
match the server's, and subtree deletes that report every removed id so the
caller can unlink the files that referenced them.
2026-08-14 16:00:22 +01:00
Reece 7eb213056b build(desktop): make the bundled backend variant selectable
desktop:jlink:jar hardcoded DISABLE_ADDITIONAL_FEATURES=true, so `task
desktop:dev` could only ever bundle the core backend — even though the release
matrix already ships a with-login variant that includes the proprietary
module. The flag is now a variable defaulting to core, overridable by env.

The task also records which variant it built and rebuilds when that changes:
its status check only tested that a JAR existed, so switching variant silently
reused the other one — the same staleness trap jlink:verify catches for the
bundled JRE.
2026-08-14 15:24:48 +01:00
Reece 8067437654 fix(processing-folders): the Downloads offer survives the backend's cold start
The offer asked the server about Downloads exactly once, on mount. On a
desktop install the window and the bundled backend start together and the UI
always wins that race, so the one attempt failed, the catch swallowed it, and
the offer never appeared. It now retries for a bounded while and stops early
on a definite answer — only "backend not up yet" resolves itself.

The offer is also no longer gated on whether policies are available. A
processing folder is its own surface: it runs on the policy engine, but the
builds where the portal's Policies rail makes sense are not the builds where
a Downloads folder exists — the desktop flag reads "confirmed SaaS", which
hid the offer precisely where it works. The wizard gates itself by asking the
server instead.
2026-08-14 15:24:48 +01:00
Reece 81a68bfe0d feat(processing-folders): work on installs with no accounts and no storage
A desktop install has no login and no file storage, and the route demanded
both: every endpoint required an authenticated principal, and a disk folder's
results were delivered into app storage. So the surface 401'd on exactly the
install it is most useful on.

The caller is now optional, mirroring PolicyAccessGuard's model — login
disabled means a null owner and the local operator owns everything. A
disk-backed folder writes its results to a "Stirling Processed" subdirectory
of the directory it watches: outside the source's non-recursive scan, recorded
in the ledger, and the user's own files are never written over. The
subdirectory name is fixed for now and expected to become a per-folder option.

The view also reports the directory a folder WATCHES, read from its source —
reading it off the output made a disk folder advertise its own results
subdirectory as its address, and grouped storage folders by the wrong id.
2026-08-14 15:24:30 +01:00
EthanHealy01 2483e9f37a Report editor-originated failures into the same queue (Review Flow PR 2) (#7296)
Review Flow PR 2 of 5. Editor tool failures now reach the same durable
queue as failures from folders, buckets and webhooks.

## What's added

**A report endpoint** — `POST /api/v1/file-run-events/reports`, open to
any authenticated user. Takes four fields: `operation`, `errorCode`,
`fileIds`, `detail`. No team, no actor, no filename: the first two come
from the session, the third is never a field. Refused with 400 above 200
file ids, and nothing is written when refused.

**Automatic reporting from every tool** — wired into `useToolOperation`,
so no per-tool work is needed. Client-side refusals (an unsupported
format that never reaches the server) are reported too. User
cancellations are not.

**Error codes parsed from Blob bodies as well as JSON** — a
download-typed tool call fails with a Blob, so `errorCodeOf` handles
both shapes.

**Source attribution for unattended runs** — `sourceId` is threaded from
`PolicyRunner` through `PolicyRun` to the recorded row and out to the
wire, so a folder, bucket or webhook failure names what fed it.
Previously it had none.

**Deleting a file closes its failures** — `FileContext.removeFiles`
notifies `POST /removed-files`, which transitions those incidents to
`FILE_REMOVED`. Terminal, so they leave every reviewer's queue. The rows
stay for audit.

**The queue can be emptied** — reads now default to open statuses only;
ask for a status explicitly to see closed rows.

## Behaviour changes

- **Editor failures dedup per person.** `RecordFailure.scopeRef()`
includes the actor for TOOL-origin rows, so two people hitting the same
failure on the same file are two incidents rather than one. Processor
rows are unaffected and their dedup key is byte-identical to before.
- **`UNKNOWN` offers only Dismiss.** Acknowledge is no longer offered on
it.
- **Background reports no longer raise a toast.** Both calls pass
`suppressErrorToast`, so a failed report is silent as intended;
previously a core build showed the user a "Not Found" toast on every
tool failure.

## What is stored

File ids only, never names. The request type has no filename field, and
a `fileNames` value handed to the client reporter is accepted and
ignored.

One caveat to review deliberately: the free-text `detail` is stored
**verbatim**. `RecordFailure` truncates it at 2000 characters and
nothing else; the redaction that used to strip name-shaped text was
reverted in `024899f3f6` because it made an unclassified failure
impossible to act on. A backend message that embeds a filename
(LibreOffice conversion errors, IO errors) will therefore persist that
text and show it to a team leader.

## How to test

Needs a proprietary or SaaS build with login enabled. `task dev:all`
gives you one.

1. **Report a failure from a tool.** Open a PDF, run **Remove Password**
on it with a wrong password. Nothing visible changes for you: reporting
is silent by design.
2. **See it recorded.** Go to `/processor/documents` and scroll to
**Failures** (dev builds only). A row appears titled "Password-protected
document", with `Hit by <your user>`. Press **Show raw JSON** to see
exactly what was stored.
3. **Confirm no filename is stored as data.** In that JSON, `fileId` is
an opaque uuid and there is no name field. Note the `detail` string may
contain a filename if the backend put one in its message, per the caveat
above.
4. **Confirm the request is capped.** In DevTools, POST to
`/api/v1/file-run-events/reports` with 201 entries in `fileIds`. It
returns 400 naming the limit, and no rows are added.
5. **Deleting a file clears its failure.** Back in the editor, delete
the file you just failed on. Refresh the failures list: its row is gone
from the default view. Filter by `FILE_REMOVED` to see it still exists.
6. **Two people, two incidents.** Have a colleague fail the same tool on
their own copy of the same file. Two rows, not one occurrence count.

## Migration

`source_id` is a new column and `FILE_REMOVED` a new status value. Both
are already in the SaaS migration ([Stirling-PDF-SaaS
#322](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/322));
self-hosted picks them up from `ddl-auto`.
2026-08-14 13:24:41 +00:00
James Brunton 6f2b829f72 Filter Pipelines page to only show what the user thinks as pipelines (#7495)
# Description of Changes
Currently, the Pipelines page shows all backend Policies, which was the
desired behaviour when we first designed this, but as it's come along,
it doesn't feel right anymore. This adds a filter so the Pipelines table
only shows things that have been defined by the user as a New Pipeline,
so not Policies etc.

## Before
<img width="1510" height="789" alt="image"
src="https://github.com/user-attachments/assets/5aebb065-3d42-4483-be3c-253fd8918d49"
/>

## After
<img width="1512" height="790" alt="image"
src="https://github.com/user-attachments/assets/2966a0de-ec9f-43e6-bb1d-c4aeb30dfbf8"
/>
2026-08-14 13:11:02 +00:00
James Brunton 588afb6306 Fix a11y violations in nightlies and improve a11y config (#7499)
# Description of Changes
This fixes the a11y violations that are currently failing in the
nightlies in dark mode. Now that we're down to 0 baseline, we can
require the a11y tests to pass in PRs before they merge, so I've changed
that, and I've also made it so that the nightly will report failures in
both light and dark mode instead of just light mode if that fails.

<img width="2560" height="838" alt="image"
src="https://github.com/user-attachments/assets/b0322182-f6ff-4dea-9a1c-5a6c9c9c5439"
/>
2026-08-14 14:02:16 +01:00
Anthony Stirling 0be10b2dff Cucumber concurrency validation plus fix (#7379)
# Description of Changes

cucumber tests to run multiple threads of commands at same time 

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-14 14:01:45 +01:00
EthanHealy01 55087313b7 Processor UI snags: fat CTAs, real Infrastructure tabs, one surface style (#7497)
Five unrelated snags in the processor (portal) UI, plus fixes they
turned up. No backend changes.

`84 files changed, +892 / −3364`

## Fat CTA buttons

- New `fat` prop on the SUI `Button`: 2.75rem tall, 1.25rem side
padding, 0.75rem corners, semibold. Composes with all four
variants/accents.
- Applied to the page-header CTA on Sources, Documents, Pipelines, Users
(both), Usage, Integrations, Infrastructure — 8 buttons, all in line
with a page title. Nothing else.
- `LandingActions` migrated onto the prop; `.landing-btn-primary` /
`.landing-btn-secondary` and their four `!important`s deleted. The
editor landing CTAs come down 4px with everything else.
- Infrastructure's header CTA is now primary; its "Create key" dropped
to secondary so they stop competing.

<!--IMG:buttons-->

## Documents empty state

- "Connect a source" opened the Sources *page*; it now opens the
`SourceModal` connect flow in place, no route change.
- No extra cache wiring: `SourceModal` already invalidates the sources
query.

<!--IMG:documents-->

## Infrastructure tabs

- Only API Keys and Audit Logs hit real endpoints. Deployments,
Security, Models and Storage read mock-only `/v1/infrastructure/*` that
no backend serves.
- Those four are now disabled: native `disabled`, out of the keyboard
tab order, `aria-disabled`, with the view refusing non-enabled keys as a
second guard.
- Real tabs moved leftmost; API Keys is the default; `?tab=` deep links
validated against the enabled set (the home flow's audit link still
works).
- Deleted: 4 tab components, their fetch fns and ~25 dead types, MSW
handlers, fixtures (908 → 253 lines), dead CSS, unused formatters, 240
lines of `en-US` strings. Most of the −3364.
- Page subtitle no longer advertises the disabled tabs.

<!--IMG:infrastructure-->

## Surface consolidation

- New `Surface` primitive (`sui-surface`): fill, hairline, radius, no
shadow. Kept separate from `sui-nav-surface` so nav chrome can diverge
later.
- `Card` composes it and no longer draws its own shadow — this changes
editor Card usages too, by design.
- SUI primitives that are surfaces adopt it: `MetricCard`,
`MetricStrip`, `NodeCard`, `Table`, `Collapsible`, `CodeBlock`.
- The portal gets its own `.portal-surface` with the same three
declarations, applied to 19 elements. A `sui-` class belongs to the
component that emits it, so feature markup doesn't wear one.
- `raised` variant = one subtle shadow for a surface in front of another
surface (the flow diagram's tiles). Same fill as its parent, so nesting
never shifts a region's colour. Dark has its own value.
- Floating chrome (modals, drawers, dropdowns, assistant, sidebar) keeps
its elevation; sunken wells stay sunken.

<!--IMG:surfaces-->

## Sources list

- Centred "No sources connected yet" empty state removed — it duplicated
the header CTA and pushed the table down the page. The header's "Connect
source" is the single way in.

## Drive-by fixes

- The connect flow rendered unstyled outside the Sources view:
`.portal-conn-picker__*` / `.portal-sources__connection-*` lived in
`views/Sources.css`, which none of the five components rendering them
imported. Moved to `components/sources/connections.css`.
- Three inert custom properties (`--surface-input`, `--color-border-2`,
`--text-default`) are defined nowhere in the codebase —
`.portal-conn-picker__card` had no fill at all as a result.
- Dead CSS removed from `Sources.css` (grep-verified unused): old
expanded-row panel + its keyframes, type-card block.

## Testing

- `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
+ stylelint), format, 238 files / 2063 tests.
- `frontend:typecheck:all` across all 9 tsconfigs.
- `frontend:storybook:a11y:changed` — 119 stories, light and dark, zero
violations, no regressions vs baseline.
- New tests: `Infrastructure.test.tsx` (tab order, default, disabled
behaviour, deep-link filtering) and a Documents test that the
empty-state CTA opens the modal without navigating.
- Merged `origin/main` (#7438 replaced `PipelineHeader` with the new
Create/Edit headers); full suite green at 240 files / 2072 tests after
the merge.
2026-08-14 10:55:15 +00:00
James Brunton 4b26797ad8 Redesign New/Edit Pipeline top bars (#7438)
# Description of Changes
Replace the dev-UI top-bar in the New Pipeline and Edit Pipeline pages
with a redesigned layout appropriate for users. I've got a big list of
extra tweaks I'd like to do to the rest of the page including graph
tweaks etc. but this is the only thing on the New/Edit Pipelines pages
that is blocking for the release.

## Before
### New Pipeline
<img width="1510" height="788" alt="image"
src="https://github.com/user-attachments/assets/23f8a3a6-a8db-4d81-8c23-fd7a78864f2d"
/>

### Edit Pipeline
<img width="1512" height="791" alt="image"
src="https://github.com/user-attachments/assets/4c0a0c3c-81d8-4d15-91c7-6aa4f3de69c7"
/>

## After
### New Pipeline
<img width="757" height="395" alt="image"
src="https://github.com/user-attachments/assets/8a287cce-336c-4624-b4e9-a6a98eabb01d"
/>

### Edit Pipeline
<img width="1512" height="789" alt="image"
src="https://github.com/user-attachments/assets/9e096dbb-0a95-4a03-8d32-d5d71d2b57c5"
/>
2026-08-14 07:44:41 +01:00
Anthony Stirlinganddagecko 929ded41a8 Harden actions secret handling (#7435)
# Description of Changes

## Harden GitHub Actions secret handling

Moves secrets behind deployment environments, removes the GitHub App
token from
workflows that only comment and label, and moves PR preview images to
GHCR so the
preview path needs no registry credential.

Builds on #6005 by @dagecko — that commit is preserved with original
authorship,
rebased onto current main.

### Extract secrets from `run:` blocks (@dagecko, #6005 rebased)

- Secrets referenced in shell bodies moved to step-level `env:` so
values never
  reach a rendered command line
- Two `workflow_dispatch` inputs moved out of shell interpolation
  (`multiOSReleases`, `push-docker-base`)
- Dropped the hunks main has since solved — `setup-uv`, `reviewdog`,
`build-push-action` and `github-script` are all pinned newer on main now
- Fixed a bug in the original: `PR-Demo-cleanup.yml` uses a **quoted**
`<< 'ENDSSH'`
heredoc, so rewriting `${{ secrets.DOCKER_HUB_USERNAME }}` to
`${DOCKER_HUB_USERNAME}`
would have sent the literal string to the VPS and expanded to empty,
silently
  orphaning preview images behind `|| true`

### Gate secret-bearing jobs behind environments

- `environment:` added to 15 jobs across 10 workflows, mapping to
`release-signing`,
  `docker-publish`, `package-publish`, `pr-preview` and `bot-identity`
- Environment branch/tag policies are enforced by GitHub before the job
starts, so
  editing the workflow file cannot bypass them
- Four jobs deliberately **not** gated — `tauri-build`,
`frontend-backend-licenses-update`,
`swagger` and `push-docker-base` would fail their own triggers under the
current
  policies and need restructuring first
- Removed the `testMain` trigger from `push-docker` — the branch doesn't
exist and
  isn't in the environment's policy

### Publish PR previews to GHCR instead of Docker Hub

- Preview images now go to `ghcr.io/stirling-tools/stirling-pdf-test`,
authenticated
  with `GITHUB_TOKEN` rather than `DOCKER_HUB_API`
- Docker Hub personal access tokens cannot be scoped to a single
repository, so the
preview path was holding the same credential that publishes `s-pdf` and
`stirling-pdf`
- `DOCKER_HUB_API` no longer appears in any PR-reachable workflow
- Login now precedes every `docker manifest inspect` —
`deploy-on-v2-commit` had them
  reversed, which only worked because the Docker Hub repo was public

### Use `GITHUB_TOKEN` for comment and label workflows

- Seven workflows no longer mint a GitHub App token; only
`sync_files_v2`,
`sync-portal-docs` and `frontend-backend-licenses-update` still do, so
unattended
  auto-merge is unaffected
- `permissions:` blocks derived per job from the API calls each actually
makes —
these were previously inert, since an App installation token ignores
them, and one
  job had no block at all
- Comment-threading matchers updated to `github-actions[bot]` so
workflows still edit
  their own previous comment instead of posting duplicates
- Removed the App token from the `refs/pull/N/merge` checkout in
`PR-Demo-Comment-with-react` and set `persist-credentials: false` — it
was written
  into `.git/config` of an untrusted tree that the same job then builds
- Fixed a script injection in `check_toml.yml`: a fork-controlled branch
name was
interpolated into `actions/github-script` JS source, with validation
running after
the injected code had already executed. Values now come from
`process.env` and are
  validated before use.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: dagecko <cnyhuis@vigilantnow.com>
2026-08-13 23:03:47 +01:00
Reece 67dd095476 feat(processing-folders): move the Downloads offer into the file sidebar
The offer is a way of getting files into the workspace, so it belongs with
"Open from computer" and "My Files" rather than in the tool panel, where it sat
above a list of PDF tools it has nothing to do with.

It enters through a slot, following the getting-started checklist's pattern:
core renders nothing, since it has no policy engine to run a folder, and the
proprietary build shadows the file with the real offer. That keeps the policy
components out of the core bundle rather than importing them and gating on a
flag at the call site.

Hidden on the collapsed rail — the offer is a sentence, and there is nothing
sensible to reduce it to at icon size.
2026-08-13 22:50:08 +01:00
Reece f208579be7 Merge origin/main into processing-folders
Main reshaped the policy model underneath this branch:

- A policy's trigger moved onto each input. `Policy` now takes a list of
  `PipelineInput` (source + its own optional trigger) in place of a single
  trigger and a list of source ids, so the compose route builds one input
  carrying the folder-watch trigger for a disk folder and none for a
  storage-backed one.
- A run delivers to every destination rather than one. The engine's delivery
  loop is main's; the inputs still travel with each delivery, which is what
  lets the storage sink anchor ownership and placement on the file the run
  came from.
- `ResolvedInput` carries the file's identity. The storage-folder source now
  builds its inputs through `forFile` like the disk source does.
- `PolicyValidator` gained an asset store and a tool-chain validator.

The folder-input source keeps track mode, which main had not seen: a processed
file in a tracked directory is recorded and left where the user put it, rather
than consumed.
2026-08-13 22:47:30 +01:00
Reece 2e4ce484dc feat(processing-folders): the Downloads offer opens its results in the workbench
Processing happened server-side, so a finished sweep left the user looking at
an unchanged screen. Each run's results are now pulled down and opened as
active files the moment that run completes, rather than at the end of the
batch — a slow or stuck straggler would otherwise hold back everything that had
already succeeded, and a timeout threw the lot away.

Results download from the storage endpoint, not the job one. ResultFile.fileId
carries a job-file UUID for an ad-hoc run but a stored file's own id for a
storage-backed one; the two share a field name and not an id space, and the job
endpoint rejects the latter outright.

The offer is a one-shot: it stands the folder down once the sweep is done
rather than leaving a standing watch, and says so.
2026-08-13 22:40:03 +01:00
Reece 82183ad4f8 feat(processing-folders): disk folders deliver their results into app storage
A disk-backed processing folder wrote its results to a "Stirling Processed"
subdirectory of the watched directory, so the output of a run was a file on the
user's filesystem and nothing the app could open. Results now land in app
storage as ordinary Stirling files, in a storage folder named after the watched
directory and created with the pair.

The storage sink previously refused any run whose input was not itself a stored
file, having nothing to anchor ownership to. A disk-fed run has no such input,
so it now takes its owner from the folder the results are placed in, and a
policy delivering there must name one. In-place replacement is skipped for
those runs: there is no stored row to replace.

The watched directory still holds only the user's own files and is never
written to.
2026-08-13 22:39:54 +01:00