f30d40446a2eef3522064930d2ee6d8ef0ddd3d2
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f30d40446a |
a ledger of runs, and what is still outstanding
Two things the panel could not answer. A run's state lives in a Map, so a restart erased it — and with it any record that a file FAILED. That is the gap the manifests structurally cannot cover: a derived folder records the size, mtime and variants it was built from, so provenance is already precise, but a file that did not build has no folder and leaves no trace. Once the watcher is running, a failure at three in the morning would never be discovered. So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than a table because db/schema.ts is emphatic that the connection is the only thing this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside derived/ rather than inside it, so wiping derived does not erase the history of what happened before, which is the comparison it exists for. Written in a finally, so a run that died still records that it did. And the line above the button now says what is TRUE rather than what a run once did: reconcile() walks originals, reads each manifest, and reports current, missing and changed. It uses upToDate — the same predicate the sync uses to skip a file — because two definitions of "current" is how a panel comes to say everything is in step while the next run rebuilds half of it. Missing and changed are separated because only the second means somebody edited something. The ledger is not polled. It reads the whole tree, so it loads on arrival and again when a run stops — the only two moments its answer can change. Sync also takes an optional folder now, so a subtree can be re-synced on its own rather than the whole library. The scope is recorded on each run, because "3 built" means different things for one folder and for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b9f543c2b6 |
video rungs stop upscaling, which is why the one video failed
`web-1080` was asked for whenever the source was at least 720 tall, so a 720p video was upscaled to 1080p — the exact thing the image ladder is careful never to do. The output was a 14.6 MB file that looks worse than the 7.8 MB one beside it, and it cost 84 seconds of a single core on an idle box. Under a running sync that overran av-club's 255 s idle timeout and homepage_video.mp4 failed outright, which is the only reason the waste was noticed. A rung is now made only when the source is at least that tall. `web-480` becomes a floor rather than a rung, so a source smaller than 720 still gets something instead of nothing or an enlargement. An unreadable height makes everything: a probe that told us nothing is not evidence the video is small. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6db5b1e85d |
decode once, encode seven times, and one file at a time
A HEIC was being decoded once per rung. av-club is stateless per request, so
seven rungs asked for from the original are seven ImageMagick conversions of it
— measured on fofos at 8.5 s each, against 1.1 s for the resize and encode that
follow. Six of the seven produced a pixel-identical intermediate and threw it
away: about 67 seconds per photograph where 16 would do, paid again on every
future upload.
So when av-club says a format needs converting, convert it ONCE into the staging
directory and cut every rung from that. `needsConversion` is asked rather than
guessed, because which formats are expensive is av-club's fact and a list of
them kept here would go on paying after its build stopped needing it. JPEG and
PNG are untouched — writing a full-resolution intermediate to save six cheap
reads would cost more than it saved.
Concurrency drops to one. Two was chosen to match av-club and is the right
number on a machine with room; fofos has 1.87 GiB of RAM, and two concurrent
12-megapixel decodes left 520 MiB free with 648 MB already paged out. The work
is CPU-bound on a box already at 99%, so the second worker was not buying
throughput — it was buying a second resident bitmap.
And the sharp calls now name their output format properly. `output` takes
`{ format, options }`; passing `{ webp: { … } }` was silently ignored, so
everything built before this encoded at sharp's default quality rather than the
82 asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e87e19ee46 |
derived/ is built from originals/, and you can watch it happen
The mapping: derived/ mirrors originals/ directory for directory, and where originals holds a FILE, derived holds a DIRECTORY of the same name — extension included — with the renditions inside it. Keeping the extension is what makes it total: a.jpg and a.png are different originals and must not share a folder. The inverse is pure string work, so the filesystem is the record and there is no second copy of the truth. Images get a WebP ladder at 320/640/960/1280/1920/2560 plus full.webp, and nothing is ever upscaled — a rung wider than the original would be a soft file that srcset then confidently prefers over the sharp one. full.webp always exists because for a HEIC it is the only browser-readable copy of the picture there is. Video gets av-club's web-1080 and web-720 presets by name, plus a poster. Images are named by width because srcset descriptors are widths; video by height because that is av-club's vocabulary and nobody misreads 1080.mp4. The ladder is fixed here and the SITE chooses from it. This never reads website/src/media/slots.ts — a plugin that knows a website's layout is a plugin for one website, and that boundary is the difference between a template and something fofos-shaped. Each derived folder carries a manifest naming the size and mtime it was built from, so the sync is idempotent and resumable: a run that died halfway is finished by running it again. That is what makes an in-memory job acceptable rather than merely convenient. It builds into <name>.partial and renames, so an interruption can never leave a folder that looks finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c38d269faf |
Media moves to the foot, and each section asks its own question
TWO REPORTS, AND THE SECOND ONE WAS A DESIGN MISTAKE RATHER THAN A SETTING.
Media is pinned now, beside Administration. it sat at the end of the scrolling list,
which is the bottom of the LIST and not the bottom of the panel. it belongs in the
foot for the same reason Administration does: both are about this machine, while
everything above them is about the site's pages.
and it was LOCKED BEHIND A CONNECTION IT NEVER USES. `ready` was one global flag —
"can anything be opened" — and Media is a folder on this disk that never speaks to
fofos. so sections now declare what they need:
'connection' the site's pages, which read fofos
'root' Media, which needs a folder and nothing else
'nothing' Administration, because locking the door you fix things through is
locking somebody out entirely
`sectionOpen` is one function, used by the nav to decide whether to offer a link and
by the detail panel to decide what to render — so the two cannot disagree about a
section. that was already the rule with one flag; it matters more now that each
section asks a different question.
`/status` gained `rootSet`, a BOOLEAN, and stays not-owner-only. the nav needs the
answer for every account that can see the section; the path itself stays behind
`/_config`, which is owner-only. whether a root has been chosen reveals nothing
about the machine — where it is would.
the empty-state line follows too: "set a root location below to begin" before there
is one, "connect fofos to open the rest" after — rather than one sentence naming a
step that is not the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
944d3d9c5b |
a Media section, browsing the site's originals in place
a new section above Administration. it embeds THE PLATFORM'S OWN file browser rather than drawing a file list — upload, delete, rename, move, thumbnails, the context menu and every task hanging off it already exist and are maintained in one place. a plugin's own would be a second to keep in step, arriving without any of that. `basePath` IS A JAIL, not a starting folder: `useFileBrowserApp` resets currentPath whenever it leaves, so pointing it at `<root>/media/originals` means the breadcrumb stops there and `derived/` is not reachable by navigating. that is what was asked for — people work in originals, derived is made for them. IT IS A GUARDRAIL AND NOT A PERMISSION BOUNDARY, and the panel says so in its own docstring because the difference will matter to whoever reads this next. the file-browser API is unchanged and still addresses the whole home: anyone with file access reaches derived/ through the Files screen. this prevents an accident, not a determined person. confining it properly means scoping that API per user, which is a platform change and a much larger one. the panel also says, once, that derived/ exists and is made from these — otherwise somebody who knows about it would think it had been lost. `rootRelative` is new on the settings API because only the SERVER knows what home is. the file browser addresses everything relative to a root, and a browser deriving that prefix from the absolute path would be guessing. null when the root sits outside home — a legitimate setting the browser cannot address, which the panel explains rather than rendering an empty folder. NOTHING HERE IS FOFOS-SPECIFIC, which matters now that this plugin is the template for other site admins: the root is a setting, media/originals is the layout this plugin creates, and both are read from the API. another site's plugin changes the label. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b9f8ce4ee4 |
originals/ and derived/, because the names are the rule
media/
originals/ nothing regenerates these
derived/ rebuildable from originals/, safe to wipe
a pure rename: nothing had been created yet — checked the real root on fofos, only
`website/` was there — so there is no directory to move and no file to lose. that
is the last moment this was free, which is why it happened now.
`user/` and `treated/` both named the wrong thing. `user` said who put the file
there, which stops distinguishing anything the moment a job writes one, and reads
like it should hold a folder per person. `treated` said what had happened to it,
which stops being informative as soon as there is more than one kind of treatment.
these two say which one you can afford to lose. that is the only property that
matters about the pair, and it is the one that decides every rule anybody writes
about them later — what a cleanup job may delete, what a backup must never skip,
what a conversion may overwrite without asking. a name that carries it is a rule
that cannot be forgotten, which is more than the comment above it can promise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
86e18fc7bf |
media/ gets its shape: user/ and treated/
<root>/
website/ the checkout
media/
user/ what people upload, untouched
treated/ what comes back out of conversion
ONE list drives both provisioning and reporting. two lists is how a status comes to
say "ready" about a structure missing a folder the creator quietly stopped making.
THE BUTTON REPAIRS AS READILY AS IT CREATES. `mkdir -p` on every path, every time,
so the same press fixes a `media/` somebody made by hand with nothing in it and a
structure that lost a folder — and pressing it on a complete one changes nothing
and fails at nothing. that is what makes it safe to offer beside a status a few
seconds stale; the version that refuses when the parent exists is the version
somebody has to finish in a terminal.
three states, not two. `media/` with one child missing looked identical to a
finished one, and that is the state most worth seeing — so the row says complete,
partly there, or absent, and lists both children whether or not they exist. the
missing ones are the reason to show the list at all.
RECORDED BEFORE ANY RULE DEPENDS ON IT: the two halves are not symmetric. `user/`
is original — nothing regenerates it, and losing it means asking somebody for a
file they may no longer have. `treated/` is derived — every file in it can be made
again from the one it came from, so it is the half that is safe to delete, safe to
rebuild, and safe for a job to overwrite without asking. nothing enforces that yet;
it is the fact that should decide every rule written about them later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
003613872d |
provision <root>/media, beside the checkout
where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.
BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:
- it must SURVIVE the checkout. deleting `website` and cloning it again is a
normal thing to do when something is wrong with it, and it must never be the
thing that destroys the client's photographs.
- it must never be TRACKED. media in git is a repository nobody can clone a year
from now, and a .gitignore is a promise somebody eventually breaks with
`git add -f`.
both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.
NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.
`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.
no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bab47526f7 |
clone and update both end in bun install
a checkout with no node_modules does not run, so cloning without installing left something that looked finished and was not. run after an update too, and UNCONDITIONALLY — including when the pull changed nothing. skipping it on "already up to date" is the shortcut that is right until the one time it is not, and an install on an unchanged tree is close to free. `process.execPath`, NEVER the string "bun". verified on fofos today: PM2 started officer with PATH=/usr/local/sbin:…:/snap/bin and bun lives in ~/.bun/bin, so looking it up by name fails inside the process and works perfectly when you run the same command by hand — the worst version of this bug, because it cannot be reproduced the obvious way. execPath is the bun already running us. bun's directory is also prepended to PATH for the child, which execPath alone does not cover: a postinstall script calling `bun` would not find it, and that reads as a broken package rather than a missing PATH entry. the two commands report as ONE, each prefixed with the line that produced it, so the panel reads like the terminal somebody would otherwise have opened. it stops at the first failure — installing into a clone that did not happen produces a second, more confusing error about a directory that is not there — and a clone that worked followed by an install that did not is legible as exactly that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df394e29a4 |
the website checkout under the root: clone it, update it, say which
`<root>/website` is now checked, and the block says which of three things it is: not there, there but not a checkout, or a checkout with its branch and its head commit. the middle one is the state worth being loud about — cloning will refuse, and the reason is something already sitting in the way. SSH, hardcoded. the repo is private, so https asks for a username with nobody at a terminal to answer, and the SSH remote is already authenticated by the owner's key. hardcoded because this plugin administers exactly one website: a field for it would be a setting nobody changes and one more thing that can point somewhere wrong. when the checkout's origin is NOT that URL, the block says so — shown always it is noise, shown on a mismatch it answers why an update pulled something unexpected. NOTHING HERE MAY HANG. these run inside an HTTP request, so git gets GIT_TERMINAL_PROMPT=0 and ssh gets BatchMode=yes. without them a key that lost access does not fail, it BLOCKS forever waiting for a password at a terminal that does not exist, and the panel spins until somebody restarts the server. there is a five-minute kill on top for everything else that can wedge. update is `--ff-only`, never a merge. a merge commit made by a button in a checkout nobody is watching is a thing somebody finds days later; refusing is recoverable and legible. it also refuses on a dirty tree rather than failing halfway — and dirty is `--untracked-files=no`, because a build artefact or an uncommitted .env is not a reason to make Update permanently unavailable. clone takes FULL history, not --depth 1. this is a working checkout somebody will pull into and read the log of, and shallow turns both into a support question. it sits inside Root location rather than in a block of its own: the path and the thing at the path are one question, and split apart somebody could change the root in one place and read a status about the old one in the other, both looking equally current. hidden while editing, because it describes what is SAVED. git's own output is shown verbatim on failure. "Permission denied (publickey)" says exactly what is wrong; anything written in its place would be a guess at which of a dozen causes it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df52045eaa |
the path gets the row, the actions get their own
Root location is two states now. reading, it is a path and one Edit. editing, it is a field and the things you can do to it. the actions moved to the block's BOTTOM row, off the value's line. an absolute path is long, and it was being truncated to make room for buttons that are not needed most of the time — the thing somebody opened this to read was the thing squeezed. shown, it wraps with `break-all` rather than truncating, because a path is read from both ends: the machine at the front, the folder at the back, and hiding either is worse than two lines. Edit is a mode and not an always-live field. this is looked at far more often than it is changed, and a live input invites a stray keystroke into a path everything else will depend on. Cancel restores what is stored, so leaving the mode cannot leave half a path on screen looking like the setting. "back to the default" is gone, as asked — and `clearRootPath` and its DELETE route with it, since that button was the only caller. an unreferenced endpoint is the same rot as an unreferenced component, and this one was a second, quieter Save that wrote a value nobody chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d5f2d8f370 |
Root location: an absolute path, with a picker that can make folders
defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a tilde means "whoever is asking", and the people asking will not all be the same person: this plugin is grantable, and the site is in one place regardless of who is looking at it. stored as `~/fofos` it would give every user a different answer to a question that has one. THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on every read, so it follows the machine. writing it in at install freezes whichever home existed on install day, and a restore onto a different machine then points at a directory that is not there while looking deliberately chosen. a saved path is a decision and is stored exactly as given; only its absence is derived. the form says which of the two it is showing, because "not set" reading identically to "set to the default" is how somebody comes to believe they configured something they did not. THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes of this plugin's own. they already refuse to leave home, and they already tell a missing directory from a forbidden one, which is a distinction that cost somebody an afternoon and would have been re-guessed here. shipping a second listing and mkdir would be a second filesystem surface to audit for the sake of one form. that API is an execution capability and never grantable, so for anybody but the owner it 403s — said in a sentence rather than shown as an empty tree, because an empty listing is data and must never be how a refusal looks. it also browses the owner's home and nothing above it; a path outside home is still reachable by typing, since the picker fills the field and the FIELD is what saves. one value on screen, one Save, and picking a folder commits nothing by itself. "Use this folder" takes the one that is OPEN rather than one highlighted in the list. that makes looking inside and choosing one gesture, and it is the only way to choose a folder with nothing in it — which a freshly made one always is. a SECOND table, not a column on fofos_admin_connection, whose url and token are both notNull: a root path could not be saved there without also having an HTTP connection, and that connection is being replaced rather than extended. when it goes, its table is a clean drop instead of surgery on columns that outlived it. the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S work, and this row holds a path somebody can retype. needs `bun db:push`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b87956e44 |
Everything needed to be a plugin
manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.
The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.
db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.
The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:
registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
multipart across the whole protected router, and Hono then caches the parsed
FormData — so asking for the bytes afterwards re-serialises them under a NEW
boundary while the ORIGINAL content-type header is what gets forwarded. Header
and body disagree and every upload fails with "Unexpected end of form".
Narrow to the one route: everything else is JSON that is fine to parse.
The catch-all is LAST in the file. Hono runs matching handlers in registration
order, so /* above /_config would swallow it and the settings form would
forward itself to fofos, which has no such endpoint — a 404 from a plugin that
is working perfectly.
Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.
Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.
Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.
Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.
NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|