Compare commits
343
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
007cc9a1f2 | ||
|
|
8e2670ff1d | ||
|
|
6b8cf848ff | ||
|
|
f56b9de0d6 | ||
|
|
c4cd42eb29 | ||
|
|
21bc460892 | ||
|
|
31151e7aa6 | ||
|
|
2b720cb2c2 | ||
|
|
c4dba8cb60 | ||
|
|
7d2b8b644d | ||
|
|
ffb4c3eedd | ||
|
|
538a623bb8 | ||
|
|
01cecdad60 | ||
|
|
fc474d3061 | ||
|
|
ea4b8a1e5f | ||
|
|
d1a41d6534 | ||
|
|
de0590c25b | ||
|
|
1072e17193 | ||
|
|
efe01e3019 | ||
|
|
ab79470356 | ||
|
|
a35df2afb2 | ||
|
|
141e470aee | ||
|
|
92fec50fcb | ||
|
|
4d2ee284ff | ||
|
|
a3fced25c4 | ||
|
|
27a8c29fe4 | ||
|
|
4564d6eb60 | ||
|
|
941259ce4f | ||
|
|
ed2a343676 | ||
|
|
02d44b93a4 | ||
|
|
22a9d58d5c | ||
|
|
f16aa87d8b | ||
|
|
7a651aee77 | ||
|
|
be56c0c7cd | ||
|
|
9bf31eb53c | ||
|
|
95dd9740da | ||
|
|
92841efac9 | ||
|
|
f07a1c8cc7 | ||
|
|
5a4fcd5a24 | ||
|
|
0096392cde | ||
|
|
b5c91a81c5 | ||
|
|
1b2a2d0a78 | ||
|
|
f4e983693e | ||
|
|
ee1cd006af | ||
|
|
70acccfe0c | ||
|
|
47db0383c7 | ||
|
|
33e8e70e06 | ||
|
|
e201bceafb | ||
|
|
d4b26be706 | ||
|
|
0fe02c84af | ||
|
|
27bd2ac768 | ||
|
|
e87323f71a | ||
|
|
20b270ef07 | ||
|
|
3e10c43bd5 | ||
|
|
6041379e98 | ||
|
|
facf1a5f9e | ||
|
|
af41144689 | ||
|
|
2c0e23d4fb | ||
|
|
68f4fe61fa | ||
|
|
58dbfc8313 | ||
|
|
d6d102111e | ||
|
|
db96f53bc2 | ||
|
|
03601db69c | ||
|
|
ddabf46bf3 | ||
|
|
887f702c05 | ||
|
|
9dfa83e607 | ||
|
|
420cd07507 | ||
|
|
fdf10f02f7 | ||
|
|
236572107b | ||
|
|
879edbdfb0 | ||
|
|
61c546ab79 | ||
|
|
fdd53b0dd9 | ||
|
|
a321babb28 | ||
|
|
955837b5bb | ||
|
|
a9dd264a72 | ||
|
|
46ae8051d4 | ||
|
|
7e50134200 | ||
|
|
dd6d0a8e12 | ||
|
|
5ee09a3fb6 | ||
|
|
0a242e15ba | ||
|
|
c6a92735d3 | ||
|
|
cb2dcfa4cc | ||
|
|
de633f8697 | ||
|
|
16d02ffee1 | ||
|
|
40a03d987f | ||
|
|
2d5e9835f5 | ||
|
|
478ddb13e9 | ||
|
|
00baa83a2c | ||
|
|
a3a8516e66 | ||
|
|
12a9605b01 | ||
|
|
e29a4ee380 | ||
|
|
247291eeb9 | ||
|
|
121ddb9964 | ||
|
|
6d673a1321 | ||
|
|
50e96e0d77 | ||
|
|
c427cb5b61 | ||
|
|
d74e07ef08 | ||
|
|
e0d80fd3ae | ||
|
|
a10757ba7c | ||
|
|
e35ff5c7ad | ||
|
|
9ddd23c388 | ||
|
|
c813eef6cc | ||
|
|
5dec221a68 | ||
|
|
e861eb55b0 | ||
|
|
2646842c32 | ||
|
|
b6d6de7e53 | ||
|
|
75d5a795a2 | ||
|
|
1600a57b66 | ||
|
|
2a0f688e10 | ||
|
|
24f3b4bdef | ||
|
|
3adc88963b | ||
|
|
6d06acc111 | ||
|
|
ba64bcd2e6 | ||
|
|
27d85f934c | ||
|
|
54f9f24726 | ||
|
|
7fc25ae53f | ||
|
|
8770f98dcd | ||
|
|
6572fa90f0 | ||
|
|
99149443b7 | ||
|
|
7370a422d1 | ||
|
|
0fc43ef6d0 | ||
|
|
5c6aea4ffc | ||
|
|
c38f02afa3 | ||
|
|
a6509f6456 | ||
|
|
9cd9d10d50 | ||
|
|
6cd99ddbc3 | ||
|
|
e15ab2303a | ||
|
|
4ca4becaa8 | ||
|
|
0f90003d15 | ||
|
|
db2382092e | ||
|
|
6e092c32ea | ||
|
|
1ff271bb5e | ||
|
|
fbdfd3fb89 | ||
|
|
6490dbc368 | ||
|
|
d7a4a18ce8 | ||
|
|
d6a3bea7c9 | ||
|
|
0457bf9ff3 | ||
|
|
a6b4bc4170 | ||
|
|
ad0fd12262 | ||
|
|
2e69b15de6 | ||
|
|
198c6c916b | ||
|
|
8148023a1e | ||
|
|
3565cc6ac6 | ||
|
|
07241440c9 | ||
|
|
f2f3ca12ba | ||
|
|
452029a6d3 | ||
|
|
084acecd22 | ||
|
|
7fd4d6c98e | ||
|
|
736a45e1ff | ||
|
|
a36fa356ae | ||
|
|
4466ac93c3 | ||
|
|
68a0a8e326 | ||
|
|
eba0e86758 | ||
|
|
1c435a88ae | ||
|
|
1c8e943667 | ||
|
|
04c1a6bfc9 | ||
|
|
1e268d553d | ||
|
|
d0904b2c84 | ||
|
|
7899539d4b | ||
|
|
fa32c82e0d | ||
|
|
44ee444cac | ||
|
|
06d5c64de3 | ||
|
|
1ae986bb31 | ||
|
|
9d0c6eba31 | ||
|
|
c7b767aa04 | ||
|
|
3e07dad69c | ||
|
|
c8fb89122e | ||
|
|
55e56ea652 | ||
|
|
ac890fd996 | ||
|
|
24d46a815a | ||
|
|
500c2f2179 | ||
|
|
26039e52b1 | ||
|
|
f4ab274db9 | ||
|
|
43197745f1 | ||
|
|
17dc9719e9 | ||
|
|
0f58331654 | ||
|
|
589c8cdf6e | ||
|
|
79186b291d | ||
|
|
ff647fba0a | ||
|
|
2f8a2b60d5 | ||
|
|
454a0f4244 | ||
|
|
a48a7ff696 | ||
|
|
bde59eb74e | ||
|
|
0b85d42197 | ||
|
|
2e846933e9 | ||
|
|
5e032898f8 | ||
|
|
91b9bec926 | ||
|
|
b978e09327 | ||
|
|
7f8455f1af | ||
|
|
f6b9ddc877 | ||
|
|
44f5246e38 | ||
|
|
9b0376ec39 | ||
|
|
66234cd3e0 | ||
|
|
ad8ed88519 | ||
|
|
7ce5af88aa | ||
|
|
6bfc1d38f7 | ||
|
|
c10dae3e6e | ||
|
|
41fffbebe9 | ||
|
|
c3c62828e3 | ||
|
|
926034750f | ||
|
|
6d8709c05a | ||
|
|
2765e241d3 | ||
|
|
36e18f935d | ||
|
|
0ddcdcbdbf | ||
|
|
99a78f3c7f | ||
|
|
845699e697 | ||
|
|
fe000541b7 | ||
|
|
dfbf9be61c | ||
|
|
be29ae7aa7 | ||
|
|
e3e790fd2b | ||
|
|
2b0b671895 | ||
|
|
2ec3f11cdd | ||
|
|
5ce1a2928e | ||
|
|
339d31f05c | ||
|
|
a444280cb4 | ||
|
|
c0788d15c0 | ||
|
|
7704867e05 | ||
|
|
04a4ab33dd | ||
|
|
b640ec486d | ||
|
|
0bd8c6c16a | ||
|
|
6ac9187c52 | ||
|
|
dcd11a3b17 | ||
|
|
3dd37d053b | ||
|
|
64894ad96e | ||
|
|
0a8fe1c2c8 | ||
|
|
2137d06498 | ||
|
|
ca7d3c657b | ||
|
|
b22e9d86c0 | ||
|
|
2095b6a717 | ||
|
|
4ea25d76c8 | ||
|
|
b94a5e90b8 | ||
|
|
510fa3f524 | ||
|
|
b5d9f591ee | ||
|
|
e6f1388aa6 | ||
|
|
dee0b89bd7 | ||
|
|
aa35428dde | ||
|
|
854f957058 | ||
|
|
edeb434233 | ||
|
|
8b4a5b3b0b | ||
|
|
730aeab6c5 | ||
|
|
2651c45733 | ||
|
|
91bc4ac74f | ||
|
|
a51a969cd0 | ||
|
|
5c3ffa7a06 | ||
|
|
1155449e4b | ||
|
|
a2ddf258b6 | ||
|
|
5c802b07b5 | ||
|
|
383df51a99 | ||
|
|
3551a3af71 | ||
|
|
038251f9c9 | ||
|
|
4f590f8879 | ||
|
|
c04fa15eb5 | ||
|
|
2aef23c8ba | ||
|
|
eefcb06e35 | ||
|
|
8ac3ca7fe5 | ||
|
|
03e7ca39c3 | ||
|
|
3254925506 | ||
|
|
a3fd3ade01 | ||
|
|
3837af657d | ||
|
|
749c80c877 | ||
|
|
af918251d3 | ||
|
|
ae27478a27 | ||
|
|
0bfad0e592 | ||
|
|
aad382f4be | ||
|
|
fc8ce04f2b | ||
|
|
cb51f35cad | ||
|
|
a6e7f64ef2 | ||
|
|
f065e2c0f8 | ||
|
|
1c47d0bba7 | ||
|
|
2bd8701b15 | ||
|
|
d08ae41f1f | ||
|
|
a356656a60 | ||
|
|
1c7619e184 | ||
|
|
3f1ba38f19 | ||
|
|
577ebde902 | ||
|
|
59f600d34d | ||
|
|
cbedfed2ba | ||
|
|
f9a3699603 | ||
|
|
75affec091 | ||
|
|
de9d229cfa | ||
|
|
cabc4825ef | ||
|
|
87ff6ed326 | ||
|
|
6d6e8a841e | ||
|
|
b7112f6e5d | ||
|
|
ee9106ef54 | ||
|
|
e4f1b75f9b | ||
|
|
4a206086af | ||
|
|
29d998b6c3 | ||
|
|
228f2f4638 | ||
|
|
68d60e409b | ||
|
|
b1dfa3f7bb | ||
|
|
f586d748d6 | ||
|
|
bcdc769083 | ||
|
|
430f058a6d | ||
|
|
9b459b79dd | ||
|
|
ab77c66f04 | ||
|
|
dd6135cac1 | ||
|
|
bd15a186b5 | ||
|
|
a15fadbddc | ||
|
|
7e054ddf11 | ||
|
|
0c2ff88c88 | ||
|
|
c982353381 | ||
|
|
78db9c8c93 | ||
|
|
ff133f88ff | ||
|
|
8e181ddf3c | ||
|
|
02df4ac61c | ||
|
|
a5f387929d | ||
|
|
e4117441b0 | ||
|
|
7671368faf | ||
|
|
6b07aafc67 | ||
|
|
f9dfd05676 | ||
|
|
cbcd8d65df | ||
|
|
861fc4a58b | ||
|
|
a58f4d2b32 | ||
|
|
a01ae73a1f | ||
|
|
78f9331b89 | ||
|
|
98177c3bc9 | ||
|
|
3cab16ca5b | ||
|
|
f023ab5da0 | ||
|
|
b10a49c082 | ||
|
|
4b2cb3d266 | ||
|
|
7d7a80782e | ||
|
|
bd9d26199f | ||
|
|
58615ee5e7 | ||
|
|
31cdb93691 | ||
|
|
8917f36be5 | ||
|
|
530e5880e1 | ||
|
|
887749722c | ||
|
|
9ce81547c4 | ||
|
|
3343b21a17 | ||
|
|
aac8af5e1f | ||
|
|
8be09a012b | ||
|
|
55ecbf4fb8 | ||
|
|
926ce86b83 | ||
|
|
e1ad680475 | ||
|
|
ebfa09e067 | ||
|
|
6dae3e7a14 | ||
|
|
2379260ffe | ||
|
|
21b893473d | ||
|
|
af78f0ed51 | ||
|
|
7a46612f41 | ||
|
|
240d8adbd3 | ||
|
|
8662f94b3b |
@@ -38,10 +38,15 @@ anything under `execution`.
|
|||||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||||
below before adding one.
|
below before adding one.
|
||||||
|
|
||||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||||
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||||
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||||
deliberate boundary.
|
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||||
|
|
||||||
|
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||||
|
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||||
|
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||||
|
the counts stay right and the sentences rot.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -113,6 +118,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
|||||||
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
||||||
first. The source build also needs `build-essential` and `python3`.
|
first. The source build also needs `build-essential` and `python3`.
|
||||||
|
|
||||||
|
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
|
||||||
|
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
|
||||||
|
watched. Two places carry that number and they move together: `BUN_SERIES` in
|
||||||
|
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
|
||||||
|
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
|
||||||
|
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
|
||||||
|
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
|
||||||
|
|
||||||
|
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
|
||||||
|
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
|
||||||
|
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
|
||||||
|
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
|
||||||
|
|
||||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
||||||
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||||
- **Backend**: Hono
|
- **Backend**: Hono
|
||||||
@@ -194,7 +212,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
|||||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||||
never grantable at any level). 27 entries as of 2026-08-13.
|
never grantable at any level). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||||
and `confined` stripped for an account with no `osUser`.
|
and `confined` stripped for an account with no `osUser`.
|
||||||
@@ -224,9 +242,20 @@ bunx tsgo # typecheck (not tsc)
|
|||||||
bun test # tests
|
bun test # tests
|
||||||
bun format # prettier over every dirty file — see the note below before running it
|
bun format # prettier over every dirty file — see the note below before running it
|
||||||
bun db:push # apply the schema to Postgres
|
bun db:push # apply the schema to Postgres
|
||||||
|
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||||
|
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||||
|
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||||
|
place to drift, and it drifts in the same edit that misses the doc.
|
||||||
|
|
||||||
|
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||||
|
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||||
|
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||||
|
written had been wrong for over a week.
|
||||||
|
|
||||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||||
@@ -337,6 +366,45 @@ withholding the commit until it is good.
|
|||||||
|
|
||||||
Commit messages: simple lowercase, no prefixes.
|
Commit messages: simple lowercase, no prefixes.
|
||||||
|
|
||||||
|
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||||
|
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||||
|
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||||
|
the two-step form only looks like it does.
|
||||||
|
|
||||||
|
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||||
|
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||||
|
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||||
|
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||||
|
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||||
|
|
||||||
|
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||||
|
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||||
|
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||||
|
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||||
|
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||||
|
|
||||||
|
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||||
|
```
|
||||||
|
|
||||||
|
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||||
|
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||||
|
restart in a single night. The transcript id survived both.
|
||||||
|
|
||||||
|
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||||
|
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||||
|
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||||
|
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||||
|
whose "why" is still answerable in six months.
|
||||||
|
|
||||||
|
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||||
|
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||||
|
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||||
|
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||||
|
add the trailer and one that does not should leave it out rather than guess.
|
||||||
|
|
||||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||||
|
|
||||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||||
@@ -453,6 +521,13 @@ explaining why it was safe.
|
|||||||
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
||||||
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
||||||
remember, and the credential-resolution work that has to land first
|
remember, and the credential-resolution work that has to land first
|
||||||
|
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
|
||||||
|
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
|
||||||
|
because otherwise "add a role" and "become the owner" are the same feature
|
||||||
|
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
|
||||||
|
web UI to more than one language. What exists and what it cannot do yet, why the server should return
|
||||||
|
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||||
|
naming convention, and the four phases. Read before adding a user-facing string
|
||||||
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
||||||
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
||||||
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
||||||
@@ -462,6 +537,18 @@ explaining why it was safe.
|
|||||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||||
anything it already rules out
|
anything it already rules out
|
||||||
|
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||||
|
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||||
|
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||||
|
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||||
|
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||||
|
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||||
|
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||||
|
a namespace makes provenance readable, separate directories make it structural
|
||||||
|
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||||
|
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||||
|
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||||
|
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||||
- `src/databases/CLAUDE.md` — database patterns
|
- `src/databases/CLAUDE.md` — database patterns
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# SendOff — mobile's answer
|
||||||
|
|
||||||
|
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
|
||||||
|
|
||||||
|
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
|
||||||
|
|
||||||
|
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
|
||||||
|
Good catch flagging the case explicitly; the app did build that path itself and would have opened
|
||||||
|
an empty sibling folder.
|
||||||
|
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
|
||||||
|
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
|
||||||
|
being siblings called "devices" would have bitten us first.
|
||||||
|
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
|
||||||
|
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
|
||||||
|
carries ids — one address format, no translation layer.
|
||||||
|
|
||||||
|
One request for `/targets` when the send screen gets built here: include each person's display name
|
||||||
|
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
|
||||||
|
so the picker can honestly say where the files will appear on the other side.
|
||||||
|
|
||||||
|
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
|
||||||
|
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
|
||||||
|
iOS now so the corrected paths are on the device.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# SendOff — `/targets` now serves the folder name
|
||||||
|
|
||||||
|
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
|
||||||
|
|
||||||
|
Request granted, and you were right to ask for it served rather than derived. Two fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/sendoff/targets
|
||||||
|
→ {
|
||||||
|
devices: [{ id, name, platform, lastSeenAt }],
|
||||||
|
people: [{ id, email, name, displayName }], ← displayName is new
|
||||||
|
myFolder: "Pastilhas" ← this is new
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
|
||||||
|
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
|
||||||
|
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
|
||||||
|
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
|
||||||
|
needs to change, it changes in one place and both sides move together.
|
||||||
|
|
||||||
|
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
|
||||||
|
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
|
||||||
|
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
|
||||||
|
|
||||||
|
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
|
||||||
|
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
|
||||||
|
that showed `displayName` as the destination folder would be naming the wrong directory.
|
||||||
|
|
||||||
|
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
|
||||||
|
what you see is what appears on disk.
|
||||||
|
|
||||||
|
## Also since the last note
|
||||||
|
|
||||||
|
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
|
||||||
|
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
|
||||||
|
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
|
||||||
|
|
||||||
|
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
|
||||||
|
asked for, and it is there now rather than when you get to it.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# SendOff — the backend is in. What mobile needs to change.
|
||||||
|
|
||||||
|
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
|
||||||
|
draft; this file is what actually shipped, and it differs in two places.
|
||||||
|
|
||||||
|
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
|
||||||
|
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
|
||||||
|
exercised against the live server before this note was written. The web send form is **not** built yet;
|
||||||
|
that is on this side and does not block anything below.
|
||||||
|
|
||||||
|
## The convention on disk
|
||||||
|
|
||||||
|
```
|
||||||
|
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
|
||||||
|
<home>/Documents/SendOff/<sender>/ — files another account sent you
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
|
||||||
|
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
|
||||||
|
|
||||||
|
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
|
||||||
|
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
|
||||||
|
relative to the right one.
|
||||||
|
|
||||||
|
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
|
||||||
|
sender's name alone.
|
||||||
|
|
||||||
|
## Two changes from the draft spec
|
||||||
|
|
||||||
|
**1. Device registration moved under the feature.**
|
||||||
|
|
||||||
|
```
|
||||||
|
was: POST /api/devices/register { id, name, platform }
|
||||||
|
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
|
||||||
|
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
|
||||||
|
`DELETE /api/sendoff/devices/:id`.
|
||||||
|
|
||||||
|
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
|
||||||
|
top-level noun collides with the **push token** registry that already exists at
|
||||||
|
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
|
||||||
|
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
|
||||||
|
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
|
||||||
|
an afternoon.
|
||||||
|
|
||||||
|
**2. `toUser` is an account id, not a username.**
|
||||||
|
|
||||||
|
```
|
||||||
|
was: meta { "toUser": "<username>" }
|
||||||
|
now: form field toUser=<account id, integer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
|
||||||
|
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
|
||||||
|
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
|
||||||
|
either; it is a small change here and a smaller one than guessing.
|
||||||
|
|
||||||
|
## The send endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/sendoff/send
|
||||||
|
multipart/form-data:
|
||||||
|
file — repeatable, any number of parts
|
||||||
|
machine — a name of your own } exactly one of these
|
||||||
|
toUser — an account id }
|
||||||
|
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes that matter for a client:
|
||||||
|
|
||||||
|
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
|
||||||
|
A file over the limit is a `400` naming the file.
|
||||||
|
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
|
||||||
|
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
|
||||||
|
they were about to destroy something. Show the returned name, not the local one.
|
||||||
|
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
|
||||||
|
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
|
||||||
|
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
|
||||||
|
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
|
||||||
|
one refused you. Do not present the two differently, because they are not distinguishable.
|
||||||
|
|
||||||
|
## Who may send to whom
|
||||||
|
|
||||||
|
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
|
||||||
|
handshake: I add you, and only then do you see me as a target.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
|
||||||
|
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
|
||||||
|
POST /api/sendoff/senders { userId }
|
||||||
|
DELETE /api/sendoff/senders/:userId
|
||||||
|
```
|
||||||
|
|
||||||
|
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
|
||||||
|
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
|
||||||
|
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
|
||||||
|
their say-so, so nobody lands files in the owner's tree uninvited.
|
||||||
|
|
||||||
|
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
|
||||||
|
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
|
||||||
|
|
||||||
|
## The push
|
||||||
|
|
||||||
|
```
|
||||||
|
code: officer/core:sendoff.landed
|
||||||
|
id: the folder name — a username, or one of your own machine names
|
||||||
|
n: how many files landed
|
||||||
|
```
|
||||||
|
|
||||||
|
The catalogue entry renders `{n} file(s) from {id}` and its route is:
|
||||||
|
|
||||||
|
```
|
||||||
|
officer://files?path=Documents/SendOff/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
|
||||||
|
from the server — which is the whole point of that design. What the app does need is to keep parsing
|
||||||
|
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
|
||||||
|
|
||||||
|
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
|
||||||
|
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
|
||||||
|
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
|
||||||
|
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
|
||||||
|
conversation rather than a patch.
|
||||||
|
|
||||||
|
## What is NOT built
|
||||||
|
|
||||||
|
- **The web send form.** This side, in progress. It changes no API.
|
||||||
|
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
|
||||||
|
blocks it — `POST /sendoff/send` takes multipart from any client.
|
||||||
|
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
|
||||||
|
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
|
||||||
|
month.
|
||||||
|
|
||||||
|
## If something here is wrong for mobile
|
||||||
|
|
||||||
|
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
|
||||||
|
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
|
||||||
|
ends up carrying a translation layer nobody meant to write.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
|
Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
|
||||||
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
|
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
|
||||||
Format and the two-way-sync contract: `todo/README.md`.
|
Format and the two-way-sync contract: `todo/README.md`. Browse and edit it at `/todos` in the app.
|
||||||
|
|
||||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
||||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||||
@@ -17,40 +17,45 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
## Urgent
|
## Urgent
|
||||||
|
|
||||||
- **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
- **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
||||||
|
- **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
**78 open, 20 closed**, across 28 themes.
|
**97 open, 31 closed**, across 32 themes.
|
||||||
|
|
||||||
| Theme | Open | Closed |
|
| Theme | Open | Closed |
|
||||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
| --- | --- | --- |
|
||||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
| [Agents coordinating agents](todo/agent-coordination/) | 3 | 0 |
|
||||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||||
| [Background jobs](todo/background-jobs/) | 1 | 0 |
|
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||||
|
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||||
| [Fleet — one command across every server](todo/fleet/) | 3 | 0 |
|
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||||
| [Known bugs](todo/known-bugs/) | 2 | 1 |
|
| [Known bugs](todo/known-bugs/) | 5 | 4 |
|
||||||
| [Mobile apps](todo/mobile-apps/) | 2 | 0 |
|
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||||
| [Multi-user](todo/multi-user/) | 17 | 3 |
|
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
|
||||||
|
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||||
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
|
| [SendOff](todo/sendoff/) | 1 | 0 |
|
||||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||||
|
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||||
|
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
|
||||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
|
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||||
|
|
||||||
## Records — no items, kept for the reasoning
|
## Records — no items, kept for the reasoning
|
||||||
@@ -62,13 +67,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
## Newest ten
|
## Newest ten
|
||||||
|
|
||||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||||
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||||
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||||
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
|
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
|
||||||
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
|
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
|
||||||
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
|
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
|
||||||
- `2026-08-21` **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
|
||||||
- `2026-08-21` **A way to mark a commit "this one ships", so a release is not just `master`'s tip** — [fleet/a-way-to-mark-a-commit-this-one-ships-so-a](todo/fleet/a-way-to-mark-a-commit-this-one-ships-so-a.md)
|
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
|
||||||
- `2026-08-21` **OPM — Officer's own process manager, or at least its own PM2** — [infra/opm-officer-s-own-process-manager](todo/infra/opm-officer-s-own-process-manager.md)
|
- `2026-08-23` **A worker cannot verify that the owner delegated to a manager, so it has to ask every time** — [agent-coordination/a-peer-cannot-verify-a-delegation](todo/agent-coordination/a-peer-cannot-verify-a-delegation.md)
|
||||||
- `2026-08-21` **iPhone Action Button → run an Officer task from a locked phone** — [mobile-apps/iphone-action-button-run-an-officer-task-from](todo/mobile-apps/iphone-action-button-run-an-officer-task-from.md)
|
- `2026-08-23` **An agent cannot learn its own transcript id, so it cannot sign its own work** — [agent-coordination/an-agent-cannot-learn-its-own-session-id](todo/agent-coordination/an-agent-cannot-learn-its-own-session-id.md)
|
||||||
|
|||||||
@@ -210,6 +210,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"embla-carousel-react": "^8.5.1",
|
"embla-carousel-react": "^8.5.1",
|
||||||
"hooks": "*",
|
"hooks": "*",
|
||||||
|
"i18n": "*",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
@@ -271,6 +272,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hooks": "workspace:*",
|
"hooks": "workspace:*",
|
||||||
|
"i18n": "workspace:*",
|
||||||
"state": "workspace:*",
|
"state": "workspace:*",
|
||||||
"widgets": "workspace:*",
|
"widgets": "workspace:*",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 2026.08.21.02
|
||||||
|
|
||||||
|
Command HQ made usable: a commander's transcript renders, and Live separates commanders from the
|
||||||
|
conversations somebody is having.
|
||||||
|
|
||||||
|
Frontend and one chat API route. **No schema change, no dependency change** — verified against
|
||||||
|
`deploy/2026.08.21.01..HEAD`: nothing touched `src/schema.ts`, `package.json` dependencies or `bun.lock`.
|
||||||
|
So there is no `db:push` and no `bun install` in this plan, and a host that runs one has run something
|
||||||
|
this release did not ask for.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- Three separate defects that each made a commander's conversation invisible: the pane navigated to
|
||||||
|
officer's session key instead of Claude's transcript id, then remounted and discarded the live chat at
|
||||||
|
the end of the first turn, then seeded `useChat` with messages that had not arrived yet.
|
||||||
|
- Live splits into `Agents (n)` / `Commanders (n)`, and `/chat/live` now carries `isCommander` so a client
|
||||||
|
with no room for tabs — the iPhone app's swipe order — can filter on it.
|
||||||
|
- The handoff watcher's controls, which were built and never rendered.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
In the order the procedure gives. Nothing here is host-specific.
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. `bun run plugins:update`
|
||||||
|
3. `pm2 restart officer`
|
||||||
|
|
||||||
|
**`officer` only.** No sidecar changed. Restarting `officer` rebuilds the SPA on boot, which is what
|
||||||
|
ships the frontend — there is no separate build step to run.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else should have restarted.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 2026.08.21.02 — deployed
|
||||||
|
|
||||||
|
**6/6 hosts.** Every platform the plan reaches is on `55ecbf4f`, `officer` restarted and back `online`,
|
||||||
|
and no other process on any host was touched by the restart step.
|
||||||
|
|
||||||
|
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this commander runs
|
||||||
|
on. See below.
|
||||||
|
|
||||||
|
Rolled out one host at a time from `officer-dev`, by the procedure in `prompts/deploy-commander.md`.
|
||||||
|
|
||||||
|
## The release
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.21.02` → `55ecbf4f`; `package.json` says `2026.08.21.02`. They agree.
|
||||||
|
Plan: `deploy/2026.08.21.02.md` — pull, `plugins:update`, restart `officer` only. No `bun install`,
|
||||||
|
no `db:push`, and neither was run anywhere.
|
||||||
|
|
||||||
|
| Host | Before | After | plugins | restarted | restart_time | back |
|
||||||
|
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
|
||||||
|
| `alpha` | `eee73e77` | `55ecbf4f` | 12/12 | officer | 60 → 61 | online |
|
||||||
|
| `fofos` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 15 → 16 | online |
|
||||||
|
| `macbookpro` | `eee73e77` | `55ecbf4f` | none installed | officer | 2 → 3 | online |
|
||||||
|
| `edge-pertento-bridge` | `eee73e77` | `55ecbf4f` | 2/2 | officer | 4 → 5 | online |
|
||||||
|
| `production-pertento-bridge` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 6 → 7 | online |
|
||||||
|
| `deadpool-rezio-bridge` | `bd41dff5` | `55ecbf4f` | none installed | officer | 0 → 1 | online |
|
||||||
|
| `officer-dev` | `55ecbf4f` | `55ecbf4f` | not run | **none** | 155 | skipped |
|
||||||
|
|
||||||
|
Every host was clean of TRACKED changes and every pull was a real fast-forward. `pm2 jlist` after each
|
||||||
|
restart showed zero processes in any state other than `online`, so nothing was collaterally bounced.
|
||||||
|
|
||||||
|
Checkout paths were taken from each `officer` process's `pm_cwd`, not assumed — so `alpha`'s stale
|
||||||
|
second checkout at `~/dockers/officer.dev/platform` was never touched.
|
||||||
|
|
||||||
|
## What was NOT done, and why
|
||||||
|
|
||||||
|
- **`officer-dev` was skipped completely** — no pull, no `plugins:update`, no restart. This session runs
|
||||||
|
inside `officer-claude-code` on it, and its `officer` process would have been restarted out from under
|
||||||
|
the rollout. Its checkout is already at `55ecbf4f` (the tag was cut here), so **the code is present and
|
||||||
|
the running process is a build behind**. `pm2 restart officer` on `officer-dev`, when the owner is happy
|
||||||
|
with the rest, finishes this release. **Outstanding.**
|
||||||
|
|
||||||
|
- **`deadpool-rezio-bridge` was two releases behind, not one.** It came in at `bd41dff5` — the
|
||||||
|
`deploy/2026.08.21.01` commit — while every other host was at `eee73e77`. The fast-forward carried both,
|
||||||
|
14 files, and it is now level. Nothing was wrong with it; it simply had not had the interim commits.
|
||||||
|
|
||||||
|
- **`fofos` still cannot sync its capabilities.** Same `dubious ownership` refusal as `2026.08.21.01`,
|
||||||
|
same unexplained cause, still not papered over with a `safe.directory` entry. New this time and probably
|
||||||
|
the same root: its gitea plugin could not publish affordances —
|
||||||
|
`EACCES: permission denied, mkdir '/home/pastilhas/officerdev/capabilities/affordances/officerdev'`.
|
||||||
|
Both point at that capabilities checkout being owned by somebody other than `pastilhas`, which is worth
|
||||||
|
one `stat` before the next deploy. `plugins:update` exited 0 and the plugin itself updated. **Unexplained,
|
||||||
|
and now costing two things instead of one.**
|
||||||
|
|
||||||
|
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — that is the rule the last
|
||||||
|
report asked for and it worked.
|
||||||
|
|
||||||
|
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither.
|
||||||
|
|
||||||
|
## Notes for the next one
|
||||||
|
|
||||||
|
- **`macbookpro` needs the PATH helped after all — carefully.** The last report said "`$SHELL -ic` and
|
||||||
|
nothing else"; that is right for `pm2` and wrong for `bun`. `bun` is at `/opt/homebrew/bin/bun`, and
|
||||||
|
`.zshrc` does not put `/opt/homebrew/bin` on the interactive PATH, so `bun run plugins:update` failed
|
||||||
|
`command not found` before it ran a line. What works is **appending** that one directory for that one
|
||||||
|
command (`export PATH=$PATH:/opt/homebrew/bin`) and running `pm2` as a separate command. Appending
|
||||||
|
shadows nothing — `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. **Replacing** the PATH is
|
||||||
|
what broke `pm2` last time. `docs/servers.md` says "do not help the PATH" and should say "append, never
|
||||||
|
replace, and only for `bun`".
|
||||||
|
|
||||||
|
- **`$SHELL -ic` on `macbookpro` also aliases `ls` to `eza`**, which is not installed, so a probe using
|
||||||
|
`ls` reports a missing file that is plainly there. Use `command ls` or a plain non-interactive shell for
|
||||||
|
file tests.
|
||||||
|
|
||||||
|
- **`plugins:update` restarts sidecars itself.** It prints `sidecar: started` per plugin, so on `alpha`
|
||||||
|
twelve sidecars bounced during step 3 — before the plan's restart step, and not something the plan asked
|
||||||
|
for. That is the script's own behaviour and it is not new, but a report that only lists `officer` under
|
||||||
|
"restarted" is understating it. Worth a line in the plan format.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026.08.21.03 — a drill
|
||||||
|
|
||||||
|
**No code change.** This release exists to watch the watcher fire, and to exercise the claim rule that
|
||||||
|
landed after the last one.
|
||||||
|
|
||||||
|
There is nothing to bump but the version and this file, and that is deliberate rather than lazy: a tag
|
||||||
|
whose `package.json` disagrees with it makes the commander stop, and a tag with no plan beside it makes it
|
||||||
|
stop too, so the smallest honest release is still a version and a plan.
|
||||||
|
|
||||||
|
## What is being tested
|
||||||
|
|
||||||
|
- The watcher noticing a tag and spawning an agent on its own, with nobody watching the composer.
|
||||||
|
- `scripts/deploy-claim.sh` taking the claim before anything is deployed, and the deploy recording success
|
||||||
|
through the same script.
|
||||||
|
- A second pass over the same version being refused rather than repeated.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. `bun run plugins:update`
|
||||||
|
3. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. No `bun install`, no `db:push` — nothing in this range touches a schema, a dependency or
|
||||||
|
the lockfile, and `git diff deploy/2026.08.21.02..HEAD` is two files.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else restarted.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.21.03` says `done`.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 2026.08.21.03 — deployed
|
||||||
|
|
||||||
|
**6/6 hosts.** Every platform the plan reaches is on `3343b21a`, `officer` restarted and back `online`,
|
||||||
|
and no other process on any host was left in a state other than `online`.
|
||||||
|
|
||||||
|
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this deploy ran on.
|
||||||
|
See below; its skip is now two releases deep.
|
||||||
|
|
||||||
|
This was a drill. The release changes `package.json` and adds its own plan file, nothing else, and it
|
||||||
|
exists to watch the claim rule work. It did: `scripts/deploy-claim.sh claim 2026.08.21.03` was taken
|
||||||
|
before any host was touched, and `state` reads `done`.
|
||||||
|
|
||||||
|
## The release
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.21.03` → `3343b21a`; `package.json` at that commit says `2026.08.21.03`. They agree.
|
||||||
|
Plan: `deploy/2026.08.21.03.md` — pull, `plugins:update`, restart `officer` only. No `bun install`, no
|
||||||
|
`db:push`, and neither ran anywhere.
|
||||||
|
|
||||||
|
| Host | Before | After | plugins | restarted | restart_time | back |
|
||||||
|
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
|
||||||
|
| `alpha` | `55ecbf4f` | `3343b21a` | 12/12 | officer | 61 → 62 | online |
|
||||||
|
| `fofos` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 16 → 17 | online |
|
||||||
|
| `macbookpro` | `55ecbf4f` | `3343b21a` | none installed | officer | 3 → 4 | online |
|
||||||
|
| `edge-pertento-bridge` | `55ecbf4f` | `3343b21a` | 2/2 | officer | 5 → 6 | online |
|
||||||
|
| `production-pertento-bridge` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 7 → 8 | online |
|
||||||
|
| `deadpool-rezio-bridge` | `55ecbf4f` | `3343b21a` | none installed | officer | 1 → 2 | online |
|
||||||
|
| `officer-dev` | `3343b21a` | `3343b21a` | not run | **none** | 155 | skipped |
|
||||||
|
|
||||||
|
Every host came in at `55ecbf4f` — the previous release, level for the first time in three deploys — and
|
||||||
|
every pull was a real fast-forward. Checkout paths were taken from each `officer` process's `pm_cwd`, so
|
||||||
|
`alpha`'s stale second checkout at `~/dockers/officer.dev/platform` was never touched. `macbookpro` was
|
||||||
|
awake.
|
||||||
|
|
||||||
|
**More than `officer` restarted, and the plan did not ask for it.** `bun run plugins:update` bounces each
|
||||||
|
plugin's sidecar as it goes: twelve on `alpha`, two on `edge`, one each on `fofos` and
|
||||||
|
`production-pertento-bridge`. Every one came back `online`. Nothing was left down.
|
||||||
|
|
||||||
|
## What was NOT done, and why
|
||||||
|
|
||||||
|
- **`officer-dev` was skipped completely** — no pull (it is already at the tag, which was cut here), no
|
||||||
|
`plugins:update`, no restart. This session runs inside `officer-claude-code` on it, and restarting
|
||||||
|
`officer` there ends the rollout mid-flight.
|
||||||
|
|
||||||
|
**Its running process is now two releases old.** `restart_time` is 155, unchanged since before
|
||||||
|
`2026.08.21.02` — so the same restart the last report left outstanding is still outstanding, and it now
|
||||||
|
carries both releases. The code for both is on disk. `pm2 restart officer` on `officer-dev` finishes
|
||||||
|
them, whenever the owner wants it. **Outstanding, and no longer new.**
|
||||||
|
|
||||||
|
- **`fofos` still cannot sync its capabilities.** Third deploy running, same two failures, same
|
||||||
|
unexplained cause:
|
||||||
|
|
||||||
|
- `capabilities: failed — fatal: detected dubious ownership in repository at
|
||||||
|
'/home/pastilhas/officerdev/capabilities'`
|
||||||
|
- `affordances: NOT published — EACCES: permission denied, mkdir
|
||||||
|
'/home/pastilhas/officerdev/capabilities/affordances/officerdev'`
|
||||||
|
|
||||||
|
Both say that checkout is not owned by `pastilhas`. Still not papered over with a `safe.directory`
|
||||||
|
entry, because a `safe.directory` line would silence the first message and leave the `EACCES` — they are
|
||||||
|
one cause, and one `stat` settles it. `plugins:update` exited 0 and the gitea plugin itself updated, so
|
||||||
|
this costs capability sync on that host and nothing else. **Unexplained.**
|
||||||
|
|
||||||
|
- **`fofos` still carries one untracked file**, `scripts/chk.ts`. A note, not a skip — the rule works.
|
||||||
|
|
||||||
|
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither, and
|
||||||
|
`deploy/2026.08.21.02..deploy/2026.08.21.03` is two files.
|
||||||
|
|
||||||
|
- **Nothing was verified beyond `pm2`.** `officer` is `online` with a moved `restart_time` on all six
|
||||||
|
hosts; no HTTP request was made to any domain. That is what the plan asked to check.
|
||||||
|
|
||||||
|
## Notes for the next one
|
||||||
|
|
||||||
|
- **The `macbookpro` PATH rule in `docs/servers.md` is now correct and was followed as written.**
|
||||||
|
`export PATH=$PATH:/opt/homebrew/bin` for the `bun` command, `pm2` as a separate command, and both
|
||||||
|
worked first time. Nothing to change.
|
||||||
|
|
||||||
|
- **`$SHELL -ic "pm2 jlist"` with stderr discarded came back empty once**, on `edge-pertento-bridge`, and
|
||||||
|
the same command with `2>&1` a second later returned the full list. Transient, and it looked exactly
|
||||||
|
like a dead pm2. Capture stderr and parse the JSON out of the stream rather than trusting the whole of
|
||||||
|
stdout to be JSON — an interactive shell puts its own noise (`starship`, aliases) in there.
|
||||||
|
|
||||||
|
- **`officer-dev`'s skip needs a home other than a report.** It has been carried forward twice now, and a
|
||||||
|
report is not a place work waits — the next deploy will write the same paragraph. Either the owner
|
||||||
|
restarts it, or it belongs in `TODO.md`.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 2026.08.21.04
|
||||||
|
|
||||||
|
`/login` in the chat window, switching between Anthropic accounts from the usage pills, and a Spotlight
|
||||||
|
that can actually reach the plugins.
|
||||||
|
|
||||||
|
**This release has a SCHEMA CHANGE.** The previous two did not, and said so; this one adds
|
||||||
|
`anthropic_accounts`. Without the push, `GET /api/claude-auth/accounts` fails on a missing relation the
|
||||||
|
first time the usage panel is opened — so `bun db:push` is a step here, not an optional tidy.
|
||||||
|
|
||||||
|
No dependency change: nothing in `deploy/2026.08.21.03..HEAD` touches `package.json` dependencies or
|
||||||
|
`bun.lock`. Do not run `bun install`.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **`/login` works in the chat composer.** `claude auth login` behaves the same through plain pipes as
|
||||||
|
through a terminal, so it is a subprocess with two reads and one write — no pty. Copy-link beside the
|
||||||
|
open-link button, because opening it here opens it in the browser already signed into the account you are
|
||||||
|
leaving.
|
||||||
|
- **Account switching from the usage pills.** `anthropic_accounts` keeps each login's OAuth credential
|
||||||
|
encrypted under a per-account key from the secret store, so a switch needs no browser round trip. An
|
||||||
|
account with no saved credential still gets a chip you can read — cached percentages and a correct reset
|
||||||
|
time — marked as needing `/login`.
|
||||||
|
- **Spotlight reached eight items.** Core filled all eight slots, so on a populated server every plugin was
|
||||||
|
unreachable. The cap is gone, rows draw the dock's own tiles, and the settings list went from nine
|
||||||
|
hand-written rows to twenty read off the screens that declare them.
|
||||||
|
- The ephemeral pane opens from the markup rather than an effect.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. **`bun db:push`** — new this release, see above
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only; no sidecar changed. Restarting it rebuilds the SPA, which is what ships the frontend.
|
||||||
|
|
||||||
|
**`alpha` is already at this code**, deployed by hand as a preview before the tag. Its pull will be a
|
||||||
|
no-op and that is expected — run the remaining steps anyway rather than skipping the host, so its
|
||||||
|
`restart_time` moves with everyone else's.
|
||||||
|
|
||||||
|
**On `macbookpro`, append to the PATH for `bun`** — `export PATH=$PATH:/opt/homebrew/bin` — and run `pm2`
|
||||||
|
as a separate command. `docs/servers.md` has the detail; replacing the PATH is what broke `pm2` on an
|
||||||
|
earlier deploy.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing else restarted by step 4 — note that
|
||||||
|
step 3 restarts every plugin's sidecar on its own, which is the script's behaviour and worth recording
|
||||||
|
rather than omitting.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.21.04` says `done`.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 2026.08.21.04 — deploy report
|
||||||
|
|
||||||
|
**6 of 6 hosts in scope succeeded.** `officer-dev` was skipped entirely — it is the host this agent runs
|
||||||
|
on. Seven platforms exist; six were deployable.
|
||||||
|
|
||||||
|
Target commit `98177c3b`, tag `deploy/2026.08.21.04`, `package.json` version `2026.08.21.04` — tag and
|
||||||
|
version agree.
|
||||||
|
|
||||||
|
Every host ran the same four steps from `deploy/2026.08.21.04.md`: `git pull --ff-only`, `bun db:push`
|
||||||
|
(the schema change), `bun run plugins:update`, `pm2 restart officer`. No host ran `bun install` — the plan
|
||||||
|
said not to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `alpha` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3cab16ca` → `98177c3b`. Tree clean, no untracked files.
|
||||||
|
- The pull moved only `deploy/2026.08.21.04.md` and `package.json`: the code was already there from the
|
||||||
|
hand deploy the plan describes. Expected, and the remaining steps ran anyway.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified present by direct query.
|
||||||
|
- `plugins:update` — 12/12 updated in 44s, exit 0. **Twelve sidecars restarted by that step**: atelier,
|
||||||
|
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission,
|
||||||
|
vault. That is the script's own behaviour, not something the plan asked for.
|
||||||
|
- `pm2 restart officer` — `restart_time` 63 → 64, `online`.
|
||||||
|
|
||||||
|
### `fofos` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. No tracked changes.
|
||||||
|
- **Untracked file present: `scripts/chk.ts`.** Left alone — a fast-forward cannot touch it, and it is
|
||||||
|
not a reason to skip a host.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted: `officer-gitea`.
|
||||||
|
- `pm2 restart officer` — 17 → 18, `online`.
|
||||||
|
- The capabilities ownership fault recorded in `docs/servers.md` did not recur: `capabilities: pulled`.
|
||||||
|
|
||||||
|
### `macbookpro` — succeeded
|
||||||
|
|
||||||
|
- HEAD `b10a49c0` → `98177c3b`. Tree clean.
|
||||||
|
- Awake and reachable; no skip needed.
|
||||||
|
- `bun` reached with `export PATH=$PATH:/opt/homebrew/bin` appended for the `bun` commands only, `pm2`
|
||||||
|
run as a separate command, per the plan and `docs/servers.md`.
|
||||||
|
- `db:push` — changes applied.
|
||||||
|
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
|
||||||
|
- `pm2 restart officer` — 4 → 5, `online`.
|
||||||
|
- **The one thing not verified here:** `psql` is not on this machine's PATH under any prefix tried, so
|
||||||
|
`anthropic_accounts` was confirmed only by `db:push` reporting "Changes applied", not by querying the
|
||||||
|
table. Every other host was checked directly.
|
||||||
|
- Cosmetic noise from the interactive shell: `.zshrc:123: command not found: starship`. Harmless, and
|
||||||
|
unrelated to the deploy.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. Tree clean.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — 2/2 (gitea, offscale), exit 0. Two sidecars restarted.
|
||||||
|
- `pm2 restart officer` — 6 → 7, `online`.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. Tree clean.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted.
|
||||||
|
- `pm2 restart officer` — 8 → 9, `online`.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — succeeded
|
||||||
|
|
||||||
|
- Logged in as `br`, checkout `/home/br/officerdev/platform`.
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. Tree clean.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
|
||||||
|
- `pm2 restart officer` — 2 → 3, `online`.
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, deliberately
|
||||||
|
|
||||||
|
Not pulled, no schema push, no plugin update, nothing restarted. This agent is a session inside
|
||||||
|
`officer-claude-code` on this machine, and restarting `officer` here ends the rollout mid-report. The
|
||||||
|
host is therefore one release behind the other six until the owner deploys it by hand — checkout
|
||||||
|
`~/officer-dev/platform`, and its working tree is already at `98177c3b` plus this report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was checked, and how
|
||||||
|
|
||||||
|
- Every checkout path came from PM2 (`officer`'s `pm_cwd`), not from the table — so `alpha`'s second,
|
||||||
|
stale checkout was never a candidate.
|
||||||
|
- `pm2 jlist` after each restart: `officer` `online` with `restart_time` moved, on all six.
|
||||||
|
- A full sweep for anything not `online` on all six hosts afterwards: **none**. No process came back
|
||||||
|
`errored`, including the sixteen sidecars that `plugins:update` bounced across four hosts.
|
||||||
|
- `anthropic_accounts` queried directly with `to_regclass` on five hosts; on `macbookpro` only the
|
||||||
|
`db:push` output stands behind it.
|
||||||
|
|
||||||
|
## What was not done
|
||||||
|
|
||||||
|
- `officer-dev` — the whole host, as above.
|
||||||
|
- No host was skipped for a dirty tree; none had tracked changes.
|
||||||
|
- No process other than `officer` was restarted by this agent. The sidecars that moved were restarted by
|
||||||
|
`plugins:update` itself.
|
||||||
|
- `bun install` was not run anywhere, per the plan.
|
||||||
|
|
||||||
|
Local record: `data/deploys/2026.08.21.04.done`, written before this report.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 2026.08.22.01
|
||||||
|
|
||||||
|
349 community themes, Spotlight made usable, prompt history that stops eating drafts, and the email
|
||||||
|
plugin's multi-account backend.
|
||||||
|
|
||||||
|
**No schema change and no dependency change.** Verified against `deploy/2026.08.21.04..HEAD`: nothing
|
||||||
|
touches a `databases/*/schema.ts`, `package.json` dependencies or `bun.lock`. So **no `bun db:push` and no
|
||||||
|
`bun install`** — a host that runs either has run something this release did not ask for. Note that the
|
||||||
|
previous release DID need a push; that was `anthropic_accounts` and it is already applied everywhere.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **The theme collection.** 147 ports from omarchythemes.com, then 44 more rescued by supporting
|
||||||
|
`colors.toml` and `0x`-prefixed hex, then a fix to accent/secondary foreground derivation across 202 of
|
||||||
|
them. This is the bulk of the diff: 229 files, ~22.5k lines, almost all data.
|
||||||
|
- **Spotlight.** Opens on **alt-space** and nothing else, from anywhere including a focused text field.
|
||||||
|
The eight-item cap is gone — core items filled it, so every plugin tile was unreachable on a populated
|
||||||
|
server. Rows draw the dock's own tiles, and the settings list went from nine hand-written entries to
|
||||||
|
twenty read off the screens that declare them.
|
||||||
|
- **Prompt history.** Up/Down only recall from an empty composer, and any other keystroke ends a recall —
|
||||||
|
so a half-written thought is never swapped for one from last week.
|
||||||
|
- **The email plugin's multi-account backend**, which arrives via `plugins:update` rather than through this
|
||||||
|
repository. `?account=<id>` names a mailbox, `?account=all` spans them. **Absent is unchanged**, so
|
||||||
|
nothing that calls those routes today behaves differently — including the mobile app and the agent's
|
||||||
|
`email_db` tool. Only hosts with the email plugin installed are affected at all.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only` — larger than usual, ~22.5k lines of theme data
|
||||||
|
2. `bun run plugins:update`
|
||||||
|
3. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 2 restarts each plugin's own sidecar, which is that script's behaviour and worth
|
||||||
|
recording in the report rather than omitting.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands and run `pm2` separately. See
|
||||||
|
`docs/servers.md` — replacing the PATH is what broke `pm2` on an earlier deploy.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it, and do NOT report it as behind
|
||||||
|
|
||||||
|
The commander runs there and skips its own host, as always. But this time the host is **already on this
|
||||||
|
code**: `officer` was restarted there many times during the night as the work landed, so it is running the
|
||||||
|
tree this tag names rather than lagging. Report it as skipped and current, not skipped and stale.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing `errored`, including the sidecars step
|
||||||
|
2 bounced.
|
||||||
|
|
||||||
|
A theme picker still lists themes and one loads — the diff is data, so a bad parse would show as a missing
|
||||||
|
or unreadable theme rather than as a failed boot.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.22.01` says `done`.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 2026.08.22.01 — deploy report
|
||||||
|
|
||||||
|
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
|
||||||
|
No host failed, no process came back `errored`.
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
|
||||||
|
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
|
||||||
|
|
||||||
|
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `alpha` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
|
||||||
|
`~/dockers/officer.dev/platform` was not touched).
|
||||||
|
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
|
||||||
|
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
|
||||||
|
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||||
|
photos, soulseek, transmission, vault.
|
||||||
|
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
|
||||||
|
- All 19 processes `online` afterwards.
|
||||||
|
|
||||||
|
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
|
||||||
|
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
|
||||||
|
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
|
||||||
|
captured an explicit `0`.
|
||||||
|
|
||||||
|
### `fofos` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
|
||||||
|
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
|
||||||
|
touch it, and it was left alone. No tracked change.
|
||||||
|
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
|
||||||
|
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
|
||||||
|
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
|
||||||
|
fix of 2026-08-21 is holding.
|
||||||
|
|
||||||
|
### `macbookpro` — succeeded
|
||||||
|
|
||||||
|
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
|
||||||
|
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
|
||||||
|
interactive shell, exactly as the plan and `docs/servers.md` require.
|
||||||
|
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
|
||||||
|
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
|
||||||
|
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
|
||||||
|
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
|
||||||
|
and the command still exited 0 — recorded so the next reader does not chase it.
|
||||||
|
- `pm2 restart officer`: 9 → 10, `online`.
|
||||||
|
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
|
||||||
|
All `online`, none touched.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — succeeded
|
||||||
|
|
||||||
|
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||||
|
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
|
||||||
|
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
|
||||||
|
the others'. Not an error.
|
||||||
|
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, and current
|
||||||
|
|
||||||
|
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
|
||||||
|
|
||||||
|
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
|
||||||
|
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
|
||||||
|
another agent working in this same tree during the rollout. Its work was left alone; only this report was
|
||||||
|
staged, by path.
|
||||||
|
|
||||||
|
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
|
||||||
|
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
|
||||||
|
at `78db9c8c`, the tag — but the next release will include them.
|
||||||
|
|
||||||
|
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
|
||||||
|
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
|
||||||
|
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
|
||||||
|
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
|
||||||
|
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
|
||||||
|
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
|
||||||
|
|
||||||
|
## What was not done
|
||||||
|
|
||||||
|
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
|
||||||
|
happy with the rest. It needs no pull.
|
||||||
|
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
|
||||||
|
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
|
||||||
|
files arrived, not that each parses.
|
||||||
|
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 2026.08.22.02
|
||||||
|
|
||||||
|
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
|
||||||
|
authorization resolver, and the email plugin's multi-account backend.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
|
||||||
|
dropdown fails on a missing relation the first time it reads a starred list.
|
||||||
|
|
||||||
|
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
|
||||||
|
`bun.lock`. Do not run `bun install`.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
|
||||||
|
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
|
||||||
|
sorting it to the top, so neighbours survive.
|
||||||
|
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
|
||||||
|
the owner's `vimazing`.
|
||||||
|
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
|
||||||
|
Brave claims it at the browser level and the key never reaches the page.
|
||||||
|
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
|
||||||
|
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
|
||||||
|
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
|
||||||
|
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
|
||||||
|
from the database calls. No behaviour change.
|
||||||
|
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
|
||||||
|
declaration, instead of a parallel list of handlers.
|
||||||
|
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
|
||||||
|
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. **`bun db:push`** — new this release
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||||
|
report rather than being omitted.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||||
|
`docs/servers.md`.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it, and it is already current
|
||||||
|
|
||||||
|
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
|
||||||
|
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
|
||||||
|
|
||||||
|
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
|
||||||
|
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
|
||||||
|
say where it is not.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 2026.08.22.02 — deploy report
|
||||||
|
|
||||||
|
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
|
||||||
|
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
|
||||||
|
|
||||||
|
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
|
||||||
|
`package.json` `2026.08.22.02`.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
|
||||||
|
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
|
||||||
|
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
|
||||||
|
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
|
||||||
|
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
|
||||||
|
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
|
||||||
|
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
|
||||||
|
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
|
||||||
|
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
|
||||||
|
|
||||||
|
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, and current
|
||||||
|
|
||||||
|
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
|
||||||
|
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
|
||||||
|
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
|
||||||
|
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
|
||||||
|
|
||||||
|
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
|
||||||
|
the tree, the schema and the running process are all already on this release.
|
||||||
|
|
||||||
|
### `alpha` — done
|
||||||
|
|
||||||
|
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
|
||||||
|
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
|
||||||
|
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
|
||||||
|
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
|
||||||
|
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
|
||||||
|
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
|
||||||
|
|
||||||
|
### `fofos` — done
|
||||||
|
|
||||||
|
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
|
||||||
|
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
|
||||||
|
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
|
||||||
|
all 8 processes `online`. `theme_favorites` present.
|
||||||
|
|
||||||
|
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
|
||||||
|
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
|
||||||
|
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
|
||||||
|
use `POSTGRES_URL`.
|
||||||
|
|
||||||
|
### `macbookpro` — done, awake
|
||||||
|
|
||||||
|
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
|
||||||
|
like the rest. Pulled straight to `68d60e40`.
|
||||||
|
|
||||||
|
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
|
||||||
|
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
|
||||||
|
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
|
||||||
|
|
||||||
|
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
|
||||||
|
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
|
||||||
|
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
|
||||||
|
|
||||||
|
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
|
||||||
|
unrelated to this release, but it appears in the raw output above every command.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — done
|
||||||
|
|
||||||
|
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
|
||||||
|
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
|
||||||
|
processes `online`. `theme_favorites` present.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — done
|
||||||
|
|
||||||
|
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
|
||||||
|
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
|
||||||
|
`theme_favorites` present.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — done
|
||||||
|
|
||||||
|
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
|
||||||
|
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
|
||||||
|
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
|
||||||
|
|
||||||
|
## What did not happen
|
||||||
|
|
||||||
|
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
|
||||||
|
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
|
||||||
|
what this agent executes.
|
||||||
|
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
|
||||||
|
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
|
||||||
|
here rather than reporting only `officer` is the plan's explicit instruction.
|
||||||
|
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
|
||||||
|
Everything else was verified exactly as written.
|
||||||
|
|
||||||
|
## Claim record
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
|
||||||
|
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
|
||||||
|
cannot cause a second rollout.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 2026.08.23.01
|
||||||
|
|
||||||
|
The commander creation flow, rebuilt end to end — named, given a directory, and started by a button that
|
||||||
|
makes it interview you rather than waiting for a prompt somebody else wrote. Plus the delete path that
|
||||||
|
actually removes one, `/minesweeper`, and `docs:check` guarding the documentation's checkable claims.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `bun db:push` is a step. `command_hq_spawned` is new and `command_hq_sessions` gains
|
||||||
|
`name`. Without the push, Command HQ fails on a missing column the first time it lists a commander.
|
||||||
|
|
||||||
|
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.22.02..HEAD`. `package.json`
|
||||||
|
moved only to add `engines.bun` and two scripts. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **A commander is created, not configured.** Name it, choose its working directory, press Start. It reads
|
||||||
|
the directory, works out whether the job is already written down there, and either reads its
|
||||||
|
understanding back for confirmation or asks what it should be watching. The name is the brief — it is the
|
||||||
|
only thing the owner says before anything else, and it now reaches the model instead of only titling the
|
||||||
|
transcript.
|
||||||
|
- **`command_hq_sessions.name`** — the platform owns the name. It was only ever in the transcript's summary
|
||||||
|
record, which the harness cannot write until the first turn ends, so the list showed the opening brief
|
||||||
|
while the header showed the real name.
|
||||||
|
- **Deleting a commander removes it.** Four defects, each of which left something behind: the session kill
|
||||||
|
ran in the wrong process and always failed; the transcript was deleted while the harness was still
|
||||||
|
writing it, so it came straight back as an untitled chat; `chat_session_events` is keyed on officer's
|
||||||
|
session key while everything else uses the transcript id, so its rows were never matched; and the key was
|
||||||
|
resolved from an in-memory map that is empty for anything older than the process. The event rows carry
|
||||||
|
both ids, so they are asked when the maps cannot answer.
|
||||||
|
- **`GET /chat/live?kind=`** — `agent` (the default), `commander`, or `all`, with `counts` always
|
||||||
|
describing the whole set. Defaulting to agents means a client that forgets cannot be handed a commander;
|
||||||
|
the web list asks for `all` explicitly.
|
||||||
|
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
|
||||||
|
error — so answering a question during the reconnect window after a restart vanished and the card span
|
||||||
|
forever. Question answers and plan decisions now report it.
|
||||||
|
- **`/minesweeper`** — a wine session streamed to the browser over RFB. The session is not running on
|
||||||
|
`officer-dev`, so the route renders its "not running" state with a Start button. That is expected.
|
||||||
|
- **Usage limits are read live.** `limitsCache` was keyed on nothing, so switching Anthropic account showed
|
||||||
|
the previous account's percentages under the new name. The cache remains for the failure path only, keyed
|
||||||
|
per account.
|
||||||
|
- **`bun docs:check`** compares documented claims against values derived at run time — the permission
|
||||||
|
count, the PM2 entry count, the generated platform skill. It found both claims it was written to check
|
||||||
|
already wrong. **Required before tagging a release.**
|
||||||
|
- **Empty transcripts no longer list.** A file holding only harness metadata and no turns is not a
|
||||||
|
conversation; four had accumulated as "(untitled) · 0 msgs".
|
||||||
|
- **Bun is held at the 1.3 series** — `engines.bun` and `BUN_SERIES` move together. Do not run
|
||||||
|
`bun upgrade` on a host.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. **`bun db:push`** — new this release
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||||
|
the report rather than being omitted.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||||
|
`docs/servers.md`.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
This is the machine the rollout runs from, and restarting `officer` here ends the agent performing it.
|
||||||
|
Name it as skipped in the report. It is already current.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 2026.08.23.01 — deploy report
|
||||||
|
|
||||||
|
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
|
||||||
|
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
|
||||||
|
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
|
||||||
|
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
|
||||||
|
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
|
||||||
|
|
||||||
|
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
|
||||||
|
`2026.08.23.01`.
|
||||||
|
|
||||||
|
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
|
||||||
|
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
|
||||||
|
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
|
||||||
|
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
|
||||||
|
tag" would not be true of any host.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
|
||||||
|
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
|
||||||
|
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
|
||||||
|
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
|
||||||
|
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
|
||||||
|
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
|
||||||
|
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
|
||||||
|
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
|
||||||
|
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
|
||||||
|
|
||||||
|
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
|
||||||
|
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
|
||||||
|
`command_hq_sessions.name` exists, queried per host against that host's own database.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, and current
|
||||||
|
|
||||||
|
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
|
||||||
|
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
|
||||||
|
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
|
||||||
|
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
|
||||||
|
|
||||||
|
### `alpha` — done
|
||||||
|
|
||||||
|
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
|
||||||
|
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
|
||||||
|
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
|
||||||
|
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
|
||||||
|
`online`. Every other PM2 entry `online`.
|
||||||
|
|
||||||
|
### `fofos` — done
|
||||||
|
|
||||||
|
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
|
||||||
|
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
|
||||||
|
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
|
||||||
|
pulled` came back clean, as it has since the 2026-08-21 `chown`.
|
||||||
|
|
||||||
|
### `macbookpro` — done
|
||||||
|
|
||||||
|
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
|
||||||
|
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
|
||||||
|
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
|
||||||
|
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
|
||||||
|
|
||||||
|
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
|
||||||
|
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
|
||||||
|
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — done
|
||||||
|
|
||||||
|
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
|
||||||
|
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
|
||||||
|
5 → 6, `online`.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — NOT REACHED
|
||||||
|
|
||||||
|
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
|
||||||
|
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
|
||||||
|
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
|
||||||
|
the tunnel is up and this is not a tailnet problem.
|
||||||
|
|
||||||
|
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
|
||||||
|
against it: it is still on whatever it was before this release, which I could not read, because reading it
|
||||||
|
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
|
||||||
|
|
||||||
|
**This host needs a look before the next release**, and the check is one command from anywhere on the
|
||||||
|
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — NOT REACHED, host key changed
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
|
||||||
|
The fingerprint for the ED25519 key sent by the remote host is
|
||||||
|
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
|
||||||
|
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
|
||||||
|
```
|
||||||
|
|
||||||
|
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
|
||||||
|
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
|
||||||
|
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
|
||||||
|
|
||||||
|
What is known, all read locally without touching the host:
|
||||||
|
|
||||||
|
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
|
||||||
|
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
|
||||||
|
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
|
||||||
|
the ED25519 already trusted.
|
||||||
|
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
|
||||||
|
during the probe.
|
||||||
|
|
||||||
|
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
|
||||||
|
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
|
||||||
|
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
|
||||||
|
`63S0GK…`, and only then drop the stale lines with
|
||||||
|
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
|
||||||
|
channel in question.
|
||||||
|
|
||||||
|
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
|
||||||
|
|
||||||
|
## What I did not do
|
||||||
|
|
||||||
|
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
|
||||||
|
Neither was pulled, neither had a process restarted, and neither has this release's schema.
|
||||||
|
- **Did not touch `~/.ssh/known_hosts`.**
|
||||||
|
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
|
||||||
|
`deploy/2026.08.22.02..HEAD`.
|
||||||
|
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
|
||||||
|
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
|
||||||
|
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
|
||||||
|
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
|
||||||
|
- **Did not touch `officer-dev`** at all.
|
||||||
|
|
||||||
|
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
|
||||||
|
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 2026.08.23.02
|
||||||
|
|
||||||
|
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
|
||||||
|
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
|
||||||
|
push, `/sendoff` fails on a missing relation the first time it lists anything.
|
||||||
|
|
||||||
|
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
|
||||||
|
moved only its version. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
|
||||||
|
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
|
||||||
|
browses — the owner's real login home, a member's managed home — because those are different directories
|
||||||
|
and writing to the wrong one makes a delivery invisible rather than wrong.
|
||||||
|
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
|
||||||
|
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
|
||||||
|
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
|
||||||
|
accounts. The owner bypasses this as a SENDER only.
|
||||||
|
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
|
||||||
|
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
|
||||||
|
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
|
||||||
|
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
|
||||||
|
the catalogue syncs.
|
||||||
|
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
|
||||||
|
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
|
||||||
|
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
|
||||||
|
days, and the last rollout spent four attempts and a minute apiece discovering that.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
|
||||||
|
2. **`bun db:push`** — new this release
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||||
|
the report rather than being omitted.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## Hosts to expect trouble from
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
|
||||||
|
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
|
||||||
|
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
|
||||||
|
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026.08.23.02 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
|
||||||
|
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
|
||||||
|
|
||||||
|
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
|
||||||
|
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
|
||||||
|
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
|
||||||
|
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
|
||||||
|
|
||||||
|
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
|
||||||
|
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
|
||||||
|
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
|
||||||
|
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
|
||||||
|
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
|
||||||
|
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
|
||||||
|
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
|
||||||
|
| `officer-dev` | — | — | — | — | — | SKIPPED |
|
||||||
|
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
|
||||||
|
|
||||||
|
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
|
||||||
|
`sendoff_senders` are new tables, so the diff was additive.
|
||||||
|
|
||||||
|
## What restarted, beyond what the plan asked for
|
||||||
|
|
||||||
|
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
|
||||||
|
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
|
||||||
|
not a fault, and it is the larger part of what moved:
|
||||||
|
|
||||||
|
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
|
||||||
|
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
|
||||||
|
- `fofos` — one (`officer-gitea`).
|
||||||
|
- `production-pertento-bridge` — one (`officer-gitea`).
|
||||||
|
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
|
||||||
|
- `macbookpro` — none; no plugins are installed there.
|
||||||
|
|
||||||
|
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
|
||||||
|
on any host was `errored`.
|
||||||
|
|
||||||
|
## Hosts the plan flagged
|
||||||
|
|
||||||
|
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
|
||||||
|
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
|
||||||
|
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
|
||||||
|
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
|
||||||
|
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
|
||||||
|
steps, with `pm2` run separately through `$SHELL -ic`.
|
||||||
|
|
||||||
|
## Things worth knowing, none of them failures
|
||||||
|
|
||||||
|
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
|
||||||
|
it, and it was left alone.
|
||||||
|
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
|
||||||
|
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
|
||||||
|
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
|
||||||
|
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
|
||||||
|
|
||||||
|
## What was not done
|
||||||
|
|
||||||
|
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
|
||||||
|
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
|
||||||
|
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
|
||||||
|
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
|
||||||
|
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
|
||||||
|
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 2026.08.23.03
|
||||||
|
|
||||||
|
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
|
||||||
|
changes and nothing reads the new table yet.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
|
||||||
|
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
|
||||||
|
existing data.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
|
||||||
|
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
|
||||||
|
`bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
|
||||||
|
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
|
||||||
|
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
|
||||||
|
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
|
||||||
|
`db:push` and is a later release.
|
||||||
|
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
|
||||||
|
step here. It never overwrites an existing row.
|
||||||
|
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
|
||||||
|
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
|
||||||
|
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
|
||||||
|
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
|
||||||
|
(`en` → `en-GB`), with no database migration.
|
||||||
|
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
|
||||||
|
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
|
||||||
|
create.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
|
||||||
|
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
|
||||||
|
exists and holds four rows on that host. `db:push` can fail and still exit 0.
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||||
|
the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## The check that matters this release
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select count(*) from roles; -- expect 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
|
||||||
|
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
|
||||||
|
per host in the report.
|
||||||
|
|
||||||
|
No direct SQL is needed and none is authorised for this release.
|
||||||
|
|
||||||
|
## Hosts to expect trouble from
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||||
|
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
The rollout runs from here. It already holds the release and the four rows, verified locally.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 2026.08.23.03 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
|
||||||
|
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
|
||||||
|
attempted, nothing spent on it).
|
||||||
|
|
||||||
|
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
|
||||||
|
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
|
||||||
|
authorised and none was needed.
|
||||||
|
|
||||||
|
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
|
||||||
|
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
|
||||||
|
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
|
||||||
|
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
|
||||||
|
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
|
||||||
|
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
|
||||||
|
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
|
||||||
|
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
|
||||||
|
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
|
||||||
|
|
||||||
|
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
|
||||||
|
`online`** — not just `officer`, every entry.
|
||||||
|
|
||||||
|
## Notes, and what was not done
|
||||||
|
|
||||||
|
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
|
||||||
|
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
|
||||||
|
restarting `officer` on it is the owner's deliberate last step.
|
||||||
|
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
|
||||||
|
never have collided with it, so it is not a reason to skip a host.
|
||||||
|
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
|
||||||
|
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
|
||||||
|
before `officer` itself was touched.
|
||||||
|
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
|
||||||
|
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
|
||||||
|
- **Nothing was retried and nothing was repaired.** No step failed on any host.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 2026.08.23.04
|
||||||
|
|
||||||
|
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
|
||||||
|
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
|
||||||
|
agents becoming visible in both directions.
|
||||||
|
|
||||||
|
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
|
||||||
|
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
|
||||||
|
database agrees with the code, but it should report no changes.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
|
||||||
|
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
|
||||||
|
`pt-PT` are complete and in step at 581 keys.
|
||||||
|
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
|
||||||
|
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
|
||||||
|
the build** on a gap — verified by removing one key and watching it go red.
|
||||||
|
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
|
||||||
|
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
|
||||||
|
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
|
||||||
|
`Intl.PluralRules`. Roughly 23 call sites moved.
|
||||||
|
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
|
||||||
|
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
|
||||||
|
a 429 naming the window rather than burning the last of one.
|
||||||
|
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
|
||||||
|
one shows which commander started it, linking back.
|
||||||
|
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
|
||||||
|
error — so answering a question during a restart window vanished and the card spun forever.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
|
||||||
|
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
|
||||||
|
this tree and that host's database disagree about something this release did not touch.
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||||
|
report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## What to check, this release
|
||||||
|
|
||||||
|
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
|
||||||
|
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
|
||||||
|
frontend bundle.
|
||||||
|
|
||||||
|
No direct SQL is needed and none is authorised.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||||
|
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Deploy report — 2026.08.23.04
|
||||||
|
|
||||||
|
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
|
||||||
|
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
|
||||||
|
`docs/servers.md`).
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
|
||||||
|
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
|
||||||
|
(see the finding below).
|
||||||
|
|
||||||
|
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
|
||||||
|
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
|
||||||
|
and no destructive prompt was reached. No direct SQL was run; none was authorised.
|
||||||
|
|
||||||
|
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
|
||||||
|
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
|
||||||
|
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
|
||||||
|
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
|
||||||
|
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
|
||||||
|
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
|
||||||
|
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
|
||||||
|
|
||||||
|
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
|
||||||
|
|
||||||
|
## The finding: `db:push` reported changes on all five hosts
|
||||||
|
|
||||||
|
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
|
||||||
|
All five hosts reported changes. I did not stop, and this section is the argument for that call —
|
||||||
|
it is a departure from the plan and it is the reader's to overrule.
|
||||||
|
|
||||||
|
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
|
||||||
|
|
||||||
|
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
|
||||||
|
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
|
||||||
|
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
|
||||||
|
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
|
||||||
|
|
||||||
|
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
|
||||||
|
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
|
||||||
|
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
|
||||||
|
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
|
||||||
|
`src/databases/CLAUDE.md` already documents under "Composite keys".
|
||||||
|
|
||||||
|
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
|
||||||
|
|
||||||
|
```
|
||||||
|
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
|
||||||
|
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
|
||||||
|
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
|
||||||
|
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
|
||||||
|
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
|
||||||
|
|
||||||
|
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
|
||||||
|
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
|
||||||
|
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
|
||||||
|
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
|
||||||
|
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
|
||||||
|
|
||||||
|
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
|
||||||
|
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
|
||||||
|
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
|
||||||
|
next deploy agent will face the same decision with less evidence.
|
||||||
|
|
||||||
|
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
|
||||||
|
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
|
||||||
|
that had just been created identically, so it changed nothing — but it was a second write to a
|
||||||
|
production database and it belongs in the record rather than only in my reasoning.
|
||||||
|
|
||||||
|
## Per-host detail
|
||||||
|
|
||||||
|
### `alpha` — 100.64.0.1, `~/officerdev/platform`
|
||||||
|
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
|
||||||
|
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
|
||||||
|
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||||
|
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
|
||||||
|
processes `online`. Database: 63 public tables.
|
||||||
|
|
||||||
|
### `fofos` — 100.64.0.17, `~/officerdev/platform`
|
||||||
|
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
|
||||||
|
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
|
||||||
|
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
|
||||||
|
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
|
||||||
|
39 public tables.
|
||||||
|
|
||||||
|
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
|
||||||
|
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
|
||||||
|
processes `online` after. Database: 41 public tables.
|
||||||
|
|
||||||
|
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
|
||||||
|
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
|
||||||
|
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
|
||||||
|
`online` after. Database: 50 public tables.
|
||||||
|
|
||||||
|
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
|
||||||
|
exited 0, while `bun db:push` on the same checkout minutes earlier reported
|
||||||
|
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
|
||||||
|
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
|
||||||
|
Note also that `kiosk` appears on no other host.
|
||||||
|
|
||||||
|
## What I verified, and what I did not
|
||||||
|
|
||||||
|
**Verified**, by command:
|
||||||
|
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
|
||||||
|
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
|
||||||
|
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
|
||||||
|
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
|
||||||
|
contents and public table count, per host, per the table above.
|
||||||
|
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
|
||||||
|
`restart_time` incremented on all five, and every other process `online`.
|
||||||
|
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
|
||||||
|
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
|
||||||
|
43 in the shared components), matching the plan.
|
||||||
|
|
||||||
|
**Not verified:**
|
||||||
|
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
|
||||||
|
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
|
||||||
|
source that builds it, not against a rendered page. The frontend is bundled at boot and every
|
||||||
|
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
|
||||||
|
still show the old picker to a human until a hard refresh. Someone should click it once.
|
||||||
|
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
|
||||||
|
called on any host.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 2026.08.23.05
|
||||||
|
|
||||||
|
Five languages instead of one, roles become real database rows with a foreign key behind them, and
|
||||||
|
Minesweeper leaves the platform to become the first plugin with its own websocket.
|
||||||
|
|
||||||
|
**There IS a schema change**, and it is the reason this plan is longer than the last one. Two foreign keys
|
||||||
|
and one dropped CHECK, on `users` and `role_permissions`. **Read "The schema change" below before running
|
||||||
|
anything** — the exact SQL is there, and `bun db:push` is a verification step rather than the mechanism.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Five locales, complete and in step.** `en-GB`, `pt-PT`, `es-ES`, `de-DE`, `fr-FR` — 1,129 keys each. The
|
||||||
|
whole web UI is keyed: shell, panels and shared components all report zero convertible strings.
|
||||||
|
- **Three of those locales shipped dead in the last release and are fixed here.** `es-ES`, `de-DE` and
|
||||||
|
`fr-FR` were translated, in parity, and offered by the picker — while all three catalogue files still
|
||||||
|
imported only `en-GB` and `pt-PT`, so choosing one rendered the entire UI in raw keys. The parity tests
|
||||||
|
could not have caught it: they compare JSON files to each other, and a file nobody imports is as
|
||||||
|
parity-correct as one that is loaded. `lib/catalogues.test.ts` is now the thing that knows.
|
||||||
|
- **Roles are a table with a foreign key, not a TypeScript union.** `USER_ROLES` is gone, `UserRole` is
|
||||||
|
`string`, and the compile-time guard moves to a runtime check at every write path that takes a role from
|
||||||
|
outside. Renaming a role now moves its holders and grants atomically, as Postgres's job.
|
||||||
|
- **Minesweeper is a plugin** (`gitea.officer.dev/plugins/minesweeper`), not part of the platform. The
|
||||||
|
permission registry drops from 22 entries to 21 — `execution` goes 4 to 3.
|
||||||
|
- **Plugins can have websockets.** `/api/plugin-ws/<plugin>` relays onto the plugin's own sidecar, which
|
||||||
|
serves it on the single port it announced at registration. Authorisation is the plugin's own permission,
|
||||||
|
so there is no second grant to forget.
|
||||||
|
- **A plugin can declare the architecture its packages need.** `multiarch: 'i386'` runs
|
||||||
|
`dpkg --add-architecture` and `apt-get update` before installing, on apt hosts, skipped when already
|
||||||
|
enabled. A closed enum rather than a command, because it runs as root.
|
||||||
|
- **Foreign keys are named explicitly across the fleet's plugins**, ending the `db:push` churn where
|
||||||
|
Postgres truncated an over-long generated name and drizzle-kit re-created the constraint every run.
|
||||||
|
|
||||||
|
## The schema change
|
||||||
|
|
||||||
|
Both foreign keys reference `roles.name`, so **every value in `users.role` and `role_permissions.role` must
|
||||||
|
already exist as a row in `roles`**, or the constraint cannot be created.
|
||||||
|
|
||||||
|
**That was checked on every host before this tag was cut.** All five are on `deploy/2026.08.23.04`, which is
|
||||||
|
the release that created and seeded `roles`; `alpha`, `fofos`, `production-pertento-bridge` and
|
||||||
|
`deadpool-rezio-bridge` each report `roles = 4` and `0` orphans. **`macbookpro` was NOT verified** — it has
|
||||||
|
no `psql` client, so only its tag could be read. Check it there before running the SQL, by whatever means
|
||||||
|
that host offers.
|
||||||
|
|
||||||
|
Run this **first**, before `bun db:push`. It is idempotent and safe to run twice.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Refuse to proceed if any role in use is not a real row. Raises rather than half-applying.
|
||||||
|
DO $$
|
||||||
|
DECLARE bad int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO bad FROM (
|
||||||
|
SELECT u.role FROM users u LEFT JOIN roles r ON r.name = u.role WHERE r.name IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.role FROM role_permissions p LEFT JOIN roles r ON r.name = p.role WHERE r.name IS NULL
|
||||||
|
) x;
|
||||||
|
IF bad > 0 THEN
|
||||||
|
RAISE EXCEPTION 'STOP: % role value(s) have no matching roles row. Do not continue; report this.', bad;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE role_permissions DROP CONSTRAINT IF EXISTS ck_role_permissions_role;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_role') THEN
|
||||||
|
ALTER TABLE users ADD CONSTRAINT fk_users_role
|
||||||
|
FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_role_permissions_role') THEN
|
||||||
|
ALTER TABLE role_permissions ADD CONSTRAINT fk_role_permissions_role
|
||||||
|
FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
`ck_role_permissions_not_owner` stays. It is a different constraint and this release does not touch it.
|
||||||
|
|
||||||
|
Then verify **by querying**, not by reading an exit code:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
|
||||||
|
WHERE conname IN ('fk_users_role','fk_role_permissions_role',
|
||||||
|
'ck_role_permissions_role','ck_role_permissions_not_owner');
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `fk_` rows must be present and say `ON UPDATE CASCADE` with no `ON DELETE`.
|
||||||
|
`ck_role_permissions_role` must be **absent**. `ck_role_permissions_not_owner` must be **present**.
|
||||||
|
Put that output in the report — for this release it is the evidence, and "schema ✓" without it is a guess.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.05`
|
||||||
|
2. **The SQL above**, then the verification query. Stop on the `RAISE EXCEPTION` and report it.
|
||||||
|
3. `bun db:push` — expect **no changes**. If it still wants to alter something after step 2, the SQL was
|
||||||
|
incomplete; stop and report rather than letting it improvise.
|
||||||
|
4. `bun run plugins:update`
|
||||||
|
5. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only at step 5. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## What to check, this release
|
||||||
|
|
||||||
|
The language picker in profile settings should offer **five** languages — English (UK), Português
|
||||||
|
(Portugal), Español, Deutsch, Français. Pick one that is not English and look at a **panel**, not just the
|
||||||
|
shell: the last release's defect was invisible in the shell and total in the panels.
|
||||||
|
|
||||||
|
A host still offering two languages did not pick up the frontend bundle.
|
||||||
|
|
||||||
|
Minesweeper should be **absent** from the dock and `/minesweeper` should 404. It is a plugin now and no host
|
||||||
|
has it installed. That is correct, and installing it is not part of this release.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||||
|
- **`macbookpro`** — the one host whose roles table could not be verified before the tag. Check it, then
|
||||||
|
proceed; do not skip it on that account.
|
||||||
|
- All five others were on `deploy/2026.08.23.04` when this was cut, so each is exactly one release behind.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
The schema change is already applied there, verified by query, and the second `db:push` emitted no DDL.
|
||||||
|
Restarting `officer` on the host running the rollout ends the rollout. Name it as skipped and say it is
|
||||||
|
current.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# 2026.08.23.05 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||||
|
rollout) and `edge-pertento-bridge` (in the *Deliberately skipped* section of `docs/servers.md`).
|
||||||
|
|
||||||
|
Every target ended at `e15ab2303a17ed0e1d1cf59b35f34aec68875907` = `deploy/2026.08.23.05`, merged
|
||||||
|
`--ff-only` onto the tag itself. No host was left on the branch, and no host is a commit past the tag.
|
||||||
|
|
||||||
|
Schema evidence is per host, by query, below. `--force` was never used and was never needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `alpha` — succeeded
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Checkout (from PM2 `pm_cwd`) | `/home/pastilhas/officerdev/platform` |
|
||||||
|
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||||
|
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||||
|
| Tracked changes before | none |
|
||||||
|
| Untracked files | none |
|
||||||
|
|
||||||
|
Pre-check: `roles = 4`, orphan users `0`, orphan grants `0`.
|
||||||
|
|
||||||
|
SQL applied, exit 0. Verification query:
|
||||||
|
|
||||||
|
```
|
||||||
|
conname | pg_get_constraintdef
|
||||||
|
-------------------------------+-------------------------------------------------------------
|
||||||
|
ck_role_permissions_not_owner | CHECK ((role <> 'Super Admin'::text))
|
||||||
|
fk_role_permissions_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||||
|
fk_users_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `fk_` present, `ON UPDATE CASCADE`, no `ON DELETE`. `ck_role_permissions_role` absent.
|
||||||
|
`ck_role_permissions_not_owner` present. Re-queried after `db:push` — unchanged.
|
||||||
|
|
||||||
|
`bun db:push` ran clean. **It is not a no-op on this host** — see "The one thing that is not clean" below.
|
||||||
|
|
||||||
|
`bun run plugins:update`: `12/12 updated in 47s`, exit 0. That step restarted twelve plugin sidecars —
|
||||||
|
atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek,
|
||||||
|
transmission, vault.
|
||||||
|
|
||||||
|
`pm2 restart officer`: restart_time 70 → 71, `online`. All 19 `officer*` processes `online` afterwards.
|
||||||
|
|
||||||
|
### `fofos` — succeeded
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||||
|
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||||
|
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||||
|
| Tracked changes before | none |
|
||||||
|
| Untracked files | `scripts/chk.ts` — left alone, not a reason to skip |
|
||||||
|
|
||||||
|
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query returned the same
|
||||||
|
three rows as `alpha`: both `fk_` present with `ON UPDATE CASCADE`, `ck_role_permissions_role` absent,
|
||||||
|
`ck_role_permissions_not_owner` present.
|
||||||
|
|
||||||
|
`bun db:push` clean; a second `push --verbose` said **`No changes detected`** — a genuine no-op.
|
||||||
|
|
||||||
|
`bun run plugins:update`: `1/1 updated in 7s`, exit 0. Restarted `officer-gitea`.
|
||||||
|
|
||||||
|
`pm2 restart officer`: 24 → 25, `online`. All 8 processes `online`.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||||
|
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||||
|
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||||
|
| Tracked changes before | none |
|
||||||
|
| Untracked files | none |
|
||||||
|
|
||||||
|
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||||
|
same verdict.
|
||||||
|
|
||||||
|
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||||
|
|
||||||
|
`bun run plugins:update`: `1/1 updated in 5s`, exit 0. Restarted `officer-gitea`.
|
||||||
|
|
||||||
|
`pm2 restart officer`: 3 → 4, `online`. All 15 processes `online` (this host also runs eight non-Officer
|
||||||
|
PM2 entries — Crons/Dashboard/Tracking/Uptime — untouched and still `online`).
|
||||||
|
|
||||||
|
Cosmetic, pre-existing, not caused by this release: every `$SHELL -ic` command on this host prints an
|
||||||
|
nvm warning about a `globalconfig`/`prefix` in `~/.npmrc`. Nothing failed because of it.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — succeeded
|
||||||
|
|
||||||
|
Login `br`, per `docs/servers.md`.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Checkout | `/home/br/officerdev/platform` |
|
||||||
|
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||||
|
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||||
|
| Tracked changes before | none |
|
||||||
|
| Untracked files | none |
|
||||||
|
|
||||||
|
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||||
|
same verdict.
|
||||||
|
|
||||||
|
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||||
|
|
||||||
|
`bun run plugins:update`: `2/2 updated in 7s`, exit 0. Restarted `officer-email` and `officer-offscale`.
|
||||||
|
|
||||||
|
`pm2 restart officer`: 9 → 10, `online`. All 9 processes `online`.
|
||||||
|
|
||||||
|
### `macbookpro` — succeeded, and the host the plan flagged is now verified
|
||||||
|
|
||||||
|
Awake and reachable. macOS, so the PATH and `psql` notes in `docs/servers.md` all applied.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Checkout | `/Users/pastilhas/projects/officer-suite/platform` |
|
||||||
|
| HEAD before | `6e092c32` (`deploy/2026.08.23.04-48-g6e092c32`) |
|
||||||
|
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||||
|
| Tracked changes before | none |
|
||||||
|
| Untracked files | none |
|
||||||
|
|
||||||
|
It started 48 commits *past* the previous tag rather than on it — the tag was still an ancestor, so the
|
||||||
|
fast-forward was legitimate and no reset was needed.
|
||||||
|
|
||||||
|
**The roles table the plan could not verify before the tag is now verified:** `roles = 4`, orphan users
|
||||||
|
`0`, orphan grants `0`. The plan was right that there is no `psql` here — there is also no Homebrew
|
||||||
|
postgres and no postgres container. The query went through **Bun's own `SQL` client** (`bun 1.3.14`)
|
||||||
|
against the same `OFFICERDEV_POSTGRES_URL` the platform's `drizzle.config.ts` reads, so it is that host's
|
||||||
|
own database and not an inference from another one.
|
||||||
|
|
||||||
|
SQL applied through the same client, no exception raised. Verification query:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "conname": "ck_role_permissions_not_owner",
|
||||||
|
"pg_get_constraintdef": "CHECK ((role <> 'Super Admin'::text))" },
|
||||||
|
{ "conname": "fk_role_permissions_role",
|
||||||
|
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" },
|
||||||
|
{ "conname": "fk_users_role",
|
||||||
|
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `fk_` present with `ON UPDATE CASCADE` and no `ON DELETE`; `ck_role_permissions_role` absent;
|
||||||
|
`ck_role_permissions_not_owner` present. Same verdict as the other four.
|
||||||
|
|
||||||
|
`bun db:push` ran clean. It is not a no-op here either — see below.
|
||||||
|
|
||||||
|
`bun run plugins:update`: exit 0, and its whole output was **`no plugins installed — nothing to update`**.
|
||||||
|
No sidecar restarted. That is worth a second look and is covered below.
|
||||||
|
|
||||||
|
`pm2 restart officer`: 11 → 12, `online`. All 6 processes `online`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one thing that is not clean
|
||||||
|
|
||||||
|
The plan says `bun db:push` should be a no-op after the SQL, and that anything it still wants to alter
|
||||||
|
means the SQL was incomplete. On two hosts it still wants to alter something. **It is not the release's
|
||||||
|
schema change, and the SQL was not incomplete** — the verification query above proves the constraints
|
||||||
|
this release introduced are exactly right on all five hosts, and re-querying after `push` on `alpha`
|
||||||
|
showed them unchanged.
|
||||||
|
|
||||||
|
What `push --verbose` still wants, after the SQL:
|
||||||
|
|
||||||
|
- **`alpha`** — drops and immediately re-adds `pk_music_now_playing` on `music_now_playing`, with an
|
||||||
|
identical definition.
|
||||||
|
- **`macbookpro`** — the same `music_now_playing` churn, plus two `atelier_collection_items` foreign
|
||||||
|
keys dropped and re-added under names one character longer (`…_id_f` → `…_id_fk`).
|
||||||
|
|
||||||
|
Both are plugin tables, both are the documented drizzle-kit mis-diff on named composite constraints and
|
||||||
|
on foreign keys whose generated name Postgres truncates at 63 characters. The `atelier` case is
|
||||||
|
precisely the churn this release's notes claim to end "across the fleet's plugins" — and on `alpha`,
|
||||||
|
where the plugins *did* update, the atelier churn is indeed gone and only the `music` one remains. On
|
||||||
|
`macbookpro` the plugins did not update at all, so it still carries both.
|
||||||
|
|
||||||
|
`fofos`, `production-pertento-bridge` and `deadpool-rezio-bridge` all reported `No changes detected` —
|
||||||
|
a true no-op, which is the outcome the plan expected.
|
||||||
|
|
||||||
|
I judged this not to be a stop condition and continued. The reasoning, so it can be overruled: the plan's
|
||||||
|
"stop and report" is aimed at the roles/FK change being half-applied, that change is provably whole on
|
||||||
|
every host, and the residue is a pre-existing self-cancelling churn on plugin tables that this release
|
||||||
|
does not touch. If that call was wrong, the thing to re-examine is `music_now_playing`'s composite
|
||||||
|
primary key — it is the one still churning on the host where plugins are current.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I did not do, and could not verify
|
||||||
|
|
||||||
|
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It is at `6cd99ddb`, one commit
|
||||||
|
*past* the tag; that commit landed on `master` after the tag was cut and is a docs commit. Its schema
|
||||||
|
change was already applied and verified before this rollout, per the plan. It is the only host not at
|
||||||
|
the tag exactly, and restarting it is the owner's deliberate last step.
|
||||||
|
- **`edge-pertento-bridge` — skipped, not a failure.** Listed in *Deliberately skipped* since
|
||||||
|
2026-08-23. Nothing was spent on it: no SSH attempt, no retry.
|
||||||
|
- **The five-language picker was NOT checked in a browser.** The plan's "what to check" asks for the
|
||||||
|
profile settings picker to offer five languages and for a *panel* to be inspected in a non-English
|
||||||
|
locale. That is a UI check and I did not perform it. What I can say is narrower: every host serves the
|
||||||
|
tag's bundle and every host restarted `officer` afterwards, so the fix is present in the code each
|
||||||
|
host is running. Whether it renders is unconfirmed.
|
||||||
|
- **Minesweeper's absence was not checked.** `/minesweeper` was not requested on any host, and the dock
|
||||||
|
was not inspected.
|
||||||
|
- **`macbookpro` reports no plugins installed** while `gen-plugin-schemas` finds three schema files on
|
||||||
|
disk (`atelier`, `kiosk`, `music`). So that host has plugin *schemas* without registered plugins. This
|
||||||
|
predates the release and nothing in the plan covers it, so I left it alone — but it explains why the
|
||||||
|
atelier constraint churn cleared on `alpha` and not here, and it means `macbookpro`'s plugins are not
|
||||||
|
being updated by the deploy at all.
|
||||||
|
- **`plugins:update` restarted sidecars on four hosts before the plan's own restart step**, as
|
||||||
|
`docs/servers.md` warns. Counted per host above rather than left as "restarted: officer".
|
||||||
|
|
||||||
|
## One note for `docs/servers.md`
|
||||||
|
|
||||||
|
The database URL key is **not the same on every host.** `alpha` has only `POSTGRES_URL`; `fofos`,
|
||||||
|
`production-pertento-bridge`, `deadpool-rezio-bridge` and `macbookpro` have only
|
||||||
|
`OFFICERDEV_POSTGRES_URL`. `drizzle.config.ts` reads `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`, so both
|
||||||
|
work for the app — but a rollout that queries the database directly must follow the same precedence or
|
||||||
|
it verifies the wrong database and says "schema ✓" about it. This cost one failed step on `fofos` before
|
||||||
|
being caught, and I re-checked `alpha` afterwards to confirm it holds only the one key and that its
|
||||||
|
verification was therefore sound.
|
||||||
|
|
||||||
|
`macbookpro` also has no `timeout(1)`. A remote command wrapping anything in `timeout` fails there with
|
||||||
|
`command not found` — and it fails *silently* in the sense that what you see is the next command's error,
|
||||||
|
not the missing binary's.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# 2026.08.23.06
|
||||||
|
|
||||||
|
Swedish, a plugin's capabilities read where they live instead of being copied, dev tools as a property of a
|
||||||
|
role, a commander as a directory rather than a session, and uploads that land atomically.
|
||||||
|
|
||||||
|
**There are TWO schema changes and `bun db:push` can apply NEITHER of them.** Both stop and ask a question
|
||||||
|
no terminal is there to answer, and then exit 0. Read "The schema changes" before running anything.
|
||||||
|
|
||||||
|
**There is also a one-off cleanup on three hosts**, and skipping it means the agent reads one affordance
|
||||||
|
twice. See "The orphan copies".
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Swedish.** `sv-SE`, complete across all three catalogues — six shipped locales now.
|
||||||
|
- **Parity checks itself.** The three parity tests listed their locales by hand, so adding a sixth added a
|
||||||
|
file none of them looked at; they read the directory now. They also check both directions — the old check
|
||||||
|
only asked whether every English key was present, so a rename reported half of itself.
|
||||||
|
- **A plugin's capabilities are LOADED, not copied.** Affordances, notifications and skills are read from
|
||||||
|
each installed and enabled plugin's own directory. `capabilities/` is meant to be forked and was
|
||||||
|
accumulating files nobody authored. A copy also went stale on update and survived a disable.
|
||||||
|
- **Roles phase 3.** `rolePermitsDevTools()` was `role === 'Developer'` gating a Postgres role and a
|
||||||
|
rootless Docker daemon. Two boolean columns carry it now, so a role created at runtime can hold dev tools.
|
||||||
|
- **Installing a plugin grants it to every role.** Deny-by-default meant every install needed a second
|
||||||
|
decision on another screen, which mostly got forgotten. The owner revokes instead. Rows, not a rule
|
||||||
|
change — absence still denies.
|
||||||
|
- **A commander is a directory, not a session.** Its own `$DATA_PATH/<email>/commanders/<id>/` with a
|
||||||
|
`CLAUDE.md` written at creation. `commander-block.ts` is gone.
|
||||||
|
- **Uploads land atomically** — written aside and renamed, so a watcher never sees a half-written file.
|
||||||
|
Same for SendOff.
|
||||||
|
- **Whisper priming prompts for 23 more languages.** Inert until an STT server is configured; none is.
|
||||||
|
|
||||||
|
## The schema changes
|
||||||
|
|
||||||
|
Two tables. **Run this SQL first**, then `bun db:push` to confirm it took.
|
||||||
|
|
||||||
|
**Why not `db:push`:** `roles` drops a column while adding two, and `command_hq_sessions` does the same
|
||||||
|
shape — drizzle-kit cannot tell a drop-plus-add from a rename, so it asks. With no terminal it bails, the
|
||||||
|
wrapper still exits 0, and the `postdb:push` seed then fails against an unchanged schema. Verified on
|
||||||
|
`officer-dev` twice. See `src/databases/CLAUDE.md`.
|
||||||
|
|
||||||
|
Both blocks are idempotent and safe to run twice.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ── roles: dev tools become a property of the role ──────────────────────────────────────────────
|
||||||
|
DO $$
|
||||||
|
DECLARE used int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO used FROM roles WHERE provisioning_script IS NOT NULL;
|
||||||
|
IF used > 0 THEN
|
||||||
|
RAISE EXCEPTION 'STOP: % role(s) have a provisioning_script. Do not continue; report this.', used;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN undefined_column THEN
|
||||||
|
NULL; -- already dropped by a previous run
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||||
|
|
||||||
|
-- ── command_hq_sessions: the row owns its identity, the transcript id becomes a pointer ──────────
|
||||||
|
-- Every existing commander is DELETED rather than migrated. This is deliberate and the owner's call:
|
||||||
|
-- a commander's identity, directory and CLAUDE.md are all new, so migrating means writing a path that
|
||||||
|
-- runs once and never again. Re-arm afterwards; it also exercises the flow this release exists to fix.
|
||||||
|
DELETE FROM command_hq_sessions;
|
||||||
|
|
||||||
|
ALTER TABLE command_hq_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||||
|
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS dir_name text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE command_hq_sessions ALTER COLUMN dir_name DROP DEFAULT;
|
||||||
|
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS reference_paths text[] NOT NULL DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tell the owner what you deleted** before running it, and put it in the report — a commander is somebody's
|
||||||
|
standing job, and "we removed yours" is not a footnote. On `officer-dev` it was one, `Platform Deployer`,
|
||||||
|
already deleted there.
|
||||||
|
|
||||||
|
Then verify **by query**, not by an exit code:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT name, is_system, grants_postgres, grants_docker FROM roles ORDER BY name;
|
||||||
|
|
||||||
|
SELECT column_name, is_nullable FROM information_schema.columns
|
||||||
|
WHERE table_name = 'command_hq_sessions' ORDER BY column_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Developer` must have both flags true and the other three both false.** If Developer comes back false the
|
||||||
|
seed did not run — fix it before restarting, or Developer accounts lose their Postgres role and Docker
|
||||||
|
daemon at their next provisioning. `provisioning_script` must be absent.
|
||||||
|
|
||||||
|
`command_hq_sessions` must show `dir_name` and `reference_paths`, with `session_id` nullable.
|
||||||
|
|
||||||
|
## The orphan copies
|
||||||
|
|
||||||
|
**`alpha`, `fofos` and `production-pertento-bridge` each have
|
||||||
|
`capabilities/affordances/officerdev/gitea/`** — published by the mechanism this release removes. The gitea
|
||||||
|
plugin still ships that affordance in its own directory, and affordances are deliberately NOT
|
||||||
|
de-duplicated, so after this release both load and the agent reads the same guidance twice.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <the capabilities directory beside the checkout>
|
||||||
|
git status --porcelain # expect only ?? extensions/ afterwards
|
||||||
|
rm -rf affordances/officerdev
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe because it is a copy: the source is `plugins/gitea/affordances/`, which is what now gets read.
|
||||||
|
`deadpool-rezio-bridge` has none; check anyway.
|
||||||
|
|
||||||
|
**`extensions/` stays.** The platform's own bootstrap writes it every boot. This release gitignores it, and
|
||||||
|
the ignore arrives with the capabilities pull in step 4.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.06`
|
||||||
|
2. **The SQL above**, both blocks, then the two verification queries. Stop on the `RAISE EXCEPTION`.
|
||||||
|
3. `bun db:push` — expect **no changes and no prompt**. If it asks anything, the SQL did not take.
|
||||||
|
4. `bun run plugins:update` — this pulls the capabilities repository too, which is how the new
|
||||||
|
`.gitignore` arrives.
|
||||||
|
5. **The orphan removal**, on the three hosts that have one.
|
||||||
|
6. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only at step 6. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It has
|
||||||
|
`psql` now and a `.zshenv` that puts it on a non-interactive PATH, so the SQL can be run there directly.
|
||||||
|
|
||||||
|
## What to check, this release
|
||||||
|
|
||||||
|
**Open a chat and ask the agent what skills it has.** The preamble is built from a new code path —
|
||||||
|
`capability-sources.ts` resolving roots per session — and the failure mode is an agent that quietly knows
|
||||||
|
less rather than an error. It should name the commander, deploy and platform skills, plus anything a plugin
|
||||||
|
ships on that host. On a host with the gitea plugin, its affordance should appear **once**.
|
||||||
|
|
||||||
|
**The language picker should offer six.** Switch to Swedish and look at a *panel*, not just the shell.
|
||||||
|
|
||||||
|
**Creating a commander should produce a directory.** `$DATA_PATH/<email>/commanders/<n>/` holding
|
||||||
|
`CLAUDE.md` and `state/`. The setup pane asks for a *reference path* now, not a working directory.
|
||||||
|
|
||||||
|
No direct SQL beyond the block above is needed, and none is authorised.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped. Down, per `docs/servers.md`.
|
||||||
|
- **`officer-dev`** — skip it. Both schema changes are already applied and verified by query there, its
|
||||||
|
capabilities repository is clean, and restarting `officer` on the host running the rollout ends the
|
||||||
|
rollout. Name it as skipped and say it is current.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 2026.08.23.06 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||||
|
rollout) and `edge-pertento-bridge` (listed under *Deliberately skipped* in `docs/servers.md`).
|
||||||
|
|
||||||
|
Every host went `e15ab230` → `2646842c` by `git merge --ff-only deploy/2026.08.23.06`. No host was
|
||||||
|
ahead, none had diverged, and no tracked file was dirty anywhere. `--force` was never used and no SQL
|
||||||
|
beyond the plan's block was run.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `alpha` — ✅
|
||||||
|
|
||||||
|
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
|
||||||
|
- **SQL:** both blocks applied. `RAISE EXCEPTION` did not fire — no role held a `provisioning_script`.
|
||||||
|
`DELETE FROM command_hq_sessions` removed **0 rows**; the table was already empty.
|
||||||
|
- **Verified by query** on `officer_dev`: `Developer` `grants_postgres=t grants_docker=t`; `Admin`,
|
||||||
|
`Member`, `Super Admin` all `f/f`; `provisioning_script` absent (0 columns); `dir_name` and
|
||||||
|
`reference_paths` present `NOT NULL`, `session_id` nullable.
|
||||||
|
- `bun db:push` exit 0, no prompt. Seed reported `0 seeded, 4 already present` — the Developer flags were
|
||||||
|
written by the seed's re-assert path, confirmed by re-querying after the push, not by the message.
|
||||||
|
- `bun run plugins:update` — **13/13 in 54s**, exit 0, `capabilities: pulled`. Every plugin's sidecar
|
||||||
|
restarted as part of this step: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, minesweeper,
|
||||||
|
music, offscale, photos, soulseek, transmission, vault.
|
||||||
|
- **Orphan removed:** `capabilities/affordances/officerdev/` existed and was deleted. Capabilities tree
|
||||||
|
clean afterwards — no leftover `?? extensions/`, so the new `.gitignore` had already arrived.
|
||||||
|
- `pm2 restart officer` — restart_time 72 → 73. **All 20 processes `online`.**
|
||||||
|
|
||||||
|
### `fofos` — ✅
|
||||||
|
|
||||||
|
- HEAD `e15ab230` → `2646842c`, remote HTTPS, no tracked changes.
|
||||||
|
- **Untracked file left in place:** `scripts/chk.ts`. Noted, not a reason to skip, not touched.
|
||||||
|
- **Database is `officer`, not `officer_dev`, and the URL is in `OFFICERDEV_POSTGRES_URL`** rather than
|
||||||
|
`POSTGRES_URL`. Matched the code's own precedence (`OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`,
|
||||||
|
`officer_db/src/db.ts:9`) so the migration went to the database the server actually opens.
|
||||||
|
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||||
|
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||||
|
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||||
|
- `bun db:push` exit 0, no prompt, no plugin schemas on disk.
|
||||||
|
- `bun run plugins:update` — **1/1 in 7s**, exit 0, `capabilities: pulled`. `officer-gitea` sidecar
|
||||||
|
restarted by this step.
|
||||||
|
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
|
||||||
|
- `pm2 restart officer` — 25 → 26. **All 8 processes `online`.**
|
||||||
|
|
||||||
|
### `macbookpro` — ✅ (awake, with two deviations worth recording)
|
||||||
|
|
||||||
|
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
|
||||||
|
- **SQL:** both blocks applied. `psql` is at `/opt/homebrew/opt/libpq/bin/psql` and on the
|
||||||
|
non-interactive PATH, as the plan said. `DELETE` removed **0 rows**.
|
||||||
|
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||||
|
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||||
|
- `bun db:push` exit 0, no prompt, run with `/opt/homebrew/bin` **appended** to PATH.
|
||||||
|
- `bun run plugins:update` — exit 0, but **`no plugins installed — nothing to update`**, so the step
|
||||||
|
exited before its capabilities pull. See *What I could not do* below.
|
||||||
|
- **No orphan to remove.** `~/projects/officer-suite/capabilities` is **not a git checkout** — it holds
|
||||||
|
only empty `agents/ extensions/ processes/ skills/ tasks/ tools/` and has no `affordances/` at all.
|
||||||
|
- `pm2 restart officer` (run as its own command, PATH untouched) — 12 → 13. **All 6 processes `online`.**
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — ✅
|
||||||
|
|
||||||
|
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
|
||||||
|
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||||
|
- **Verified by query** on `officer_dev`: `Developer` `t/t`, other three `f/f`, `provisioning_script`
|
||||||
|
absent, `dir_name` and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||||
|
- `bun db:push` exit 0, no prompt. Its shell prints an nvm/`.npmrc` `globalconfig` warning before every
|
||||||
|
command — cosmetic, pre-existing, unrelated to this release.
|
||||||
|
- `bun run plugins:update` — **1/1 in 5s**, exit 0, `capabilities: pulled`. `officer-gitea` restarted.
|
||||||
|
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
|
||||||
|
- `pm2 restart officer` — 4 → 5. **All 15 processes `online`** (nine of them are this host's non-Officer
|
||||||
|
Pertento jobs — Crons, Dashboard, Tracking, Uptime — none of which were touched).
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — ✅ (login `br`)
|
||||||
|
|
||||||
|
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
|
||||||
|
- Database is `officer` via `OFFICERDEV_POSTGRES_URL`, same shape as `fofos`.
|
||||||
|
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||||
|
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||||
|
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||||
|
- `bun db:push` exit 0, no prompt, 2 plugin schemas (email, offscale).
|
||||||
|
- `bun run plugins:update` — **2/2 in 8s**, exit 0, `capabilities: pulled`. The **email plugin advanced
|
||||||
|
`c60e3d6` → `c1c3674`** — the only plugin anywhere in this rollout that was not already current.
|
||||||
|
`officer-email` and `officer-offscale` sidecars restarted by this step.
|
||||||
|
- **No orphan**, as the plan predicted; checked anyway and `affordances/officerdev` does not exist.
|
||||||
|
- `pm2 restart officer` — 10 → 11. **All 9 processes `online`.**
|
||||||
|
|
||||||
|
## Skipped
|
||||||
|
|
||||||
|
- **`officer-dev`** — the host this rollout ran from. Nothing pulled, nothing restarted. Confirmed
|
||||||
|
current rather than assumed: HEAD is `2646842c`, the tag's commit, and a direct query shows `Developer`
|
||||||
|
`t/t` with the other three `f/f`, `provisioning_script` absent, `dir_name`/`reference_paths` present and
|
||||||
|
`session_id` nullable. Its own `officer` process is untouched and still runs the pre-restart build of
|
||||||
|
this commit — **the owner restarts it deliberately, as the last step.**
|
||||||
|
- **`edge-pertento-bridge`** — in *Deliberately skipped* since 2026-08-23. Not contacted, no retry budget
|
||||||
|
spent.
|
||||||
|
|
||||||
|
## Commanders deleted
|
||||||
|
|
||||||
|
**None, on any host.** `command_hq_sessions` held **0 rows everywhere** — every `DELETE` reported
|
||||||
|
`DELETE 0`. Each host was counted by query *before* the delete ran, so this is measured rather than
|
||||||
|
inferred. Nobody's standing job was removed by this release.
|
||||||
|
|
||||||
|
`officer-dev`'s one commander, `Platform Deployer`, had already been deleted there before the rollout
|
||||||
|
began, per the plan.
|
||||||
|
|
||||||
|
## What I could not do
|
||||||
|
|
||||||
|
- **`macbookpro` did not get the capabilities pull.** `bun run plugins:update` exits early with `no
|
||||||
|
plugins installed` and never reaches the capabilities repository, which the plan named as how the new
|
||||||
|
`.gitignore` arrives. It does not matter on that host — its capabilities directory is not a git
|
||||||
|
checkout, so there is no repository for a `.gitignore` to land in and no `affordances/` to be
|
||||||
|
duplicated. I did not invent a `git pull` the plan had not authorised. **Nothing is outstanding on that
|
||||||
|
host; the note is about the step's shape, not a missing change.**
|
||||||
|
- **The release's own "what to check" list is unrun.** Asking an agent to name its skills, switching a
|
||||||
|
panel to Swedish, and creating a commander to confirm it produces a directory are interactive checks in
|
||||||
|
a browser. This deploy agent cannot make them, so **no host has been confirmed working by use** — only
|
||||||
|
by schema query and process state.
|
||||||
|
- **`officer-dev` needs `pm2 restart officer`** to run the code it already has checked out.
|
||||||
|
|
||||||
|
## Two things worth carrying forward
|
||||||
|
|
||||||
|
**`fofos` and `deadpool-rezio-bridge` name their database differently from the other three** — the URL
|
||||||
|
lives in `OFFICERDEV_POSTGRES_URL` and the database is `officer`, not `officer_dev`. Any future plan step
|
||||||
|
that reaches for `POSTGRES_URL` alone will connect to a socket that is not there, which is what it did
|
||||||
|
here on the first attempt. The code's precedence is `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`; follow it
|
||||||
|
rather than either name.
|
||||||
|
|
||||||
|
**The seed's own output does not prove the Developer flags were written.** It prints `0 seeded, 4 already
|
||||||
|
present` on every host, because it counts inserts and the roles already existed — while the flags are
|
||||||
|
re-asserted by a separate update path. On all five hosts the flags read `f/f` immediately after the SQL
|
||||||
|
and `t/t` only after `db:push`. Nothing was wrong; the message is simply not the evidence, and the query
|
||||||
|
after the push is.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 2026.08.24.01
|
||||||
|
|
||||||
|
One frontend file: the transcribe button is hidden in the file viewer's audio player.
|
||||||
|
|
||||||
|
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` is still a step
|
||||||
|
because it is cheap and it proves the database agrees with the code, but it must report no changes.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **The transcribe button is hidden.** It calls `POST /file-browser/transcribe`, which refuses on every
|
||||||
|
server here because no STT endpoint is configured anywhere — a control that can only fail, sitting beside
|
||||||
|
OCR and extract-audio, which work. Hidden rather than deleted: speech-to-text is moving to a plugin and
|
||||||
|
the wiring stays correct for when it lands.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.01`
|
||||||
|
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
|
||||||
|
touched no schema, so a diff means that host and this tree disagree about something else.
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## What to check, this release
|
||||||
|
|
||||||
|
Open a `.m4a` in the file browser. The audio player's header should show a download and a close icon and
|
||||||
|
**no transcribe icon**. An image should still offer OCR and a video should still offer extract audio — if
|
||||||
|
either of those disappeared, too much was hidden.
|
||||||
|
|
||||||
|
A stale bundle is the likely failure. Hard refresh before deciding it did not work.
|
||||||
|
|
||||||
|
No direct SQL is needed and none is authorised.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
|
||||||
|
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. Name
|
||||||
|
it as skipped and say whether it is current.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 2026.08.24.01 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
|
||||||
|
|
||||||
|
Every host went `2646842c` → `c813eef6`, the tag commit, by fast-forward. No host was dirty in tracked
|
||||||
|
files. No `bun install` was run — the plan forbade it and `bun.lock` was untouched. No direct SQL was run
|
||||||
|
and none was authorised.
|
||||||
|
|
||||||
|
| Host | Before → after | db:push | plugins | `officer` | Result |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `alpha` | `2646842c` → `c813eef6` | applied 2 statements (see below) | 13/13 | online, 73 → 74 | ✅ |
|
||||||
|
| `fofos` | `2646842c` → `c813eef6` | applied 2 statements | 1/1 (gitea) | online, 26 → 27 | ✅ |
|
||||||
|
| `production-pertento-bridge` | `2646842c` → `c813eef6` | applied 2 statements | 1/1 (gitea) | online, 5 → 6 | ✅ |
|
||||||
|
| `deadpool-rezio-bridge` | `2646842c` → `c813eef6` | applied 2 statements | 2/2 (email, offscale) | online, 11 → 12 | ✅ |
|
||||||
|
| `macbookpro` | `2646842c` → `c813eef6` | applied 2 statements | none installed | online, 13 → 14 | ✅ |
|
||||||
|
| `officer-dev` | `c813eef6` (already) | not run | not run | **not restarted** | ⏭ skipped — self |
|
||||||
|
| `edge-pertento-bridge` | — | — | — | — | ⏭ skipped — `docs/servers.md` |
|
||||||
|
|
||||||
|
Every process on every host reported `online` after the restart: alpha 20/20, fofos 8/8,
|
||||||
|
production-pertento-bridge 15/15, deadpool 9/9, macbookpro 6/6. No process came back `errored` and none
|
||||||
|
failed to restart.
|
||||||
|
|
||||||
|
## The plan said `db:push` must report no changes. It did not, on any host.
|
||||||
|
|
||||||
|
This is a finding, and it is **not** what the plan's stop-condition describes, which is why the rollout
|
||||||
|
continued.
|
||||||
|
|
||||||
|
`bun db:push` printed `Changes applied` on all five hosts. Run again in `--strict --verbose`, it wants the
|
||||||
|
same two statements every time:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||||
|
ALTER TABLE "music_now_playing" DROP CONSTRAINT "pk_music_now_playing";
|
||||||
|
ALTER TABLE "music_now_playing" ADD CONSTRAINT "pk_music_now_playing" PRIMARY KEY("user_id","device");
|
||||||
|
```
|
||||||
|
|
||||||
|
**The same diff appears on `officer-dev`, this release's own source host, whose database has been running
|
||||||
|
this schema for days.** That is the decisive check: the plan's condition is "that host and this tree
|
||||||
|
disagree about something else", and no host disagrees with the tree — the tree disagrees with itself,
|
||||||
|
identically, everywhere. It is the drizzle-kit mis-diff already documented in `src/databases/CLAUDE.md`
|
||||||
|
under "Composite keys", here on a named composite **primary** key rather than a unique constraint, plus a
|
||||||
|
spurious array-default diff.
|
||||||
|
|
||||||
|
Verified by query per host rather than by exit code, as the doctrine requires:
|
||||||
|
|
||||||
|
- `alpha` — `pk_music_now_playing` = `PRIMARY KEY (user_id, device)`; `reference_paths` default
|
||||||
|
`'{}'::text[]`. Both exactly what the schema code says.
|
||||||
|
- `fofos`, `production-pertento-bridge`, `deadpool-rezio-bridge`, `macbookpro` — `reference_paths`
|
||||||
|
default `'{}'::text[]`. `music_now_playing` **does not exist** on these four (`information_schema`
|
||||||
|
count 0): the music plugin is not installed there, so there is nothing for that half of the diff to
|
||||||
|
touch. Not a fault.
|
||||||
|
|
||||||
|
So no host's schema is wrong. What is wrong is that `db:push` cannot serve as the proof step this plan
|
||||||
|
wanted it to be — it reports work on a database that already matches.
|
||||||
|
|
||||||
|
**What the owner should know:** on `alpha`, where the table exists, every deploy drops and re-adds a live
|
||||||
|
table's primary key for no reason. Harmless so far and verified intact afterwards, but it is real DDL on
|
||||||
|
real data, repeated each release. The fix belongs in the schema code, not in a rollout.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||||
|
cannot touch it.
|
||||||
|
- `bun run plugins:update` restarts each plugin's sidecar itself. On `alpha` that is 13 sidecars bouncing
|
||||||
|
before the `officer` restart the plan asked for. Listed above as the plugin count; "restarted" is
|
||||||
|
therefore more than `officer` alone on every host but `macbookpro`.
|
||||||
|
- `officer-dev` is **current in tree** at `c813eef6` but its `officer` process was not restarted, so it is
|
||||||
|
still serving the previous bundle. Restarting it is the owner's deliberate last step.
|
||||||
|
|
||||||
|
## Not verified by this deploy
|
||||||
|
|
||||||
|
The plan's visual check — open a `.m4a` and confirm the audio player shows no transcribe icon, while an
|
||||||
|
image still offers OCR and a video still offers extract-audio. That needs a browser and a hard refresh,
|
||||||
|
and no host was checked that way. The code is deployed; that it looks right is unconfirmed.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 2026.08.24.02
|
||||||
|
|
||||||
|
Speech-to-text leaves the platform. It is a plugin now — `transcribe`, in the catalogue.
|
||||||
|
|
||||||
|
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` stays a step because
|
||||||
|
it is cheap and proves the database agrees with the code, but it must report no changes.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **`api/stt/` and `server-settings/stt.ts` are gone**, along with `POST /chat/stt`,
|
||||||
|
`POST /file-browser/transcribe`, the FileViewer handler behind the button hidden last release, the
|
||||||
|
Settings → Speech to Text screen and the orphaned locale keys in all six locales.
|
||||||
|
- **The transcription logic moved byte for byte** into `plugins/transcribe` — the two-pass
|
||||||
|
detect-then-transcribe, the 25 language priming prompts, the timeouts. Verified identical, not rewritten.
|
||||||
|
- **`transcribe` is in the catalogue**, so it can be installed from `/plugins` on any host that wants it.
|
||||||
|
|
||||||
|
## Installing the plugin is NOT part of this release
|
||||||
|
|
||||||
|
This release only removes. A host that had speech-to-text configured loses it until somebody installs the
|
||||||
|
plugin and points it at a server — and no host here had it configured, so nothing is lost anywhere.
|
||||||
|
|
||||||
|
**Do not install it as part of the rollout.** Which hosts get transcription is the owner's decision, made
|
||||||
|
per host, and an install grants access to every role. Report that the catalogue entry is available; leave
|
||||||
|
it there.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.02`
|
||||||
|
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
|
||||||
|
touched no schema, so a diff means that host and this tree disagree about something else.
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## What to check, this release
|
||||||
|
|
||||||
|
**Settings should no longer offer Speech to Text.** That screen is gone, and its absence is the visible
|
||||||
|
change. If it is still there, the frontend bundle is stale — hard refresh before deciding it failed.
|
||||||
|
|
||||||
|
The audio player's header should still show download and close, and still no transcribe icon. Images
|
||||||
|
should still offer OCR and videos extract audio; those stay in the platform for now.
|
||||||
|
|
||||||
|
No direct SQL is needed and none is authorised.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
|
||||||
|
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. It
|
||||||
|
already has this code and the plugin installed; name it as skipped and say so.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 2026.08.24.02 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
|
||||||
|
|
||||||
|
Rolled out `deploy/2026.08.24.02` → `e0d80fd3`. Tag and `package.json` agreed. Every target moved
|
||||||
|
`c813eef6` → `e0d80fd3` — one commit, no host ahead, no host behind.
|
||||||
|
|
||||||
|
Speech-to-text left the platform. Nothing was installed in its place: the `transcribe` plugin is in the
|
||||||
|
catalogue and available, and per the plan no host was given it. That is the owner's decision, per host.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
| Host | Before | After | db:push | plugins | officer restart | processes |
|
||||||
|
| ---- | ------ | ----- | ------- | ------- | --------------- | --------- |
|
||||||
|
| `alpha` | `c813eef6` | `e0d80fd3` | no changes | 13/13 | 74 → 75 | 20/20 online |
|
||||||
|
| `fofos` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 27 → 28 | 8/8 online |
|
||||||
|
| `production-pertento-bridge` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 6 → 7 | 15/15 online |
|
||||||
|
| `deadpool-rezio-bridge` | `c813eef6` | `e0d80fd3` | no changes | 2/2 | 12 → 13 | 9/9 online |
|
||||||
|
| `macbookpro` | `c813eef6` | `e0d80fd3` | no changes | none installed | 14 → 15 | 6/6 online |
|
||||||
|
|
||||||
|
Every host ran the plan's four steps in order, all four exited 0, and none was stopped early. Checkout
|
||||||
|
paths were taken from each `officer` process's `pm_cwd`, not assumed — `alpha` resolved to
|
||||||
|
`~/officerdev/platform`, not its stale second checkout. `deadpool-rezio-bridge` was reached as `br`.
|
||||||
|
`macbookpro` was awake; its `bun` commands ran with `/opt/homebrew/bin` appended to PATH and `pm2` as a
|
||||||
|
separate command.
|
||||||
|
|
||||||
|
## Schema — verified, not assumed
|
||||||
|
|
||||||
|
The plan says no schema changed and `bun db:push` must report no changes. **It reported none on all five
|
||||||
|
hosts**, and the evidence is that it emitted no DDL: no `ALTER`, no `CREATE`, no statement of any kind
|
||||||
|
between reading the config and finishing. `[✓] Changes applied` alone was not treated as evidence —
|
||||||
|
`drizzle-kit push` prints that line whether or not it did anything, and it can exit 0 on a half-applied
|
||||||
|
change.
|
||||||
|
|
||||||
|
`stdin` was `/dev/null` on every host, so no prompt could be answered and none was. No direct SQL was
|
||||||
|
needed, none was authorised, and none was run. `--force` was not used anywhere.
|
||||||
|
|
||||||
|
The seed steps that follow `push` were no-ops everywhere, which is the same answer from a second angle:
|
||||||
|
`0 seeded, 4 already present` for roles on all five, and every transcript already recorded.
|
||||||
|
|
||||||
|
## Skipped
|
||||||
|
|
||||||
|
- **`officer-dev`** — the host this rollout ran on. Not pulled, nothing restarted. It already carries
|
||||||
|
`e0d80fd3` and has the `transcribe` plugin installed. Restarting `officer` here ends the rollout
|
||||||
|
mid-flight, so the whole host is left alone deliberately. **The owner restarts it, as the last step,
|
||||||
|
once happy with the rest.**
|
||||||
|
- **`edge-pertento-bridge`** — deliberately skipped per `docs/servers.md`, down since 2026-08-23 by the
|
||||||
|
owner's call. Not contacted at all, so it cost nothing this time.
|
||||||
|
|
||||||
|
## Notes — nothing here needs action
|
||||||
|
|
||||||
|
- **`plugins:update` bounced far more than `officer`.** Sequential per plugin, each printing
|
||||||
|
`sidecar: started`: 13 sidecars on `alpha`, 2 on `deadpool`, 1 each on `fofos` and
|
||||||
|
`production-pertento-bridge`. A report listing only `officer` under "restarted" would understate what
|
||||||
|
moved. All came back online.
|
||||||
|
- **`fofos` has one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could never
|
||||||
|
touch it, and nobody's work was discarded to ship this.
|
||||||
|
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell: `.npmrc` carries a
|
||||||
|
`globalconfig`/`prefix` setting incompatible with nvm. Cosmetic, pre-existing, unrelated to this
|
||||||
|
release, and it did not affect any step. Not fixed — that is not a rollout's job.
|
||||||
|
|
||||||
|
## What was not verified
|
||||||
|
|
||||||
|
The plan's visible check — that Settings no longer offers Speech to Text — is a **browser** check, and
|
||||||
|
this rollout has no browser. The code carrying that screen is gone from every host's tree, which is
|
||||||
|
verified; that the rendered UI reflects it is not, and a stale JS bundle would look identical to a failed
|
||||||
|
deploy. Hard refresh before concluding anything from it.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 2026.08.25.01
|
||||||
|
|
||||||
|
Commanders become usable, agents get a credential that can be bounded, and a nudge finally arrives.
|
||||||
|
|
||||||
|
96 commits — the largest release so far, and most of it is one night's work on commanders driven by
|
||||||
|
watching real setup runs.
|
||||||
|
|
||||||
|
**One schema change**, additive and safe. **No dependency change** — `bun.lock` is untouched.
|
||||||
|
**Do not run `bun install`.**
|
||||||
|
|
||||||
|
**`edge-pertento-bridge` is NOT a target for this release.** It needs a schema step no other host does.
|
||||||
|
See "Edge is excluded, and why" — this is the important part of this plan.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Commanders.** The template a new commander is created with was rewritten across the night: it asks
|
||||||
|
one question per turn in the owner's own language, proves its action on a single item before arming,
|
||||||
|
writes its own `CLASSIFICATION.md` and `DISPATCHING.md` rather than editing the platform's file, and
|
||||||
|
records per step in its ledger. The Stop control works, a deleted commander stays deleted, and its
|
||||||
|
children no longer appear in `/chat`.
|
||||||
|
- **Every agent session gets a key, scoped to `notify`.** A chat can send you a notification through
|
||||||
|
the platform instead of having no sanctioned way to reach you. It is minted per session and revoked
|
||||||
|
with it.
|
||||||
|
- **Credential narrowing** — `api_keys.permissions` (an allow list) and `api_keys.excluded_kinds`
|
||||||
|
(resolved against the live registry). Applied **before** the owner bypass, which makes it the only
|
||||||
|
thing that can bound a Super Admin — the case that matters, because a commander holds the owner's
|
||||||
|
identity unattended for months. This is the schema change.
|
||||||
|
- **`notify` moved from `app` to `core`.** It was grantable, nothing granted it, and every Member's app
|
||||||
|
was silently 403'd registering its own push token. On the bridges that is four accounts.
|
||||||
|
- **The loopback notify door is closed.** `/_officer/` paths now require a producer secret the sidecar
|
||||||
|
issues at registration. It previously accepted any caller that set a header.
|
||||||
|
- **Sidecar proxy timeouts are OFF.** `TIMEOUTS_ON = false` in `create-proxy.ts`, one line to reverse.
|
||||||
|
Bun's `server.timeout()` stores one byte, so declaring 1800 seconds gave a _shorter_ timeout than
|
||||||
|
declaring nothing. More permissive than what these hosts run today — a long transcription or a film
|
||||||
|
no longer gets cut.
|
||||||
|
- **A nudge always arrives.** Three refusals removed from the nudger. Read that commit before judging
|
||||||
|
it: the old guards meant no nudge could ever fire on a machine running commanders, silently.
|
||||||
|
- **Plugin tasks appear in the file browser**, and a direct task run is direct — no modal, no job row.
|
||||||
|
- **`/todos`**, a kanban board over the repo's own `todo/` tree, owner-only.
|
||||||
|
- **Two editor fixes** — one buffer per file across panels, and the model picker reading the right id.
|
||||||
|
|
||||||
|
## The schema change
|
||||||
|
|
||||||
|
Two nullable columns on `api_keys`:
|
||||||
|
|
||||||
|
```
|
||||||
|
permissions text[] null
|
||||||
|
excluded_kinds text[] null
|
||||||
|
```
|
||||||
|
|
||||||
|
Both null means unnarrowed, which is what every existing key is — including the mobile apps'. **No
|
||||||
|
existing credential changes behaviour.** Nothing is dropped and nothing is rewritten.
|
||||||
|
|
||||||
|
`bun db:push` applies it. **Do not take the exit code as the answer** — drizzle-kit can print an error
|
||||||
|
and still exit 0. Verify on each host, against that host's own database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select column_name from information_schema.columns
|
||||||
|
where table_name = 'api_keys' and column_name in ('permissions','excluded_kinds');
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rows, or the host is not done. `permissions` alone is a half-applied push — stop and report it.
|
||||||
|
|
||||||
|
## Every host except edge
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.25.01`
|
||||||
|
2. `bun db:push` — then **run the query above**. Two rows.
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It is
|
||||||
|
a laptop and may be asleep — unreachable is a skip with a note, not a failure.
|
||||||
|
|
||||||
|
## Edge is excluded, and why
|
||||||
|
|
||||||
|
`edge-pertento-bridge` came back on 2026-08-24 at `deploy/2026.08.22.02`, **288 commits behind**. It is
|
||||||
|
a deploy target again and its row is in `docs/servers.md`, but it must not be rolled out with the rest,
|
||||||
|
and the reason was verified on the host rather than inferred:
|
||||||
|
|
||||||
|
- **It has no `roles` table.** `to_regclass('roles')` is null.
|
||||||
|
- **Its two accounts hold `Super Admin` and `Developer`.**
|
||||||
|
|
||||||
|
Current schema makes `users.role` a foreign key onto `roles.name`. Every other host got `roles` created
|
||||||
|
and seeded by `2026.08.23.04`, then the foreign key added by `2026.08.23.05` — two releases, in order,
|
||||||
|
against an already-populated table. Edge would do both in one `db:push`, and the seeder runs from
|
||||||
|
`postdb:push`, which is **after** the push completes. So the constraint would be created against an
|
||||||
|
empty `roles` while `users.role` holds two values that are not in it.
|
||||||
|
|
||||||
|
That fails. Worse, it can fail and still exit 0, leaving the schema half-applied — which is exactly the
|
||||||
|
warning `2026.08.23.05` carried.
|
||||||
|
|
||||||
|
**It also missed two plans with one-off steps** that a single fast-forward does not replay:
|
||||||
|
`2026.08.23.05`'s pre-flight SQL, and `2026.08.23.06`'s orphan-affordance cleanup.
|
||||||
|
|
||||||
|
So: **name edge as skipped, do not attempt it, and do not report it as a failure.** Bringing it forward
|
||||||
|
is a watched, by-hand operation — push, seed, push again, verify the constraint — and it is being done
|
||||||
|
separately by the owner and this session, immediately after this rollout.
|
||||||
|
|
||||||
|
**One more thing about edge, for whoever reaches it later:** from `officer-dev` the name
|
||||||
|
`edge-pertento-bridge` does not resolve. It answers on **`100.64.0.15`**. `docs/servers.md` has the
|
||||||
|
address; use it.
|
||||||
|
|
||||||
|
## What to check, this release
|
||||||
|
|
||||||
|
**A commander can be created and asks its first question in your language.** That is the release, and
|
||||||
|
it is the thing most likely to reveal a stale bundle — hard refresh before deciding anything failed.
|
||||||
|
|
||||||
|
**A Member's phone can register for notifications.** `notify` is core now, so an account that was
|
||||||
|
403'ing should simply work.
|
||||||
|
|
||||||
|
**Nothing else should have changed for an ordinary signed-in owner.** Every narrowing column is null on
|
||||||
|
every existing key.
|
||||||
|
|
||||||
|
No direct SQL is needed on any host in this release, and none is authorised. The query in "The schema
|
||||||
|
change" is a read.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It is already on this code.
|
||||||
|
- **`edge-pertento-bridge`** — skipped, see above. Not a failure.
|
||||||
|
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||||
|
- **`macbookpro`** — a target if it is awake.
|
||||||
+8
-2
@@ -11,7 +11,13 @@ deploy/
|
|||||||
The plan is written by hand before the release commit. The report is written by the deploy commander
|
The plan is written by hand before the release commit. The report is written by the deploy commander
|
||||||
afterwards and pushed back here.
|
afterwards and pushed back here.
|
||||||
|
|
||||||
**The report's presence is what marks a release deployed.** That is the whole idempotence mechanism: a
|
**A release is a tag**, `deploy/<version>`, and nothing else triggers a deploy — not a commit message,
|
||||||
watcher that restarts checks for the report before acting, so it cannot roll the same release out twice.
|
not a branch. A tag cannot be written into prose by accident, which a code word can and which every
|
||||||
|
document about deploying does.
|
||||||
|
|
||||||
|
**What marks a release deployed is LOCAL**: `$OFFICER_ROOT/data/deploys/<version>.done`. Not the report.
|
||||||
|
If the lock were the pushed report, a failed push would mean the release looked undeployed and the next
|
||||||
|
tick would roll it out again — and pushes here fail for unrelated reasons. So the report is an artefact,
|
||||||
|
valuable and not load-bearing.
|
||||||
|
|
||||||
See `prompts/deploy-commander.md`.
|
See `prompts/deploy-commander.md`.
|
||||||
|
|||||||
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
|
|||||||
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
|
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
|
||||||
still null contributes to no directory (see above).
|
still null contributes to no directory (see above).
|
||||||
|
|
||||||
|
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
|
||||||
|
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
|
||||||
|
accepted because the web client's own type is already `'agents' | 'commanders'`.
|
||||||
|
|
||||||
|
The default is the point. Commanders run background work and the phone swipes between live chats one at a
|
||||||
|
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
|
||||||
|
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
|
||||||
|
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
|
||||||
|
built to show both, and they are the ones positioned to notice if the answer is wrong.
|
||||||
|
|
||||||
|
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
|
||||||
|
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
|
||||||
|
costing more round trips than the client-side one it replaced.
|
||||||
|
|
||||||
|
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
|
||||||
|
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
|
||||||
|
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
|
||||||
|
about which of its rows is one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Landmines
|
## Landmines
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
|
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
|
||||||
it, kept current as answers landed.
|
it, kept current as answers landed.
|
||||||
|
|
||||||
|
**Superseded in part, 2026-08-24, by `docs/commander-directory.md`.** A commander's identity moved from
|
||||||
|
Claude's transcript id to its own `command_hq_sessions` row, and it gained its own directory
|
||||||
|
(`$DATA_PATH/<email>/commanders/<dirName>/`, `CLAUDE.md` + `state/`) created BEFORE any turn spawns. That
|
||||||
|
makes several sections below describe the PREVIOUS mechanism rather than the current one — notably
|
||||||
|
"How a commander is created" (creation is no longer session-first), "Working directory" (a commander no
|
||||||
|
longer shares one directory per user, nor chooses an arbitrary one — it has its own), and the `POST
|
||||||
|
/commanders` / `POST /commanders/new` rows in "API reference" (both now require `name` and `referencePath`
|
||||||
|
and no longer accept an arbitrary `cwd`). This file was not rewritten to match — read
|
||||||
|
`docs/commander-directory.md` first for anything those sections describe, and treat the code
|
||||||
|
(`src/servers/api/command-hq/`) as the source of truth where the two disagree.
|
||||||
|
|
||||||
**Status:** data model, the full REST API including both creation paths (web and `POST
|
**Status:** data model, the full REST API including both creation paths (web and `POST
|
||||||
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
|
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
|
||||||
failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
|
failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# A commander is a directory, not a session
|
||||||
|
|
||||||
|
**Status: phase 1 built 2026-08-24** — schema, directory creation, `CLAUDE.md` at creation, the inverted
|
||||||
|
`POST /commanders` → start-turn → point-at-transcript order, `resolveChatCwd` resolving server-side, and the
|
||||||
|
frontend composer split (`NewCommanderSetup` → `ActiveNewCommander`) in `src/servers/api/command-hq/` and
|
||||||
|
`src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx`. `bunx tsgo`/`./node_modules/.bin/tsgo`
|
||||||
|
clean, suite green. **`bun db:push` has not been run** — the owner reviews and runs the database work
|
||||||
|
separately; see the schema section below for what changed and why nothing here is a migration. Phase 2
|
||||||
|
(a commander becomes a git repository) is still not built.
|
||||||
|
|
||||||
|
## What is wrong now
|
||||||
|
|
||||||
|
A commander's identity is borrowed. `command_hq_sessions.session_id` is Claude's **transcript id**, minted
|
||||||
|
by the CLI as the first turn begins — verified: the id on the live row has a transcript file under
|
||||||
|
`~/.claude/projects/`. Everything about a commander hangs off that one borrowed value, and three problems
|
||||||
|
fall out of it.
|
||||||
|
|
||||||
|
**An ordering problem.** `POST /command-hq/commanders` takes a `sessionId` the caller already holds and
|
||||||
|
validates the transcript exists, so a chat session is created first and filed as a commander second. But
|
||||||
|
the working directory is fixed when the session spawns — before the id exists. There is no point in the
|
||||||
|
current flow where a commander can be started *in a directory named after itself*.
|
||||||
|
|
||||||
|
**A first-turn interlock that is a side effect.** `commanderBlock` compiles the paths a commander must
|
||||||
|
record itself at into every turn, and on the first turn they cannot be resolved, so the block is empty.
|
||||||
|
The commander skill turns that into a rule: *"if you have not been given paths, you are on your first
|
||||||
|
turn."* It works, and it is a signal derived from a failure rather than something anybody said. That
|
||||||
|
block's own comment explains the awkwardness — it rides `effectivePrompt` rather than the session
|
||||||
|
preamble, because the preamble is built once and the first turn has no id to build it from.
|
||||||
|
|
||||||
|
**Losing the conversation loses the commander.** State lives in the transcript and in a second tree,
|
||||||
|
`commander-state/<transcript id>/`. A session that dies takes its commander with it.
|
||||||
|
|
||||||
|
## The design
|
||||||
|
|
||||||
|
**The directory IS the commander. The session is how you talk to it.**
|
||||||
|
|
||||||
|
```
|
||||||
|
$DATA_PATH/<email>/commanders/<commander id>/
|
||||||
|
CLAUDE.md written at creation, extended by the commander as it learns the job
|
||||||
|
spawned session ids of the agents its loop started
|
||||||
|
<whatever it writes> scripts, procedures, its record of what it has handled
|
||||||
|
```
|
||||||
|
|
||||||
|
The id is one the **platform** owns, generated when the commander is created. Nothing waits on the CLI.
|
||||||
|
|
||||||
|
The flow becomes linear:
|
||||||
|
|
||||||
|
1. The user gives a name and a reference path, and clicks Start.
|
||||||
|
2. The platform creates the row and the directory.
|
||||||
|
3. It writes `CLAUDE.md` into the directory: the name, the reference path, and the standing guidance
|
||||||
|
`commanderBlock` currently injects.
|
||||||
|
4. It spawns the session with `cwd` set to that directory.
|
||||||
|
5. The transcript id arrives later and is recorded **on the row**, as a pointer to the current
|
||||||
|
conversation.
|
||||||
|
|
||||||
|
**The API does not change.** Same endpoints, same shape from the client's side. What changes is the server
|
||||||
|
being able to do things in an order that works.
|
||||||
|
|
||||||
|
## What this buys
|
||||||
|
|
||||||
|
**`CLAUDE.md` needs no mechanism.** Claude Code reads one from the working directory at session start. So
|
||||||
|
the living document is free: the commander edits its own file, and the next turn has it. No injection, no
|
||||||
|
per-turn cost, no block riding `effectivePrompt`.
|
||||||
|
|
||||||
|
**A commander survives its conversation.** Everything it knows is on disk. A session that dies can be
|
||||||
|
replaced by a fresh one in the same directory, and the commander carries on with its scripts, its
|
||||||
|
procedures and its record of what it has already handled. Today that is a total loss.
|
||||||
|
|
||||||
|
**One directory instead of two.** `commander-state/<id>/` collapses in. Deleting a commander becomes
|
||||||
|
deleting a directory, which is also the only way to be sure nothing is left behind.
|
||||||
|
|
||||||
|
**The first turn can be stated rather than inferred.** With the file written before the session exists,
|
||||||
|
the platform can simply say which turn it is on. The interlock stops being a trick.
|
||||||
|
|
||||||
|
## The reference path
|
||||||
|
|
||||||
|
**What was the working directory becomes a reference** — what the commander operates ON, not where it
|
||||||
|
lives. One is collected in the start dialog, because a person can name one thing and asking for a list up
|
||||||
|
front gets a worse answer than asking later.
|
||||||
|
|
||||||
|
**More can be added in conversation.** A voice-memos commander watches one folder; a life manager might
|
||||||
|
watch memos, scans and a mailbox. So this is a list on the row, seeded with one, and the commander writes
|
||||||
|
additions into its own `CLAUDE.md` as they are agreed.
|
||||||
|
|
||||||
|
## The two tiers, and why a commander should get cheaper
|
||||||
|
|
||||||
|
This is the point of the whole design, and it generalises past any particular commander.
|
||||||
|
|
||||||
|
**The script gathers. The agent judges.**
|
||||||
|
|
||||||
|
A commander has two kinds of work and they have opposite economics. A script runs per event — every email,
|
||||||
|
every voice memo — and costs nothing, so there is no ceiling on volume. An agent runs per batch, on
|
||||||
|
material a script already assembled, and costs a turn every time it wakes. So the rule for building any
|
||||||
|
commander is: push as much as possible down into the script, and let the agent see only what actually
|
||||||
|
needs judgement.
|
||||||
|
|
||||||
|
Detecting a one-time code in an email is a script. Reading fifty messages and deciding which of them the
|
||||||
|
owner would want to know about is an agent. A watcher that transcribes a recording is a script; deciding
|
||||||
|
what a week of transcripts means is an agent.
|
||||||
|
|
||||||
|
**And the tiers are not fixed, which is the part that matters.**
|
||||||
|
|
||||||
|
Because the commander owns its own scripts — they are files in its directory, and it can edit them — every
|
||||||
|
correction moves work downward. The owner says "this one was missed, it had eight digits rather than six",
|
||||||
|
and a decision that required judgement becomes a rule that does not. The agent tier gets thinner; the free
|
||||||
|
tier gets better.
|
||||||
|
|
||||||
|
**A commander that runs for six months should cost less per event than it did in week one, not more.**
|
||||||
|
|
||||||
|
That is the opposite of how automation usually ages, and it is a direct consequence of two decisions made
|
||||||
|
here rather than a hope: the agent owns its scripts, and the corrections arrive through dialogue rather
|
||||||
|
than through a ticket to somebody else. Nothing improves a system it cannot edit.
|
||||||
|
|
||||||
|
Two things this requires, and both are cheap if done from the start:
|
||||||
|
|
||||||
|
- **The script's verdict must be reproducible.** Not logged — RE-RUN. The input is still in the mailbox or
|
||||||
|
the folder, and the script is deterministic, so diagnosing a miss is running it again and watching. A
|
||||||
|
log would be a second copy of something already on disk.
|
||||||
|
- **A change is checked against what came before.** The corpus already exists — the last few hundred
|
||||||
|
messages, the transcripts so far. Re-run the old script and the new one over it and compare; anything
|
||||||
|
whose verdict changed is either the bug just fixed or one just introduced. Without that, the fix for
|
||||||
|
eight-digit codes quietly starts flagging every order confirmation, unattended, at three in the morning.
|
||||||
|
|
||||||
|
`capabilities/skills/commander/SKILL.md` should teach this. Somebody building the fourth commander needs
|
||||||
|
the rule more than they need the worked example.
|
||||||
|
|
||||||
|
## What the directory holds: definition versus state
|
||||||
|
|
||||||
|
Two kinds of thing live in there and they must not mix, because one of them travels and the other must
|
||||||
|
never.
|
||||||
|
|
||||||
|
```
|
||||||
|
$DATA_PATH/<email>/commanders/<commander id>/
|
||||||
|
CLAUDE.md DEFINITION — the living document, extended as the job is agreed
|
||||||
|
parameters.* DEFINITION — the things a fork has to change. Paths, cadences, thresholds
|
||||||
|
procedures/ DEFINITION — what a spawned agent reads at fire time
|
||||||
|
scripts/ DEFINITION — the loops
|
||||||
|
state/ INSTANCE — what it has handled, what it spawned, its loop's pid
|
||||||
|
```
|
||||||
|
|
||||||
|
**Separate from the first commit, not sorted out later.** A shareable bundle is "everything except
|
||||||
|
`state/`" only if that boundary is structural; mixed together, extracting one means picking files apart by
|
||||||
|
hand and getting it wrong once. Same reasoning as plugin capabilities being read in place rather than
|
||||||
|
copied — a directory boundary beats a convention, because a convention is what a tired person violates.
|
||||||
|
|
||||||
|
The failure it prevents is specific: a recipient's commander starting up believing it has already handled
|
||||||
|
your recordings, and silently doing nothing.
|
||||||
|
|
||||||
|
**Parameters are separate from procedure, or forking means editing code.** With `~/Documents/VoiceMemos`
|
||||||
|
baked into a script, a fork is a debugging session. Declared in one place, a fork is a conversation — the
|
||||||
|
new owner is asked where their memos land and the commander rewrites its own file. That difference is the
|
||||||
|
whole point of the thing being an agent rather than a template.
|
||||||
|
|
||||||
|
## Phase 2 — a commander becomes a git repository
|
||||||
|
|
||||||
|
**Not now.** Recorded because the phase 1 layout has to leave room for it, and it costs nothing today to
|
||||||
|
leave that room.
|
||||||
|
|
||||||
|
**There is no button.** The owner asks the commander, in conversation, to make itself shareable, and it
|
||||||
|
does — `git init`, a `.gitignore` that excludes `state/`, a first commit. That is the same shape as
|
||||||
|
everything else here: the commander maintains its own directory, and the platform does not reach in.
|
||||||
|
|
||||||
|
**What it needs to know goes in the commander SKILL, not an affordance.** The layout, what is tracked,
|
||||||
|
what is ignored and how credentials are handled is reference material — read on the one day somebody asks
|
||||||
|
for it. An affordance's body is charged to every turn of every commander forever, and this would earn that
|
||||||
|
cost approximately never. `capabilities/skills/commander/SKILL.md` gains a section.
|
||||||
|
|
||||||
|
**A commander never holds a service's connection details, and that is a boundary rather than a
|
||||||
|
precaution.** The Whisper URL is not the commander's business: it knows the platform can transcribe and
|
||||||
|
asks the platform, which holds the connection. So there is usually nothing sensitive in a commander to
|
||||||
|
leak, and the sharing story is simple for a reason rather than by luck.
|
||||||
|
|
||||||
|
Where a commander genuinely does need a secret of its own, the same rule applies for the obvious reason: a
|
||||||
|
credential is definition-shaped — it looks like configuration — so excluding `state/` does not protect it,
|
||||||
|
and a shared repository is exactly the artefact where one gets published and stays published. Name what
|
||||||
|
must be configured; let the platform hold the value.
|
||||||
|
|
||||||
|
**Whether the platform is actually configured is not this system's problem.** A commander depending on
|
||||||
|
transcription can be armed on a server where nobody set up a Whisper endpoint, and it would produce
|
||||||
|
nothing. That is a system administration mistake, and the answer is already in the commander skill rather
|
||||||
|
than in new machinery: **do the first run by hand, then arm.** A first run against something unconfigured
|
||||||
|
fails in front of the person doing it. The same rule catches the shared case — somebody forking this onto
|
||||||
|
their own machine finds out on their first run instead of at 3am.
|
||||||
|
|
||||||
|
The forking story then reads: clone, open a conversation, answer where your memos land and what you want
|
||||||
|
done with them, and the commander rewrites its own parameters and arms.
|
||||||
|
|
||||||
|
## The consequence for the skill
|
||||||
|
|
||||||
|
`capabilities/skills/commander/SKILL.md` says turn one is orientation — *"read where you actually are
|
||||||
|
before you ask anything"* — and that assumes `cwd` is the thing worth exploring. Under this design the
|
||||||
|
commander's own directory is nearly empty on turn one and the interesting thing is somewhere else.
|
||||||
|
|
||||||
|
That guidance has to change with the code, or every new commander will orient on an empty folder and ask
|
||||||
|
a worse question than it does today.
|
||||||
|
|
||||||
|
## The schema change, and why there is no migration
|
||||||
|
|
||||||
|
`session_id` stops being the commander's identity and becomes a nullable pointer to its current
|
||||||
|
conversation. The row gains its own id, its directory, and its reference paths.
|
||||||
|
|
||||||
|
**Nothing is migrated.** The one commander on this machine is deleted and re-armed from scratch on the new
|
||||||
|
system — the owner's call, made on 2026-08-24, and the right one: it is unarmed and has never fired, so
|
||||||
|
migrating it would mean writing and testing a path that runs exactly once and then never again. Re-arming
|
||||||
|
also exercises the flow this design exists to fix, which a migration would route around.
|
||||||
|
|
||||||
|
That decision has a shelf life. It is free while one commander exists and unarmed; it stops being free the
|
||||||
|
moment somebody is relying on one. Build this before that happens, or write the migration.
|
||||||
|
|
||||||
|
## What this does not decide
|
||||||
|
|
||||||
|
**Whether a commander's loops are scripted or agentic.** Both, in practice — a watcher that transcribes
|
||||||
|
does not need judgement and should not spend a turn per file, while a job that reads transcripts and
|
||||||
|
decides what to do with each of them is agentic by nature. The directory holds the procedure either way.
|
||||||
|
|
||||||
|
**How a commander learns what the platform can do.** It reads the generated platform skill and, for a
|
||||||
|
plugin, that plugin's own skill. That is a separate gap: a plugin must ship a skill documenting its routes
|
||||||
|
or a commander can see it is installed and still not know how to call it.
|
||||||
@@ -23,19 +23,78 @@ Two details from that report do most of the diagnostic work:
|
|||||||
Do not re-investigate these without new evidence.
|
Do not re-investigate these without new evidence.
|
||||||
|
|
||||||
| Candidate | Why it is out |
|
| Candidate | Why it is out |
|
||||||
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
||||||
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
||||||
| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. |
|
| ~~Two Monaco editors sharing a model~~ **— WRONG, see below** | This asked which OTHER component mounts Monaco and answered `EditorRenderer` (still true: no `path`, so `inmemory://model/N`, no collision). It never asked whether the **code editor collides with ITSELF**. It does — the panel is addable to a dashboard twice. |
|
||||||
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
||||||
|
|
||||||
That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic
|
That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic
|
||||||
`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about.
|
`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about.
|
||||||
|
|
||||||
One thing to check first, because it is recent and it is ours: `2b70a570` gave `<Editor>` a real `path`,
|
## Two code-editor panels share one model and two buffers (2026-08-25)
|
||||||
so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share
|
|
||||||
one model. That should be an improvement, and it is a change in exactly the layer that turns out to be
|
The suspicion recorded here — `2b70a570` giving `<Editor>` a real `path`, so models are keyed by file URI
|
||||||
involved.
|
**globally** rather than per mount — is correct, and it is worse than "should be an improvement". Verified
|
||||||
|
by reading `@monaco-editor/react@4.7.0`'s built source, not inferred:
|
||||||
|
|
||||||
|
- **Model lookup is `getModel(Uri.parse(path)) || createModel(value, …)`.** An existing model for that path
|
||||||
|
wins and the second mount's `value` is **ignored at creation**. So the second panel to open a file adopts
|
||||||
|
the first panel's buffer.
|
||||||
|
- **The value-sync effect then overwrites it**, because `value !== editor.getValue()`:
|
||||||
|
`executeEdits('', [{ range: getFullModelRange(), text: value }])`.
|
||||||
|
- **The guard that suppresses `onChange` during that write is per-INSTANCE.** The package sets an internal
|
||||||
|
flag around its own `executeEdits` and checks it inside `onDidChangeModelContent`. Panel B's flag is not
|
||||||
|
panel A's. So B's programmatic write **fires A's `onChange`** with B's content.
|
||||||
|
- **Unmount disposes the shared model.** `keepCurrentModel` defaults to `false` and we never set it, so the
|
||||||
|
cleanup calls `editor.getModel()?.dispose()`. And `CodeEditor` mounts `<Editor key={activeFile.path}>`,
|
||||||
|
so this runs on every **tab switch**, not only on panel close — switching tabs in one panel disposes the
|
||||||
|
model another panel is still displaying.
|
||||||
|
|
||||||
|
The other half is ours: `useEditorState` holds `files` in a plain `useState`, so it is **per panel
|
||||||
|
instance**. Its own comment says so — "this app also mounts as a dashboard panel, and a dashboard can hold
|
||||||
|
two of them". One shared model, two independent `content` values, and an `onChange` that crosses between
|
||||||
|
them. `CodeEditor`'s handler does `setContent(activeFile.path, value)`, so panel A's buffer silently becomes
|
||||||
|
panel B's content and `isDirty` flips with no keystroke — dirty tab, content the user never typed, disk
|
||||||
|
untouched, fixed by reopening. That is the reported symptom's exact shape.
|
||||||
|
|
||||||
|
**What this does NOT yet explain is how JOB OUTPUT becomes one of those `value`s.** Nothing in the app
|
||||||
|
writes job output into `useEditorState` — `openFile`/`setContent`/`reloadFile` are called only from
|
||||||
|
`CodeEditor.tsx`, and the task log renders through shiki. So this is a real defect that produces the right
|
||||||
|
shape, and it is not proven to be the reported instance. **The trap is still the thing that names the
|
||||||
|
text's source.** Do not delete it on the strength of this section.
|
||||||
|
|
||||||
|
## Fixed 2026-08-25: the buffer is shared, because the model already was
|
||||||
|
|
||||||
|
`useEditorState` now keys buffers by path in one store shared across panels, and keeps only the tab set and
|
||||||
|
the active path per panel. The rule that matters: **`openFile` discards the content it is handed when the
|
||||||
|
file is already open somewhere else.** That mirrors what Monaco does with the model, and it is what removes
|
||||||
|
the divergence — with both panels reading one buffer there is never a `value` for the sync effect to
|
||||||
|
reconcile, so the `executeEdits` never runs and the cross-panel `onChange` never fires.
|
||||||
|
|
||||||
|
A buffer outlives the tab that opened it. `holders` is the set of panels with the file open, and the entry
|
||||||
|
is dropped when the last one closes, so closing a tab in one panel cannot take unsaved work out from under
|
||||||
|
another. Closing the last one still discards unsaved edits, exactly as before.
|
||||||
|
|
||||||
|
`<Editor keepCurrentModel>` is the other half, and it was needed under any fix: the default disposes the
|
||||||
|
shared model on unmount, and `key={activeFile.path}` makes that every **tab switch**, not just a panel
|
||||||
|
close. The cost is one retained model per file opened in a session.
|
||||||
|
|
||||||
|
Two things worth knowing:
|
||||||
|
|
||||||
|
- **Writes go through `queryClient.setQueryData`, not `useGlobal`'s setter.** `useGlobal` applies a
|
||||||
|
functional update to the `data` captured in render rather than to what is in the cache now. With one
|
||||||
|
writer that is invisible; this slot has one writer per panel, which is the whole scenario.
|
||||||
|
- **The trap can now fire legitimately.** Two panels on one file share a model, so a large paste in one
|
||||||
|
fires the other's `onChange` while it is unfocused — which is what `reportSuspiciousEdit` looks for. A
|
||||||
|
report whose `stack` runs through the editor's own `onChange` with another panel open is that, not the
|
||||||
|
bug. This is the reason to keep reading the `stack` rather than the count.
|
||||||
|
|
||||||
|
Pinned by `useEditorState.test.ts` (9 tests): adoption, cross-panel edit, close-with-holders, drop on last
|
||||||
|
close, release on unmount, and that the tab set stays per panel.
|
||||||
|
|
||||||
|
**Still not closed.** This removes the mechanism, not the mystery: where the log text came from is still
|
||||||
|
unknown, and nothing found so far writes job output into an editor buffer. Keep the trap.
|
||||||
|
|
||||||
## When it is reported
|
## When it is reported
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Filesystem quotas — the prerequisite for per-user and per-team storage limits
|
||||||
|
|
||||||
|
**Status: planned, not done. Nothing in the platform reads or writes a quota yet.**
|
||||||
|
|
||||||
|
This is the host-side half. It makes limits *enforceable*; it does not create any. The platform side —
|
||||||
|
the owner declaring defaults and maxima, and provisioning applying them — is `docs/roles-and-teams.md`
|
||||||
|
→ "Quotas", and it is not built either. Do this first, because a policy nothing enforces is worse than
|
||||||
|
no policy: it reads as a limit and is not one.
|
||||||
|
|
||||||
|
## Why the kernel and not the platform
|
||||||
|
|
||||||
|
Members hold `terminal` at `write` and have their own Linux accounts. A quota the platform tracks by
|
||||||
|
counting bytes through the file browser is decorative — a member opens a terminal, or connects over SSH
|
||||||
|
with their own key, and writes past it without the platform seeing the write.
|
||||||
|
|
||||||
|
Only the filesystem can refuse. So this is a provisioning feature, not an accounting one.
|
||||||
|
|
||||||
|
## Where it matters
|
||||||
|
|
||||||
|
**`edge-pertento`.** It is the first real multi-user deployment and the only host that will hold accounts
|
||||||
|
that are not the owner's. It already has one account, which is the argument for doing this soon rather
|
||||||
|
than carefully later: moving a home directory is easy once and tedious per person.
|
||||||
|
|
||||||
|
`officer-dev` is a development machine and will not be multi-user. `alpha` is the owner's own. Neither
|
||||||
|
needs this, and neither can do it without the same downtime, so there is no reason to.
|
||||||
|
|
||||||
|
## What to check BEFORE booting into rescue
|
||||||
|
|
||||||
|
Run these on the live machine. They decide whether the procedure below is the right one.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt -no FSTYPE,SOURCE,TARGET / # expect ext4, and note the real device
|
||||||
|
findmnt -no OPTIONS / | tr ',' '\n' | grep -i quota # expect nothing — that is why we are here
|
||||||
|
sudo tune2fs -l "$(findmnt -no SOURCE /)" | grep -iE 'features|Inode size'
|
||||||
|
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
|
||||||
|
df -h /home
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things decide the plan:
|
||||||
|
|
||||||
|
1. **Is `/home` its own filesystem, or part of `/`?** On a stock Hetzner image it is part of `/`, so the
|
||||||
|
root filesystem is what gains the feature.
|
||||||
|
2. **Inode size.** Only matters if you use project quotas — they need 256 bytes and cannot be enabled on a
|
||||||
|
128-byte filesystem without recreating it. The plan below avoids project quotas for exactly this reason.
|
||||||
|
3. **Is the device under LVM?** `alpha` is; `officer-dev` is not. It changes only the device path.
|
||||||
|
|
||||||
|
## The plan: user quotas and group quotas, not project quotas
|
||||||
|
|
||||||
|
Two mount options cover both cases and neither needs a feature the filesystem might lack:
|
||||||
|
|
||||||
|
- **`usrquota`** — a limit per Linux user. This is the per-account home directory limit.
|
||||||
|
- **`grpquota`** — a limit per Linux group. **This is how a team's shared folder gets a limit**, because a
|
||||||
|
team is already a Linux group (see `docs/roles-and-teams.md` — the group is what keeps one team out of
|
||||||
|
another's folder, not the permission grant).
|
||||||
|
|
||||||
|
Project quotas (`prjquota`) would let a *directory* carry a limit independent of ownership. That is more
|
||||||
|
flexible and it is not needed: every case here is "this user" or "this team", both of which are already
|
||||||
|
identities the kernel knows. Skipping it also skips the inode-size problem entirely.
|
||||||
|
|
||||||
|
**For the group quota to work, the team folder must be `setgid`** (`chmod g+s`), so files created inside it
|
||||||
|
inherit the team's group and are charged to it. Without that, a member's files are charged to the member's
|
||||||
|
own primary group and the team limit counts nothing. This is the single easiest thing to get wrong here.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
Downtime is roughly ten minutes, most of it reboots. The owner has said downtime is acceptable.
|
||||||
|
|
||||||
|
**1. Install the tools, on the live machine.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update && sudo apt-get install -y quota
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Take note of the root device**, from the pre-flight above — `/dev/sda1` on a stock image,
|
||||||
|
`/dev/mapper/<vg>-<lv>` under LVM. Every command below uses it.
|
||||||
|
|
||||||
|
**3. Boot into Hetzner rescue** from the Cloud console, and do not mount the filesystem.
|
||||||
|
|
||||||
|
**4. Check it, then add the feature.** `tune2fs` refuses on a filesystem that is not clean, and the check
|
||||||
|
is worth running anyway before changing metadata:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
e2fsck -f /dev/sda1
|
||||||
|
tune2fs -O quota /dev/sda1
|
||||||
|
tune2fs -l /dev/sda1 | grep -i features # 'quota' must now appear
|
||||||
|
```
|
||||||
|
|
||||||
|
`quota` here is the ext4 *journaled quota* feature. It is what lets quotas come up with the filesystem
|
||||||
|
rather than needing a `quotacheck` scan at every boot, which on 320 GB is not quick.
|
||||||
|
|
||||||
|
**5. Add the mount options.** Back on the normal system — or by mounting the root filesystem from rescue
|
||||||
|
and editing it there — the `/` line in `/etc/fstab` gains `usrquota,grpquota`:
|
||||||
|
|
||||||
|
```
|
||||||
|
UUID=<unchanged> / ext4 defaults,usrquota,grpquota 0 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Reboot into the normal system and confirm.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt -no OPTIONS / | tr ',' '\n' | grep quota # usrquota, grpquota
|
||||||
|
sudo quotaon -p / # both should report "is on"
|
||||||
|
sudo repquota -s / # a table, not an error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify it actually refuses
|
||||||
|
|
||||||
|
Enabling quotas and setting none is the failure that looks like success. Prove enforcement once, on a
|
||||||
|
throwaway account, before trusting it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -m quotaprobe
|
||||||
|
sudo setquota -u quotaprobe 10240 10240 0 0 / # 10 MB soft and hard
|
||||||
|
sudo -u quotaprobe dd if=/dev/zero of=/home/quotaprobe/fill bs=1M count=50
|
||||||
|
# expect: "Disk quota exceeded" and a file of about 10 MB, NOT 50
|
||||||
|
sudo userdel -r quotaprobe
|
||||||
|
```
|
||||||
|
|
||||||
|
If that writes 50 MB, quotas are not on, whatever the mount options say.
|
||||||
|
|
||||||
|
## What this does not do
|
||||||
|
|
||||||
|
- **It sets no limits.** Every existing account is unlimited until something assigns a quota. That is the
|
||||||
|
correct intermediate state: the machine gains the ability, nobody's behaviour changes.
|
||||||
|
- **It does not touch the platform.** `provision-os.ts` does not call `setquota` yet, and there is nowhere
|
||||||
|
for the owner to declare a default. That is the other half.
|
||||||
|
- **It does not apply to the team folders**, because there are no team folders yet. When they arrive they
|
||||||
|
must be `setgid` or their group quota counts nothing.
|
||||||
|
|
||||||
|
## When the platform half is built
|
||||||
|
|
||||||
|
`POST /users/:id/provision-linux` already exists as a retry path and is the natural home for this: it
|
||||||
|
becomes "re-apply current policy to this account", covering both a new account and a changed limit.
|
||||||
|
|
||||||
|
**On a host where quotas are not enabled, the platform must say so** — "policy recorded, not enforceable on
|
||||||
|
this host" — rather than storing numbers that do nothing. Same rule as the plugin dependency check: refuse
|
||||||
|
or report, never silently no-op. `quotaon -p` is the check, and it is cheap enough to run per request.
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# Localization
|
||||||
|
|
||||||
|
**Status: BUILT and shipping six locales** — en-GB, pt-PT, es-ES, de-DE, fr-FR, sv-SE. The mechanism
|
||||||
|
is done; what remains is coverage, which is the count-taking below.
|
||||||
|
|
||||||
|
**To add a seventh language, read "Adding a locale" at the bottom and nothing else.** It is seven
|
||||||
|
files and the order does not matter, but missing one is silent — see the failure it is written from.
|
||||||
|
|
||||||
|
This is an execution plan. It is written to be handed to an agent who has not had the conversation
|
||||||
|
that produced it, so it states the decisions and the reasons for them, not just the steps.
|
||||||
|
|
||||||
|
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
|
||||||
|
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
|
||||||
|
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
|
||||||
|
would be a guard against progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
|
||||||
|
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
|
||||||
|
decision, deferred, and nothing here depends on it.
|
||||||
|
|
||||||
|
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
|
||||||
|
the server does instead, which is the one decision that reaches outside this boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What exists today, and what it cannot do
|
||||||
|
|
||||||
|
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
|
||||||
|
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
|
||||||
|
components. All of that is now history and is kept below only as the "before" column, because the shape of
|
||||||
|
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
|
||||||
|
|
||||||
|
| | before phase 0 | now |
|
||||||
|
| -------------------------- | ---------------------- | --------------------------------------------- |
|
||||||
|
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
|
||||||
|
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
|
||||||
|
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
|
||||||
|
| components using it | 3 | all of `officer-web` and `components` |
|
||||||
|
| plural rules | none | `Intl.PluralRules`, per locale |
|
||||||
|
| dates and numbers | browser locale | `useFormat()`, the account's locale |
|
||||||
|
| a missing key | rendered its dot-path | falls back to English, warns in dev |
|
||||||
|
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
|
||||||
|
| parity enforcement | none | **a gap fails the build** |
|
||||||
|
|
||||||
|
Still standing from the original description, and still true: the package is hand-rolled, has no npm
|
||||||
|
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
|
||||||
|
sounds when one agent is editing hundreds of files.
|
||||||
|
|
||||||
|
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
|
||||||
|
wired into the bridge, and empty.
|
||||||
|
|
||||||
|
Two things learned doing phases 1 and 2 that the plan did not anticipate:
|
||||||
|
|
||||||
|
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
|
||||||
|
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
|
||||||
|
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
|
||||||
|
assertions.
|
||||||
|
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
|
||||||
|
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
|
||||||
|
which phase 2 needed immediately. Memoised.
|
||||||
|
|
||||||
|
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
|
||||||
|
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
|
||||||
|
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
|
||||||
|
raising if a phase turns out to be full of them rather than absorbing it forty times.
|
||||||
|
|
||||||
|
### The size of the remaining work
|
||||||
|
|
||||||
|
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
||||||
|
|
||||||
|
| area | files | occurrences |
|
||||||
|
| --------------------- | ------- | ----------- |
|
||||||
|
| `officer-web` (shell) | 132 | 556 |
|
||||||
|
| `officerdev` (panels) | 213 | 567 |
|
||||||
|
| shared `components` | 91 | 47 |
|
||||||
|
| **total** | **436** | **1170** |
|
||||||
|
|
||||||
|
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
||||||
|
|
||||||
|
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
|
||||||
|
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1. The web UI only — and the server returns codes, not prose
|
||||||
|
|
||||||
|
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
|
||||||
|
owns every word a person reads and localizes it like any other string.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// not this
|
||||||
|
{ "error": "The Minesweeper session is not running" }
|
||||||
|
// this
|
||||||
|
{ "code": "MINESWEEPER_NOT_RUNNING" }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is better than translating the server, for reasons that have nothing to do with translation. A
|
||||||
|
code maps to exactly one failure site, so it is greppable and a support conversation has something
|
||||||
|
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
|
||||||
|
|
||||||
|
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
|
||||||
|
and the catalog gets an `errors.*` namespace.
|
||||||
|
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
|
||||||
|
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
|
||||||
|
a raw key.
|
||||||
|
|
||||||
|
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
|
||||||
|
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
|
||||||
|
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
|
||||||
|
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
|
||||||
|
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
|
||||||
|
|
||||||
|
### D2. Regional locales, BCP 47, and every catalog is complete
|
||||||
|
|
||||||
|
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
|
||||||
|
it is the owner's own language, so getting it wrong is felt immediately.
|
||||||
|
|
||||||
|
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
|
||||||
|
every key the UI has, translated for that locale, or it does not ship.
|
||||||
|
|
||||||
|
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
|
||||||
|
carried the shared bulk and a regional file carried only the differences. That is the conventional design
|
||||||
|
and it was rejected for a product reason that outranks the engineering one:
|
||||||
|
|
||||||
|
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
|
||||||
|
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
|
||||||
|
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
|
||||||
|
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
|
||||||
|
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
|
||||||
|
variant's words.
|
||||||
|
|
||||||
|
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
|
||||||
|
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
|
||||||
|
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
|
||||||
|
by the test rather than by a fallback quietly papering over a gap.
|
||||||
|
|
||||||
|
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
|
||||||
|
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
|
||||||
|
base-versus-overlay merge order, and no question about which file a given string lives in.
|
||||||
|
|
||||||
|
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
|
||||||
|
|
||||||
|
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
|
||||||
|
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
|
||||||
|
over a database migration — a stale bare code should resolve, not fail.
|
||||||
|
|
||||||
|
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
|
||||||
|
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
|
||||||
|
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
|
||||||
|
the other.
|
||||||
|
|
||||||
|
### D3. RTL — prepare, do not ship
|
||||||
|
|
||||||
|
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
|
||||||
|
opened once:
|
||||||
|
|
||||||
|
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
|
||||||
|
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
|
||||||
|
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
|
||||||
|
|
||||||
|
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
|
||||||
|
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
|
||||||
|
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
|
||||||
|
|
||||||
|
### D4. Extend the existing package. Do not adopt a library
|
||||||
|
|
||||||
|
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
|
||||||
|
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
|
||||||
|
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
|
||||||
|
|
||||||
|
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
|
||||||
|
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
||||||
|
lines of glue, listed in "What it cannot do yet".
|
||||||
|
|
||||||
|
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
|
||||||
|
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||||||
|
|
||||||
|
### D5. Keys, not English source text
|
||||||
|
|
||||||
|
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
|
||||||
|
|
||||||
|
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
|
||||||
|
renders. They also mean **editing an English word invalidates every translation of it**, and this
|
||||||
|
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
|
||||||
|
trading away.
|
||||||
|
|
||||||
|
### D6. Catalogs live per workspace, merged at the bridge
|
||||||
|
|
||||||
|
Not one god file. Three:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/apps/officer-web/locales/ → namespace app.*
|
||||||
|
src/workspaces/officerdev/src/locales/ → namespace panel.*
|
||||||
|
src/workspaces/components/locales/ → namespace ui.*
|
||||||
|
```
|
||||||
|
|
||||||
|
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
|
||||||
|
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
|
||||||
|
|
||||||
|
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
|
||||||
|
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
|
||||||
|
on every parallel edit** — with several agents or several days of work, a single file is a queue.
|
||||||
|
|
||||||
|
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
|
||||||
|
under it must not mention a feature.
|
||||||
|
|
||||||
|
### D7. Plurals and formatting go through `Intl`
|
||||||
|
|
||||||
|
A plural value becomes an object; a string stays a string:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
|
||||||
|
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
|
||||||
|
must be an open record, never a two-field type.
|
||||||
|
|
||||||
|
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
|
||||||
|
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
|
||||||
|
locale and quietly contradict the rest of the UI.
|
||||||
|
|
||||||
|
### D8. A missing key falls back to English and is loud in dev
|
||||||
|
|
||||||
|
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
|
||||||
|
missing too does it render the key — which at that point is a bug the parity test below should have
|
||||||
|
caught.
|
||||||
|
|
||||||
|
In dev, a miss logs once per key with the key and the locale. In production it is silent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The key naming convention
|
||||||
|
|
||||||
|
Three segments, sometimes four. **Name the role, never the text.**
|
||||||
|
|
||||||
|
```
|
||||||
|
<namespace>.<area>.<component>.<element>
|
||||||
|
|
||||||
|
app.settings.profile.changePassword.title
|
||||||
|
panel.files.contextMenu.rename
|
||||||
|
panel.chat.composer.placeholder
|
||||||
|
ui.button.cancel
|
||||||
|
app.errors.MINESWEEPER_NOT_RUNNING
|
||||||
|
```
|
||||||
|
|
||||||
|
- `namespace` — `app` / `panel` / `ui`, per D6.
|
||||||
|
- `area` — the route or panel it belongs to; matches the directory.
|
||||||
|
- `component` — the file, lowercased.
|
||||||
|
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||||
|
|
||||||
|
Rules that keep 1200 keys navigable:
|
||||||
|
|
||||||
|
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
|
||||||
|
`ui.button.Save`.
|
||||||
|
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
|
||||||
|
the extractor. Use an explicit map from the dynamic value to literal keys.
|
||||||
|
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
|
||||||
|
them as a prop.
|
||||||
|
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
|
||||||
|
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
|
||||||
|
gets two keys. When unsure, split: merging later is safe, splitting later is not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What must never be translated
|
||||||
|
|
||||||
|
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
|
||||||
|
|
||||||
|
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||||
|
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||||
|
- **Not everything a panel renders is content, and "panel plus file names" is the wrong test.** The
|
||||||
|
guidance during phase 3 was to expect roughly half of FileBrowser's hits to be content and stay English.
|
||||||
|
`useFileBrowserApp.ts`, the largest file in the phase at 47 hits, turned out **100% convertible**.
|
||||||
|
|
||||||
|
The reason is worth keeping. A panel that LISTS things renders content — a file name in a row is the
|
||||||
|
user's, and translating it would be absurd. A panel REPORTING about those things owns the sentence:
|
||||||
|
`Renamed to "{{name}}"` is ours, and the file name sits inside it as a parameter, which is exactly where
|
||||||
|
content belongs. So the test is not "does this panel touch file names" but "is the string a frame around
|
||||||
|
content, or the content itself".
|
||||||
|
|
||||||
|
It also matters for plurals. Six hand-rolled ones in that file became real entries because Portuguese
|
||||||
|
reorders them — `1 item eliminado` against `Deleted 1 item` — which a frame with a count spliced into it
|
||||||
|
cannot express at all.
|
||||||
|
|
||||||
|
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
|
||||||
|
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
|
||||||
|
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
|
||||||
|
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
|
||||||
|
says "Enable".
|
||||||
|
|
||||||
|
So translate the connective prose around them and leave the quoted label in English. Do not key it with
|
||||||
|
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
|
||||||
|
which is a maintenance cost bought for nothing.
|
||||||
|
|
||||||
|
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
|
||||||
|
|
||||||
|
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
|
||||||
|
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||||
|
names, database values.
|
||||||
|
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
||||||
|
reading a terminal at 4am, and they should stay greppable in one language.
|
||||||
|
- **"Officer"**, and product names generally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
|
||||||
|
because a half-converted UI is unreviewable.
|
||||||
|
|
||||||
|
### Phase 0 — infrastructure, no visible change
|
||||||
|
|
||||||
|
Everything in "What it cannot do yet". Nothing in this phase touches a component.
|
||||||
|
|
||||||
|
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
|
||||||
|
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
|
||||||
|
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
|
||||||
|
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
|
||||||
|
5. Settings migration: widen stored bare codes on read.
|
||||||
|
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
|
||||||
|
7. Land the tooling below.
|
||||||
|
|
||||||
|
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
|
||||||
|
expensive to reverse — review it before phase 1 starts.
|
||||||
|
|
||||||
|
### Phase 1 — shared components (91 files, ~47 strings)
|
||||||
|
|
||||||
|
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
|
||||||
|
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
|
||||||
|
|
||||||
|
### Phase 2 — the shell (132 files, ~556 strings)
|
||||||
|
|
||||||
|
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
|
||||||
|
look at first. Convert per directory, one commit per area.
|
||||||
|
|
||||||
|
### Phase 3 — the panels (213 files, ~567 strings)
|
||||||
|
|
||||||
|
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
|
||||||
|
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
|
||||||
|
agents in the same panel will still conflict.
|
||||||
|
|
||||||
|
### Then: `pt-PT`
|
||||||
|
|
||||||
|
Only after English is fully keyed. Translating against a moving catalog is wasted work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tooling and guardrails
|
||||||
|
|
||||||
|
Without these, 1200 keys rot. All three are small and belong in phase 0.
|
||||||
|
|
||||||
|
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
|
||||||
|
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
|
||||||
|
meter: the count goes down as work lands.
|
||||||
|
|
||||||
|
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
|
||||||
|
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
|
||||||
|
`bun docs:check` fails on a stale number.
|
||||||
|
|
||||||
|
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
|
||||||
|
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
|
||||||
|
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
|
||||||
|
|
||||||
|
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
|
||||||
|
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
|
||||||
|
it globally on day one would just be red forever.
|
||||||
|
|
||||||
|
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
|
||||||
|
else will ever notice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
|
||||||
|
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
|
||||||
|
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
|
||||||
|
- `bun test` — parity test green for every shipped locale.
|
||||||
|
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
|
||||||
|
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
|
||||||
|
as **English, never as dot-paths**.
|
||||||
|
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
|
||||||
|
in English and wrong everywhere else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
|
||||||
|
canonicalization and redirect questions that are not worth answering yet.
|
||||||
|
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
|
||||||
|
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
||||||
|
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||||
|
manifest. Their strings are not counted above.
|
||||||
|
|
||||||
|
**One constraint that contract has to answer, found while converting the shared components
|
||||||
|
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
|
||||||
|
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
|
||||||
|
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
|
||||||
|
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
|
||||||
|
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
|
||||||
|
|
||||||
|
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
|
||||||
|
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
|
||||||
|
provider-absent fallback inside the `components` package was considered and rejected: it would have
|
||||||
|
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
|
||||||
|
|
||||||
|
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
|
||||||
|
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||||
|
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||||
|
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a locale
|
||||||
|
|
||||||
|
Seven files. Six are mechanical; the seventh is the one that gets missed.
|
||||||
|
|
||||||
|
**Three catalogues, one per workspace.** Copy `en-GB.json` beside itself and translate the values,
|
||||||
|
keeping every key:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/apps/officer-web/locales/<code>.json app.* the shell, screens, settings
|
||||||
|
src/workspaces/officerdev/src/locales/<code>.json panel.* the windowed apps
|
||||||
|
src/workspaces/components/locales/<code>.json ui.* shared components
|
||||||
|
```
|
||||||
|
|
||||||
|
**Three registrations, one per catalogue.** Each is an `import` plus a line in a map:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/apps/officer-web/lib/catalogues.ts
|
||||||
|
src/workspaces/officerdev/src/locales/index.ts
|
||||||
|
src/workspaces/components/locales/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**And the picker**, which is the one that gets missed the other way round:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/apps/officer-web/lib/locales.ts SHIPPED_LOCALES
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why this is spelled out rather than left to reading the code
|
||||||
|
|
||||||
|
es-ES, de-DE and fr-FR once shipped translated, in perfect key parity, listed in the picker — and
|
||||||
|
completely dead. All three registration files still imported only en-GB and pt-PT, so the merge
|
||||||
|
produced three empty catalogues and the UI rendered raw keys in three of five languages.
|
||||||
|
|
||||||
|
Nothing caught it. The parity tests compare the JSON files against each other and every file was
|
||||||
|
correct; a locale that is never imported is invisible to them by construction. The picker was right.
|
||||||
|
Four edits in four files with no single place that knew all four had happened.
|
||||||
|
|
||||||
|
### What checks you now
|
||||||
|
|
||||||
|
Run `bun test` and it is settled. Two things stand where nothing stood:
|
||||||
|
|
||||||
|
- `src/apps/officer-web/lib/catalogues.test.ts` asserts every locale in `SHIPPED_LOCALES` resolves to
|
||||||
|
a populated catalogue in all three namespaces, and matches en-GB's size. A locale in the picker but
|
||||||
|
not imported fails here. So does one imported but empty.
|
||||||
|
- The three `parity.test.ts` files read their own directory rather than listing locales by hand, so a
|
||||||
|
new `<code>.json` is checked the moment it exists — for missing keys AND extra ones, because
|
||||||
|
`findMissingKeys` alone reports half of a rename.
|
||||||
|
|
||||||
|
Then `bunx tsgo` and `bunx prettier --write` on what you touched, as everywhere else.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# A plugin's capabilities are LOADED, never copied
|
||||||
|
|
||||||
|
**Status: built.** Skills, affordances and notification catalogues landed in `2026.08.23.06`; tasks — the
|
||||||
|
read-write one, and the last piece — landed on 2026-08-24. The four publishers are gone.
|
||||||
|
|
||||||
|
## The problem, as it is on disk right now
|
||||||
|
|
||||||
|
`capabilities/` is a git repository. The intent is that anyone can fork it, keep their own tasks, skills
|
||||||
|
and affordances in it, and pull core's on top. That only works if the directory contains exactly two
|
||||||
|
things: what core ships, and what the owner wrote.
|
||||||
|
|
||||||
|
It does not. Four publishers copy a plugin's files into it at install time — assets, affordances,
|
||||||
|
notification catalogues and skills — so a fork accumulates files it did not author. On this machine today
|
||||||
|
`skills/email-db/` and `extensions/` sit untracked in that repository, published there by plugins. Commit
|
||||||
|
them and the fork carries another plugin's work; leave them and `git status` is permanently noisy. There
|
||||||
|
is no third answer, because nothing on disk says which files came from where.
|
||||||
|
|
||||||
|
Copying is also wrong in a second way that has nothing to do with git. **A copy goes stale.**
|
||||||
|
`plugins:update` pulls new plugin code and the copied capabilities keep describing the old version until
|
||||||
|
something republishes them. And a copy survives `plugins:disable`, so a disabled plugin's skill stays in
|
||||||
|
the agent's index — the plugin is off and the agent still believes it is there.
|
||||||
|
|
||||||
|
## The design
|
||||||
|
|
||||||
|
**A plugin keeps its capabilities in its own directory, and the platform reads them where they are.**
|
||||||
|
|
||||||
|
```
|
||||||
|
capabilities/ the owner's fork: core's, plus their own. Nothing else, ever.
|
||||||
|
tasks/ skills/ affordances/ notifications/
|
||||||
|
|
||||||
|
plugins/<name>/ the plugin's own repository, cloned on install
|
||||||
|
tasks/ skills/ affordances/ notifications/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
|
||||||
|
which is the whole reason this beats scoping the paths. A namespace makes provenance _readable_; separate
|
||||||
|
directories make it _structural_.
|
||||||
|
|
||||||
|
Four consequences, and each of them is a bug that stops existing:
|
||||||
|
|
||||||
|
- **A fork stays clean.** `capabilities/` contains only what its owner is responsible for, so pulling from
|
||||||
|
upstream merges without touching anything a plugin put there.
|
||||||
|
- **Capabilities update with the plugin.** `plugins:update` is a `git pull` in the plugin's directory, and
|
||||||
|
the capabilities are in that directory. There is nothing left to go stale.
|
||||||
|
- **Disable actually disables.** The load list is built from plugins that are installed AND enabled, so a
|
||||||
|
disabled plugin contributes nothing. Today its copies remain.
|
||||||
|
- **Uninstall needs no cleanup step.** The directory goes and its capabilities go with it. No list of
|
||||||
|
filenames to be careful with, and no way to delete the owner's own work by getting that list wrong.
|
||||||
|
|
||||||
|
## What has to change
|
||||||
|
|
||||||
|
**The loaders take a list of roots.** `loadSkills(root)` and `loadAffordances(root)` already take one with
|
||||||
|
a default, so this is a small change rather than a rewrite. Notifications the same.
|
||||||
|
|
||||||
|
**The list is resolved per session, not at module load.** `affordanceBlock()` and `skillsBlock()` are
|
||||||
|
called once per session in `sidecar/claude/claude-manager.ts` — deliberately, so a capability written an
|
||||||
|
hour ago is offered on the next session. That is also why they can afford to ask the database which
|
||||||
|
plugins are installed: once per session, not once per turn.
|
||||||
|
|
||||||
|
**Tasks are the awkward one, because that store is read-write.** `api/tasks/task-files.ts` creates, edits
|
||||||
|
and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's
|
||||||
|
directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that
|
||||||
|
silently writes into a directory the next `plugins:update` overwrites.
|
||||||
|
|
||||||
|
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
|
||||||
|
search every source rather than assuming one root.
|
||||||
|
|
||||||
|
**How it went in, since it turned out to be one function rather than several.** Every path that runs a
|
||||||
|
task — the file browser's context menu, both runner sockets, the job queue, the pipeline executor —
|
||||||
|
resolves it through `getTaskByDirName` and then reads `implementation` off the record. So the sources go
|
||||||
|
in there and in `listTasks`, and a plugin's task becomes runnable everywhere at once with its identity
|
||||||
|
unchanged: still a directory name, still what `pipeline_jobs` stores.
|
||||||
|
|
||||||
|
The record carries `plugin` — the app name, or null for the owner's own. That one field does the rest:
|
||||||
|
`updateTask` and `deleteTask` throw on it, `DELETE /api/tasks/:name` answers 409 naming the plugin, and
|
||||||
|
the capability screen replaces the edit and delete controls with a line saying whose it is. Without the
|
||||||
|
delete guard the old code was quietly a no-op — `rm -rf` on a path in the owner's store that does not
|
||||||
|
exist, then `{ ok: true }`, and the task still in the menu.
|
||||||
|
|
||||||
|
`getTaskByDirName` also refuses a name containing a separator. It always joined a caller-supplied string
|
||||||
|
onto a path; searching several roots is the moment that stops being one directory nobody could escape.
|
||||||
|
|
||||||
|
**The four publishers are deleted**, along with their install and uninstall wiring:
|
||||||
|
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
|
||||||
|
last was written on 2026-08-23 and is superseded before it ever shipped.
|
||||||
|
|
||||||
|
`app-store/assets` is a different case and stays. Those are images served over HTTP from a
|
||||||
|
platform-managed path, not capabilities read from disk, and the copy is what makes them reachable.
|
||||||
|
|
||||||
|
## The migration
|
||||||
|
|
||||||
|
One-off, and it is deletion rather than movement. The copies already in `capabilities/` have nothing to
|
||||||
|
remove them once the publishers are gone — `skills/email-db/` and `extensions/` on this machine, and
|
||||||
|
whatever the equivalent is per host. They are copies of files that still exist in each plugin's own
|
||||||
|
directory, so deleting them loses nothing.
|
||||||
|
|
||||||
|
Check each host rather than assuming this one is representative.
|
||||||
|
|
||||||
|
## What this does not decide
|
||||||
|
|
||||||
|
**Where the owner's own capabilities live inside their fork.** Flat at the top of each type directory is
|
||||||
|
what happens today and needs no change. Scoping them is a separate question and this design does not
|
||||||
|
force it either way.
|
||||||
|
|
||||||
|
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
|
||||||
|
task with the same directory name. The tie-break shipped as first-come with the owner's store searched
|
||||||
|
first, which settles the case that matters — a plugin cannot take over a name its owner is already
|
||||||
|
using — and leaves two plugins colliding with each other resolved by discovery order, which is arbitrary.
|
||||||
|
Worth revisiting when a second plugin ships a task; not before.
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Roles and teams
|
||||||
|
|
||||||
|
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
|
||||||
|
design, written down so the work starts from a spec rather than from a conversation.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
|
||||||
|
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
|
||||||
|
a homelab and it cannot express a company, where the same person is an administrator of one area and an
|
||||||
|
ordinary member of another.
|
||||||
|
|
||||||
|
Two things are wanted, and they are separable:
|
||||||
|
|
||||||
|
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
|
||||||
|
provisioning.
|
||||||
|
- **Teams**, so a user belongs to several and holds a different role in each.
|
||||||
|
|
||||||
|
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
|
||||||
|
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
|
||||||
|
uses both. Nothing below may make teams mandatory to reach a feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What already landed — phase 0, 2026-08-23
|
||||||
|
|
||||||
|
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
|
||||||
|
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
|
||||||
|
|
||||||
|
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
|
||||||
|
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
|
||||||
|
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
|
||||||
|
|
||||||
|
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
|
||||||
|
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
|
||||||
|
Verified against a scratch database: push reported the violation, created the table, skipped the
|
||||||
|
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1. The owner is an identity. It is never a role.
|
||||||
|
|
||||||
|
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
|
||||||
|
|
||||||
|
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
|
||||||
|
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
|
||||||
|
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
|
||||||
|
whatever UI manages roles.
|
||||||
|
|
||||||
|
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
|
||||||
|
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
|
||||||
|
else can wait behind it.
|
||||||
|
|
||||||
|
### D2. Several Super Admins are allowed, and the role provisions sudo
|
||||||
|
|
||||||
|
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
|
||||||
|
a company has an owner and several administrators, and a homelab owner may add one or never do.
|
||||||
|
|
||||||
|
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
|
||||||
|
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
|
||||||
|
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
|
||||||
|
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
|
||||||
|
they left behind.
|
||||||
|
|
||||||
|
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
|
||||||
|
the UI to say what it is rather than presenting it as one more row in a dropdown.
|
||||||
|
|
||||||
|
### D3. `Admin` is app-wide authority with no operating-system privilege
|
||||||
|
|
||||||
|
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
|
||||||
|
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
|
||||||
|
"administering the platform is not developing on it". This decision only writes that down.
|
||||||
|
|
||||||
|
### D4. A role may be deleted, but not while anyone holds it
|
||||||
|
|
||||||
|
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
|
||||||
|
nobody asked for, and "absence denies" is the posture everywhere else in this system.
|
||||||
|
|
||||||
|
`isSystem` roles refuse both delete and rename, because the code names them by name:
|
||||||
|
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
|
||||||
|
`Member`. Renaming one is a code change that has not happened yet.
|
||||||
|
|
||||||
|
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
|
||||||
|
|
||||||
|
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
|
||||||
|
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
|
||||||
|
rename cascades to every user and every grant, atomically, with no code change.
|
||||||
|
|
||||||
|
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
|
||||||
|
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
|
||||||
|
|
||||||
|
### D6. A team is a scope. Permissions UNION across a user's teams.
|
||||||
|
|
||||||
|
Admin in marketing and member in developers means marketing's admin level does not reach developers'
|
||||||
|
files — but a user's effective permission for a given capability is the **highest level any of their teams
|
||||||
|
grants**, plus their global role.
|
||||||
|
|
||||||
|
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
|
||||||
|
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
|
||||||
|
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
|
||||||
|
should be refused rather than half-built.
|
||||||
|
|
||||||
|
### D7. The global role remains, and answers a different question
|
||||||
|
|
||||||
|
Two questions, two mechanisms:
|
||||||
|
|
||||||
|
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
|
||||||
|
- **"What may this account do in this area?"** — team membership with a role in that team.
|
||||||
|
|
||||||
|
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
|
||||||
|
has no natural team to ask it about. A homelab uses only the first and never makes a team.
|
||||||
|
|
||||||
|
### D8. Per-role provisioning is a script the role carries
|
||||||
|
|
||||||
|
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
|
||||||
|
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
|
||||||
|
in code that should become a row.
|
||||||
|
|
||||||
|
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
|
||||||
|
rootless specifically. `usermod -aG docker` is root by a longer route —
|
||||||
|
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
|
||||||
|
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 0 — roles are a table ✅ DONE 2026-08-23
|
||||||
|
|
||||||
|
Additive only. Nothing reads it.
|
||||||
|
|
||||||
|
### Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
|
||||||
|
|
||||||
|
The load-bearing phase, and small.
|
||||||
|
|
||||||
|
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
|
||||||
|
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
|
||||||
|
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
||||||
|
"owner or Super Admin" is a separate, reviewable change per route.
|
||||||
|
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
||||||
|
4. ~~Remove the `users` CHECK that pins user 1 to `Super Admin`~~ — **KEPT, overruling this plan.** It is
|
||||||
|
redundant for the question it was written for: `isOwner` is an id comparison and never reads that column.
|
||||||
|
But it still forbids user 1 being set to `Member` while remaining the owner, which is a state with no
|
||||||
|
benefit and a confusing display. A constraint that costs nothing and forbids a nonsense state is worth
|
||||||
|
keeping after its original reason has gone.
|
||||||
|
|
||||||
|
**One asymmetry introduced deliberately.** An existing account can now be promoted to `Super Admin`;
|
||||||
|
creating one directly as `Super Admin` is still refused by `create-user.ts`. Promotion happens to an account
|
||||||
|
somebody has looked at, from a screen showing who they are. Creation is a form filled in once, and the role
|
||||||
|
that provisions sudo should not be a dropdown default reached by accident. Create, then promote.
|
||||||
|
|
||||||
|
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||||
|
|
||||||
|
### Phase 2 — the foreign key, and role CRUD
|
||||||
|
|
||||||
|
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
|
||||||
|
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
|
||||||
|
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
|
||||||
|
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
|
||||||
|
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
|
||||||
|
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
|
||||||
|
replace what the compiler was doing.
|
||||||
|
|
||||||
|
### Phase 3 — per-role provisioning
|
||||||
|
|
||||||
|
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
|
||||||
|
D8's warning in front of whoever writes the first script.
|
||||||
|
|
||||||
|
### Phase 4 — teams
|
||||||
|
|
||||||
|
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
|
||||||
|
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
|
||||||
|
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
|
||||||
|
one table with a nullable `team_id`.
|
||||||
|
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
|
||||||
|
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
|
||||||
|
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
|
||||||
|
4. Teams UI, and the account screen showing which teams a user is in and as what.
|
||||||
|
|
||||||
|
### Phase 5 — account creation — **DONE, and it was done before this phase was written**
|
||||||
|
|
||||||
|
`POST /api/users` is owner-gated and live, with a form under Settings → User Management: same password
|
||||||
|
validator as bootstrap, a role checked against the `roles` table, `Super Admin` refused outright, an
|
||||||
|
optional inbound SSH key, and Linux provisioning with `POST /users/:id/provision-linux` to retry it.
|
||||||
|
Alongside it: role change, password reset, delete.
|
||||||
|
|
||||||
|
It shipped in `69a31051`. This section spent days telling readers it did not exist and sequencing work
|
||||||
|
around building it — and on 2026-08-23 it produced a concrete cost, when a recommendation to "do phase 5
|
||||||
|
first" was made from this text and withdrawn only because someone opened `api/users/` to write the spec.
|
||||||
|
|
||||||
|
The general lesson, since it is the second time this exact shape has bitten: `bun run docs:check` compares
|
||||||
|
numbers, so counts stay honest while prose rots quietly. **A phase marked as not built is worth verifying
|
||||||
|
against the tree before it is planned around**, not because the plan was careless but because a plan is
|
||||||
|
written once and the code moves every day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What must not be built
|
||||||
|
|
||||||
|
- **Deny rules.** See D6. Teams grant; they never restrict.
|
||||||
|
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
|
||||||
|
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
|
||||||
|
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
|
||||||
|
the answer is a `confined` grant with a Linux user, which is per-account by nature.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
|
||||||
|
binary directly.
|
||||||
|
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
|
||||||
|
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
|
||||||
|
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
|
||||||
|
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
|
||||||
|
|
||||||
|
## Teams — decided 2026-08-23
|
||||||
|
|
||||||
|
The four open questions were answered by the owner. Recorded here so phase 4 is implementation rather
|
||||||
|
than another round of design.
|
||||||
|
|
||||||
|
**A team owns a shared folder.** Not only an authorization grouping — storage is the part that was
|
||||||
|
actually wanted. Team *dashboards* are a probable later addition and were explicitly deferred, because
|
||||||
|
they need a permission shape nothing here has: people who can edit a dashboard's STRUCTURE versus people
|
||||||
|
who only see what it renders. Every grant today is read-or-write over a whole capability, so that is a new
|
||||||
|
model rather than a new level. Build the folder; leave room for the dashboards.
|
||||||
|
|
||||||
|
**Any Admin may create a team**, not the owner alone. Teams are self-service for people already trusted to
|
||||||
|
administer the platform. This is what makes the quota section below necessary rather than optional: an
|
||||||
|
Admin creating a team is an Admin allocating disk.
|
||||||
|
|
||||||
|
**A team may grant a capability the member's own role lacks** — the union, per D6 — with one rule on top:
|
||||||
|
**whoever creates the team can only attach capabilities they hold themselves.** Escalation is capped at the
|
||||||
|
creator's own reach rather than the whole registry. `execution` and `admin` stay ungrantable regardless,
|
||||||
|
and `confined` still resolves to nothing for an account with no Linux user.
|
||||||
|
|
||||||
|
**Deleting a role is refused while anyone holds it, in a team or globally.** The same rule D4 already
|
||||||
|
applies to global holders, extended rather than special-cased; the refusal should name the teams.
|
||||||
|
|
||||||
|
### Two things phase 4 must not get wrong
|
||||||
|
|
||||||
|
**The shared folder is enforced by the kernel, not by the grant.** Every role already holds `files` at
|
||||||
|
`write` — they are the seeded `confined` defaults — so the capability is not what keeps one team out of
|
||||||
|
another's folder. A Linux group per team is. The grant says whether someone can use the files surface at
|
||||||
|
all; group membership says which folders they reach. Both are required and they are not substitutes.
|
||||||
|
|
||||||
|
**The grant cache is keyed on ROLE today**, deliberately: "at most one entry per role, and a new account
|
||||||
|
needs no warm-up" (`permissions/authorize.ts`). Teams end that — effective permissions become role plus
|
||||||
|
memberships, which is per-user. That property is easy to lose without noticing and awkward to recover, so
|
||||||
|
it should be a decision in the implementation rather than a side effect.
|
||||||
|
|
||||||
|
## Quotas — the model, and the prerequisite nobody can skip
|
||||||
|
|
||||||
|
The owner declares platform-level defaults and maxima: per user, and per team. Admins allocate inside
|
||||||
|
them. A change re-applies at the Linux level through `POST /users/:id/provision-linux`, which already
|
||||||
|
exists as a retry path and becomes "re-apply current policy to this account".
|
||||||
|
|
||||||
|
**Enforcement is the kernel's job, not the platform's.** Members hold `terminal` at `write` and have their
|
||||||
|
own Linux accounts, so a quota the platform tracks by counting bytes through the file browser is
|
||||||
|
decorative — a member opens a terminal or SSHes in and writes past it. Only filesystem quotas bind.
|
||||||
|
|
||||||
|
**Which means a filesystem that can carry them.** Checked on 2026-08-23: neither `officer-dev` nor `alpha`
|
||||||
|
can. Both are ext4 with homes on the root filesystem, no `usrquota`/`grpquota`/`prjquota` mount options,
|
||||||
|
and neither has the ext4 `quota` or `project` feature. Enabling `project` needs `tune2fs -O project` on an
|
||||||
|
UNMOUNTED filesystem, and `/` cannot be unmounted on a running server — so it is a rescue boot, not a
|
||||||
|
config change.
|
||||||
|
|
||||||
|
The cheap moment is when a host is built, by giving the Linux homes their own filesystem with quota
|
||||||
|
features from the start. Retrofitting a live host is expensive and gets more so with every account on it.
|
||||||
|
|
||||||
|
**Where this actually matters is `edge-pertento`** — the first real multi-user deployment, and the only
|
||||||
|
one that will have accounts that are not the owner's. `officer-dev` is a development machine and will not
|
||||||
|
be multi-user; alpha is the owner's own. Check edge-pertento's disk before it gains more accounts.
|
||||||
|
|
||||||
|
**On a host that cannot enforce, the platform says so** — "policy recorded, not enforceable here" — rather
|
||||||
|
than storing numbers that do nothing. A feature that silently does nothing is worse than one that admits
|
||||||
|
it cannot.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None outstanding for phase 4. The four that were here are answered above.
|
||||||
|
holds globally; the same question exists per team and is not answered here.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# SendOff — files land in the user's own tree, and a push says where
|
||||||
|
|
||||||
|
**From:** mobile · **Date:** 2026-08-23 · **v2, replacing this morning's spec entirely.** The v1
|
||||||
|
design (staging area, transfer/target/file tables, ack lifecycle, TTL cleanup) is withdrawn — Andre's
|
||||||
|
observation kills it: every user already has a filesystem on the server, a file browser on the web,
|
||||||
|
and a Files app on the phone. SendOff is not a new storage system; it is a folder convention, one
|
||||||
|
endpoint, and a push.
|
||||||
|
|
||||||
|
## The convention
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Downloads/sendoff/from-<username>/ ← a file another USER sent you
|
||||||
|
~/Downloads/sendoff/<machine>/ ← a file you sent yourself, aimed at a named device
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole data model. Nothing to ack, nothing to expire, nothing to clean up — the files are
|
||||||
|
ordinary files in the recipient's tree, visible in every file UI that already exists, managed like
|
||||||
|
anything else they own.
|
||||||
|
|
||||||
|
## The endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/sendoff/send
|
||||||
|
multipart: any number of `file` parts + a `meta` part:
|
||||||
|
{ "toUser": "<username>" } — cross-user: writes into THEIR tree, from-<sender>/
|
||||||
|
{ "machine": "<device name>" } — self: writes into own tree, <machine>/
|
||||||
|
→ { path: "Downloads/sendoff/…", files: [{ name, size }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side because of the cross-user case: `/api/file-browser/upload` correctly writes only the
|
||||||
|
caller's own tree, and that must not change. This endpoint is the one sanctioned door into another
|
||||||
|
user's filesystem, and it writes ONLY under `Downloads/sendoff/from-<sender>/` — the sender's
|
||||||
|
identity comes from their token, never from the payload, so the folder name cannot lie about who
|
||||||
|
sent it. Filenames sanitised, collisions suffixed `(1)` — never overwritten; a second send of the
|
||||||
|
same name is a second file.
|
||||||
|
|
||||||
|
Whether user A may send to user B at all is a platform policy decision (everyone on the server? a
|
||||||
|
grant?) — flagging it rather than deciding it here.
|
||||||
|
|
||||||
|
## The push
|
||||||
|
|
||||||
|
On write, push to the recipient's devices through the existing pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "officer": { "c": "officer/core:sendoff.landed", "path": "Downloads/sendoff/from-andre" } }
|
||||||
|
alert: "photo.jpg from andre" / "3 files from omega"
|
||||||
|
```
|
||||||
|
|
||||||
|
The push catalogue's route for the category:
|
||||||
|
|
||||||
|
```
|
||||||
|
officer://files?path={path}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mobile opens routes from the catalogue verbatim (`openNotificationRoute`), and Officer now parses
|
||||||
|
`officer://files?path=…` into the Files app at that folder. The web can deep-link its file browser
|
||||||
|
the same way.
|
||||||
|
|
||||||
|
For a `machine`-targeted send, push only to that machine's device if it can be identified; to all
|
||||||
|
the user's devices otherwise — the path in the folder name already says who it was for.
|
||||||
|
|
||||||
|
## Devices
|
||||||
|
|
||||||
|
The `machine` name needs a picker on the sending side, so a minimal registry survives from v1:
|
||||||
|
|
||||||
|
```
|
||||||
|
devices: id (client uuid), user_id, name, platform, last_seen_at
|
||||||
|
POST /api/devices/register { id, name, platform } (upsert; called on every app start)
|
||||||
|
GET /api/devices → { devices } (the "send to my…" picker)
|
||||||
|
PATCH /api/devices/:id { name } · DELETE /api/devices/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
No link to transfers — nothing references a device row except the picker and, best-effort, push
|
||||||
|
targeting.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
One send form: files + target (one of my machines / another user), POST, done. Received files need
|
||||||
|
no UI at all — they are in the file browser already.
|
||||||
|
|
||||||
|
## What mobile already does (shipped against this)
|
||||||
|
|
||||||
|
- Registers the device on start (silent failure until the endpoint exists).
|
||||||
|
- Parses `officer://files?path=…` and opens the Files app at that path — this is the piece that
|
||||||
|
makes the push tap land somewhere.
|
||||||
|
- SendOff screen: the device's name (the folder senders will aim at), and an explanation of where
|
||||||
|
arrivals land. Sending FROM the phone comes later, likely through the existing share sheet.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Receiving in the browser (already exists — it is the file browser); cross-user permission model
|
||||||
|
(platform decision); phone-side arbitrary-file sending (needs a document picker; the share sheet is
|
||||||
|
the natural door and is a later step).
|
||||||
+126
-37
@@ -4,59 +4,148 @@ Every machine the owner runs, how to reach it, and what of Officer is on it. Kep
|
|||||||
orchestrator ("pull and update every platform", "restart officer everywhere", "which host is behind")
|
orchestrator ("pull and update every platform", "restart officer everywhere", "which host is behind")
|
||||||
has one place to read rather than a fact re-derived per session.
|
has one place to read rather than a fact re-derived per session.
|
||||||
|
|
||||||
**Started 2026-08-21.** Everything below was verified from `officer-dev` on that date by SSH and
|
**Verified 2026-08-21** from `officer-dev` by SSH and `pm2 jlist`, except where marked assumed.
|
||||||
`pm2 jlist`, except where marked assumed. Commit SHAs are a snapshot and go stale immediately — they
|
|
||||||
are here to show which checkout is live, not as a status board.
|
|
||||||
|
|
||||||
## How access works
|
## How access works
|
||||||
|
|
||||||
All of it is over the tailnet. There is **no `~/.ssh/config`** — names resolve by MagicDNS and the
|
All of it is over the tailnet, key-based, no `~/.ssh/config`, no passwords, no jump hosts, no ports.
|
||||||
login is key-based, so `ssh <name>` as `pastilhas` is the whole story:
|
|
||||||
|
**The login user is NOT always `pastilhas`.** It is per host and it is in the table below. `deadpool` runs
|
||||||
|
as `br` and always will — the owner's key is authorised for that account. An orchestrator that hard-codes
|
||||||
|
a username fails on that host and will fail on the next one like it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh alpha
|
ssh alpha # pastilhas, implied
|
||||||
ssh fofos
|
ssh br@deadpool-rezio-bridge # not implied
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing here needs a password, a jump host or a port. A host that refuses port 22 is not
|
|
||||||
misconfigured — see "Not SSH-reachable" below.
|
|
||||||
|
|
||||||
## Officer platforms
|
## Officer platforms
|
||||||
|
|
||||||
| Host | Tailnet IP | Live checkout | Runs `officer` | Git remote |
|
Seven, all running.
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `officer-dev` | 100.64.0.13 | `~/officer-dev/platform` | yes (this host) | `git@gitea.officer.dev:officerdev/platform.git` |
|
|
||||||
| `alpha` | 100.64.0.1 | `~/officerdev/platform` | yes | `git@gitea.officer.dev:officerdev/platform.git` |
|
|
||||||
| `fofos` | 100.64.0.17 | `~/officerdev/platform` | yes | `https://gitea.officer.dev/officerdev/platform.git` |
|
|
||||||
| `headscale` | 100.64.0.2 | `~/dockers/officerdev/platform` | no | `ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git` |
|
|
||||||
| `omega` | 100.64.0.3 | `~/projects/officerdev/platform` | no | none set |
|
|
||||||
|
|
||||||
Three things an orchestrator has to handle, all verified and none of them cosmetic:
|
| Host | Tailnet IP | Login | Checkout | Domain |
|
||||||
|
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
|
||||||
|
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
|
||||||
|
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
||||||
|
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||||
|
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||||
|
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||||
|
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||||
|
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||||
|
|
||||||
- **The checkout path differs per host.** There is no single path to hard-code. Take it from PM2 —
|
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||||
`pm2 jlist` gives the `officer` process's `pm_cwd`, which is the checkout that is actually serving.
|
reachable from inside only.
|
||||||
- **`alpha` has a SECOND, stale checkout** at `~/dockers/officer.dev/platform` (same remote, different
|
|
||||||
HEAD). Pulling the wrong one updates nothing and looks like success. PM2's `pm_cwd` is the tiebreak.
|
|
||||||
- **The remotes are not the same URL.** `fofos` pulls over HTTPS and `headscale` points at a different
|
|
||||||
Gitea entirely (`gitea.pastilhas.dev:2222`). An orchestrator that assumes SSH to `gitea.officer.dev`
|
|
||||||
breaks on two of five.
|
|
||||||
|
|
||||||
`headscale` and `omega` hold a checkout but run no `officer` process (no PM2 on either). Whether those
|
## Deliberately skipped
|
||||||
are retired, staging or half-installed is **not established** — ask before treating them as targets.
|
|
||||||
|
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||||
|
and it must not be reported as one.
|
||||||
|
|
||||||
|
None today. `edge-pertento-bridge` was here from 2026-08-23 and came back up on 2026-08-24; it is a
|
||||||
|
deploy target again and its row is in the table above.
|
||||||
|
|
||||||
|
It was down long enough to matter, so expect it to be behind: when it returned it was 215 commits back at
|
||||||
|
`2026.08.22.02`, with no `roles` table and no commanders. A host coming off this list needs its schema
|
||||||
|
brought forward before it is treated as ordinary.
|
||||||
|
|
||||||
|
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
|
||||||
|
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
|
||||||
|
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
|
||||||
|
not a retry budget.
|
||||||
|
|
||||||
|
### Names that disagree with themselves
|
||||||
|
|
||||||
|
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
|
||||||
|
|
||||||
|
| In `tailscale status` | Also | The machine's own hostname |
|
||||||
|
| ----------------------- | ---------------------------- | -------------------------- |
|
||||||
|
| `edge-bridge` | `edge-pertento-bridge` | `edge-pertento` |
|
||||||
|
| `gitea-bridge` | `production-pertento-bridge` | `PPROD` |
|
||||||
|
| `deadpool-rezio-bridge` | — | `deadpool` |
|
||||||
|
|
||||||
|
The middle column is what to use. `edge-bridge` and `gitea-bridge` are the old names and are being
|
||||||
|
retired; anything reading `tailscale status` will still see them.
|
||||||
|
|
||||||
|
### Things that will catch out an orchestrator
|
||||||
|
|
||||||
|
All verified, none cosmetic.
|
||||||
|
|
||||||
|
- **The checkout path differs per host** — four distinct shapes across seven machines. Take it from PM2:
|
||||||
|
the `officer` process's `pm_cwd`. `alpha` carries a SECOND, stale checkout at
|
||||||
|
`~/dockers/officer.dev/platform`, and pulling that one succeeds while changing nothing.
|
||||||
|
- **`pm2` is not on a non-interactive PATH.** It lives under nvm, and nvm's setup is in `.zshrc`, which a
|
||||||
|
_login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is **not** enough — it
|
||||||
|
reports "command not found" on machines where pm2 is plainly running, which cost two wrong conclusions
|
||||||
|
on 2026-08-21.
|
||||||
|
- **On `macbookpro`, APPEND to the PATH for `bun`. Never replace it.** This said "do not help the PATH"
|
||||||
|
until 2026-08-21, and that was half right in a way that cost a deploy step: `pm2` is only on the
|
||||||
|
interactive shell's PATH, so replacing it breaks `pm2` — but `bun` is at `/opt/homebrew/bin/bun` and
|
||||||
|
`.zshrc` does not put that directory on the interactive PATH either, so `bun run plugins:update` fails
|
||||||
|
`command not found` before it runs a line. `export PATH=$PATH:/opt/homebrew/bin` for that one command
|
||||||
|
shadows nothing: `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. Run `pm2` as a separate
|
||||||
|
command.
|
||||||
|
- **`$SHELL -ic` on `macbookpro` aliases `ls` to `eza`, which is not installed.** A probe using `ls`
|
||||||
|
reports a missing file that is plainly there. Use `command ls`, or a non-interactive shell for file tests.
|
||||||
|
- **The git remotes differ.** `fofos` pulls over HTTPS; the others over SSH. Never assume a URL — pull in
|
||||||
|
place.
|
||||||
|
- **A "dirty tree" means TRACKED changes.** `git status --porcelain` counts untracked files, and a stray
|
||||||
|
script a fast-forward could never touch is not a reason to skip a host. Use `--untracked-files=no`.
|
||||||
|
- **`macbookpro` is a laptop.** It may be asleep. Unreachable is a skip with a note, not a failed deploy.
|
||||||
|
- **`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin — on
|
||||||
|
`alpha` that is twelve processes bouncing during the plugins step, before any restart the plan asked for.
|
||||||
|
Not a fault, but a deploy report that lists only `officer` under "restarted" is understating what moved.
|
||||||
|
- **`fofos` failed its capabilities sync for three deploys, and it was ownership.** Fixed 2026-08-21.
|
||||||
|
Two errors that read as unrelated problems — `dubious ownership` from git, and
|
||||||
|
`EACCES … mkdir '…/capabilities/affordances/officerdev'` from the gitea plugin — were one cause in two
|
||||||
|
dialects: fifty paths under that checkout were owned by `root`, including `.git` and `affordances`, all
|
||||||
|
stamped `19 Aug 21:43`. Something was run with `sudo` and left them behind. Mode `755` is why the split
|
||||||
|
looked odd: everything could be READ, so git saw the repo and refused it on ownership while the plugin
|
||||||
|
saw `affordances` and could not write into it.
|
||||||
|
|
||||||
|
One `stat` settled it, and that is the general lesson — `stat` prints the owner rather than inferring
|
||||||
|
anything, so it either proves ownership is the cause or eliminates it, in one command. Fixed with
|
||||||
|
`sudo chown -R pastilhas:pastilhas ~/officerdev/capabilities`; verified after by a `mkdir` into the
|
||||||
|
directory that used to refuse one.
|
||||||
|
|
||||||
|
**A `safe.directory` entry was deliberately NOT used**, and that call is worth keeping. It would have
|
||||||
|
silenced the git message and left the `EACCES` untouched — one symptom of a two-symptom cause hidden,
|
||||||
|
with the obvious explanation spent.
|
||||||
|
|
||||||
|
## Not platforms — ignore for now
|
||||||
|
|
||||||
|
A checkout may be present; no `officer` process runs on any of these. Whether they are retired, staging
|
||||||
|
or half-installed is **not established** — ask before treating them as targets.
|
||||||
|
|
||||||
|
| Host | Tailnet IP |
|
||||||
|
| ----------- | ---------- |
|
||||||
|
| `headscale` | 100.64.0.2 |
|
||||||
|
| `omega` | 100.64.0.3 |
|
||||||
|
| `gateway` | 100.64.0.4 |
|
||||||
|
|
||||||
## Other machines on the tailnet
|
## Other machines on the tailnet
|
||||||
|
|
||||||
- `gateway` (100.64.0.4) — reachable over SSH as `pastilhas`, no platform checkout, no PM2.
|
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `officer-bridge` (100.64.0.5).
|
||||||
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `edge-bridge` (100.64.0.15),
|
Assumed service-only containers — not confirmed. `edge-bridge` was listed here until 2026-08-21 and was
|
||||||
`gitea-bridge` (100.64.0.12), `officer-bridge` (100.64.0.5). Assumed to be service-only
|
wrong: its SSH was simply down at the time of checking.
|
||||||
containers rather than hosts to log into — not confirmed.
|
- **Client devices, not servers:** `iphone16`, `andres-ipad`, `pixel9a`, plus other people's devices in
|
||||||
- **Client devices, not servers:** `macbookpro`, `iphone16`, `andres-ipad`, `pixel9a`, plus other
|
the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`, `fedras-iphone`,
|
||||||
people's devices in the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`,
|
|
||||||
`guilhermes-macbook-pro`, `macbook-pro-de-joo`).
|
`guilhermes-macbook-pro`, `macbook-pro-de-joo`).
|
||||||
|
|
||||||
|
## Known wrong, waiting on someone else
|
||||||
|
|
||||||
|
- **`officer.rezio.dev` resolves to 100.64.0.4** — that is `gateway`, not `deadpool` (100.64.0.21). Not
|
||||||
|
merely unavailable: pointed at a different machine, so it will serve the wrong box the moment gateway
|
||||||
|
answers on 443. Ports 80 and 443 are closed on `deadpool`, so nothing fronts it yet. Waiting on the
|
||||||
|
domain owner.
|
||||||
|
|
||||||
## Keeping this list true
|
## Keeping this list true
|
||||||
|
|
||||||
It is a running list, so it is worth saying what makes it rot: a new server, a moved checkout, a
|
It rots when a server is added, a checkout moves, a platform is retired, a remote changes or a login
|
||||||
platform retired, or a remote changed. Re-verify with the same three commands the list was built from
|
differs. Re-verify with the four commands it was built from:
|
||||||
— `tailscale status`, `pm2 jlist` for the `officer` entry's `pm_cwd`, and `git -C <cwd> remote get-url
|
|
||||||
origin`.
|
```bash
|
||||||
|
tailscale status
|
||||||
|
ssh <login>@<host> '$SHELL -ic "pm2 jlist"' # the officer entry's pm_cwd
|
||||||
|
ssh <login>@<host> 'git -C <cwd> remote get-url origin'
|
||||||
|
getent hosts <domain>
|
||||||
|
```
|
||||||
|
|||||||
+8
-3
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.21.01",
|
"version": "2026.08.25.01",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/databases/*",
|
"src/databases/*",
|
||||||
"src/workspaces/*"
|
"src/workspaces/*"
|
||||||
],
|
],
|
||||||
|
"engines": {
|
||||||
|
"bun": "1.3.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
||||||
"gen:index": "bun run ./scripts/gen-index.ts",
|
"gen:index": "bun run ./scripts/gen-index.ts",
|
||||||
@@ -27,14 +30,16 @@
|
|||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
|
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
|
||||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||||
"setup": "bash scripts/install.sh",
|
"setup": "bash scripts/install.sh",
|
||||||
"check:theme": "bun scripts/check-theme-tokens.ts"
|
"check:theme": "bun scripts/check-theme-tokens.ts",
|
||||||
|
"docs:check": "bun run scripts/docs-check.ts",
|
||||||
|
"skills:platform": "bun run scripts/gen-platform-skill.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||||
|
|||||||
+229
-24
@@ -9,36 +9,140 @@ one.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Arming for the first time — there is nothing to rehearse against
|
||||||
|
|
||||||
|
General commander doctrine says to run the action once before arming, so it is known to work unattended.
|
||||||
|
**That does not apply here, and reading it as "replay the last release" is wrong.**
|
||||||
|
|
||||||
|
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
|
||||||
|
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
|
||||||
|
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
|
||||||
|
release, which you must never do.
|
||||||
|
|
||||||
|
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
|
||||||
|
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
|
||||||
|
fights the claim or invents an occasion.
|
||||||
|
|
||||||
|
Two things follow from it, and they are the reason this is fine rather than a gap:
|
||||||
|
|
||||||
|
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
|
||||||
|
What is unproven is this loop, not the rollout.
|
||||||
|
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
|
||||||
|
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
|
||||||
|
|
||||||
## What wakes you
|
## What wakes you
|
||||||
|
|
||||||
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
|
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
|
||||||
there is a release to deploy that has not been deployed.
|
there is a release to deploy that has not been deployed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/officer-dev/platform && git fetch --quiet origin HEAD
|
git ls-remote --tags origin 'refs/tags/deploy/*'
|
||||||
git log --oneline HEAD..FETCH_HEAD | grep DEPLOY_NOW
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Nothing matching** → say "no release pending" and stop. Do not pull, do not touch a server.
|
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
|
||||||
- **A commit whose subject begins `DEPLOY_NOW`** → that is the release. Continue.
|
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
|
||||||
|
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
|
||||||
|
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
|
||||||
|
long stretches.
|
||||||
|
|
||||||
`DEPLOY_NOW` must be at the **start of the subject line**, not merely present. This document contains the
|
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
|
||||||
word repeatedly and must never trigger a deploy; so must any commit that discusses one.
|
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
|
||||||
|
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
|
||||||
|
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
|
||||||
|
|
||||||
**Already deployed?** A release is deployed when `deploy/<version>.report.md` exists on `origin`. Check
|
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
|
||||||
before acting. A watcher that restarts must not redeploy, and this is the only thing standing between it
|
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
|
||||||
and doing so.
|
already trusted.
|
||||||
|
|
||||||
---
|
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
|
||||||
|
a commit message, not a branch, not a push.
|
||||||
|
|
||||||
|
**Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
|
||||||
|
about deploying contains it, this file included, and so does every conversation planning one — so the
|
||||||
|
trigger has to be defended with rules about where in the subject line it sits, and a rule like that is
|
||||||
|
one somebody eventually gets wrong. A tag cannot be typed into prose. It is created deliberately, it
|
||||||
|
names its own version, and `git ls-remote` answers "is there one" in a single call without fetching.
|
||||||
|
|
||||||
|
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
|
||||||
|
- **A `deploy/*` tag with no local record** → that is the release. Continue.
|
||||||
|
|
||||||
|
### One attempt per release, and you do not enforce that yourself
|
||||||
|
|
||||||
|
**Before you spawn or run anything for a version, claim it:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/deploy-claim.sh claim <version> # exit 0 = it is yours, exit 1 = do not proceed
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit 1 means somebody already attempted this release. **Stop. Do not retry it, do not deploy it, and do
|
||||||
|
not decide the previous attempt does not count.** Say what the script said and move on.
|
||||||
|
|
||||||
|
On success, record it through the same script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/deploy-claim.sh done <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this is a script and not a rule you follow.** A watcher loop is written fresh each time one arms, so
|
||||||
|
a rule that lives only in this file gets re-implemented from scratch every time — and the wrong version of
|
||||||
|
it looks right: test for the `.done` file, which exists only on SUCCESS. Write that and a failing release
|
||||||
|
spawns a new agent every poll, all night, each one costing money. That is not hypothetical. It is what the
|
||||||
|
first watcher did on 2026-08-21: a careful loop, honest logging, and an unbounded retry inside it.
|
||||||
|
|
||||||
|
So the rule is code. `mkdir` is the claim — atomic, one syscall that both tests and takes, no window
|
||||||
|
between checking and acting. The script refuses a second claim and keeps refusing, whatever happened to the
|
||||||
|
first attempt.
|
||||||
|
|
||||||
|
**A failed release is therefore not retried. It is looked at.** Clearing a claim is a deliberate human act
|
||||||
|
(`rm -rf …/<version>.claim`) or a new tag. A loop cannot undo its own claim, which is the property that
|
||||||
|
makes the storm impossible rather than unlikely.
|
||||||
|
|
||||||
|
### The record is LOCAL, not in git
|
||||||
|
|
||||||
|
```
|
||||||
|
$OFFICER_ROOT/data/deploys/<version>.done
|
||||||
|
```
|
||||||
|
|
||||||
|
That file's existence means deployed. Written after the rollout, before the report is pushed.
|
||||||
|
|
||||||
|
**This is deliberate and it is the important part.** The obvious design makes the pushed report the
|
||||||
|
marker — and then a failed `git push` means the release looks undeployed and the next tick **does it all
|
||||||
|
again**. Pushes here are genuinely unreliable: gitea's public SSH is rate-limited because every
|
||||||
|
connection arrives from one proxy container IP and internet bots get it penalised, which cost several
|
||||||
|
retries on 2026-08-21.
|
||||||
|
|
||||||
|
So the lock is local and cannot fail for a network reason. The report is an ARTEFACT — valuable, and not
|
||||||
|
load-bearing. A missing report is a missing record; a missing lock is a second deploy.
|
||||||
|
|
||||||
|
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
|
||||||
|
clone does not arrive believing it has deployed nothing.
|
||||||
|
|
||||||
|
## Before a release is tagged — not your job, but know it happened
|
||||||
|
|
||||||
|
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
|
||||||
|
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
|
||||||
|
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
|
||||||
|
|
||||||
|
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
|
||||||
|
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
|
||||||
|
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
|
||||||
|
thirteen, listing eleven processes that do not run here while omitting six that do.
|
||||||
|
|
||||||
|
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
|
||||||
|
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
|
||||||
|
it would put a prose edit between two hosts.
|
||||||
|
|
||||||
## The release
|
## The release
|
||||||
|
|
||||||
The triggering commit carries everything:
|
The tag names the version. The commit it points at carries the rest:
|
||||||
|
|
||||||
- `package.json` — `version` bumped to `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps' scheme. `NN`
|
- `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
|
||||||
increments for a second deploy on the same day.
|
scheme. `NN` increments for a second deploy on the same day.
|
||||||
- `deploy/<version>.md` — **the plan**. What to run, in order, and on which hosts. Written by hand.
|
- `deploy/<version>.md` — **the plan**. What to run, in order, and on which hosts. Written by hand.
|
||||||
|
|
||||||
|
If the tag and `package.json` disagree, **stop and say so**. One of them is a mistake and guessing which
|
||||||
|
is how the wrong tree gets deployed.
|
||||||
|
|
||||||
If the plan file is missing, **stop and say so**. A release with no plan is not a release you can guess
|
If the plan file is missing, **stop and say so**. A release with no plan is not a release you can guess
|
||||||
at, and guessing is how a database gets a schema it was not ready for.
|
at, and guessing is how a database gets a schema it was not ready for.
|
||||||
|
|
||||||
@@ -49,8 +153,18 @@ at, and guessing is how a database gets a schema it was not ready for.
|
|||||||
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
|
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
|
||||||
because a copy rots the day a server moves and the copy is always the one somebody trusts.
|
because a copy rots the day a server moves and the copy is always the one somebody trusts.
|
||||||
|
|
||||||
That document holds every machine, its tailnet name, its checkout and whether a platform runs on it. Read
|
That document holds every machine, its tailnet name, **its login user**, its checkout and whether a
|
||||||
it at the start of every deploy, and treat the hosts marked as having no platform as out of scope.
|
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
|
||||||
|
platform as out of scope.
|
||||||
|
|
||||||
|
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
|
||||||
|
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
|
||||||
|
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
|
||||||
|
told. Read that section before you try to reach anything.
|
||||||
|
|
||||||
|
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
|
||||||
|
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
|
||||||
|
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
|
||||||
|
|
||||||
Four things that will catch you out, all verified rather than assumed:
|
Four things that will catch you out, all verified rather than assumed:
|
||||||
|
|
||||||
@@ -71,9 +185,19 @@ Four things that will catch you out, all verified rather than assumed:
|
|||||||
Per host, in this order. Do them **one host at a time** and keep a record as you go — a report you
|
Per host, in this order. Do them **one host at a time** and keep a record as you go — a report you
|
||||||
assemble at the end from memory is a report that will be wrong about the host that failed.
|
assemble at the end from memory is a report that will be wrong about the host that failed.
|
||||||
|
|
||||||
1. **Refuse a dirty tree.** `git status --porcelain` — if anything is uncommitted, skip this host and say
|
1. **Refuse a dirty tree — TRACKED changes only.** `git status --porcelain --untracked-files=no`. Plain
|
||||||
so. Never discard someone's work to ship a release.
|
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
|
||||||
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
|
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
|
||||||
|
discard someone's work to ship a release.
|
||||||
|
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
|
||||||
|
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
|
||||||
|
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
|
||||||
|
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
|
||||||
|
harmless and it need not have been.
|
||||||
|
|
||||||
|
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
|
||||||
|
because the host is AHEAD is the same answer — report it, do not reset.
|
||||||
|
|
||||||
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
|
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
|
||||||
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
|
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
|
||||||
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
|
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
|
||||||
@@ -89,13 +213,77 @@ assemble at the end from memory is a report that will be wrong about the host th
|
|||||||
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
||||||
one that stopped in the middle and said nothing about the four hosts it never reached.
|
one that stopped in the middle and said nothing about the four hosts it never reached.
|
||||||
|
|
||||||
|
### Schema changes, and why `db:push` cannot be trusted
|
||||||
|
|
||||||
|
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
||||||
|
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
||||||
|
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
||||||
|
success while the schema is half-applied.
|
||||||
|
|
||||||
|
So on any release whose plan says the schema changed:
|
||||||
|
|
||||||
|
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
||||||
|
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
||||||
|
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
||||||
|
a guess.
|
||||||
|
|
||||||
|
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
||||||
|
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
||||||
|
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
||||||
|
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
||||||
|
|
||||||
|
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
||||||
|
|
||||||
|
This is allowed and it is not a licence to improvise. The rules, in order:
|
||||||
|
|
||||||
|
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
||||||
|
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
||||||
|
that is a code change and it is not yours to make on five production hosts at 3am.
|
||||||
|
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
||||||
|
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
||||||
|
the fleet has diverged in a way nothing will detect.
|
||||||
|
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
||||||
|
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
||||||
|
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
||||||
|
forensic exercise.
|
||||||
|
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
||||||
|
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
||||||
|
finding for the report rather than something to push past.
|
||||||
|
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
||||||
|
the specific check, and its answer, for each.
|
||||||
|
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
||||||
|
repair you invented.
|
||||||
|
|
||||||
|
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
||||||
|
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
||||||
|
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
||||||
|
|
||||||
|
### Skip the host you are running on
|
||||||
|
|
||||||
|
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
||||||
|
|
||||||
|
You are a session inside `officer-claude-code` on that machine. Restarting it ends you mid-rollout —
|
||||||
|
which is not a hypothetical: on 2026-08-21 a manual deploy reached its own host last and had to leave
|
||||||
|
that one sidecar alone to survive writing its own report.
|
||||||
|
|
||||||
|
Restarting only `officer` and leaving your own sidecar behind would work, and it is worse: it puts one
|
||||||
|
host in a state nobody chose, a step behind the others, discovered later. So skip the whole host and say
|
||||||
|
which one. The owner restarts it deliberately, as the last step, once they are happy with the rest.
|
||||||
|
|
||||||
|
Find it with `hostname` and match it against the list — do not assume you are on any particular one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The report
|
## The report
|
||||||
|
|
||||||
Commit `deploy/<version>.report.md` to the platform repository and push it. Subject line: the version and
|
Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
|
||||||
the outcome, **without the code word** — a report is not a release, and a report that triggers a deploy
|
`deploy/<version>.report.md` and push it.
|
||||||
would loop forever.
|
|
||||||
|
That order matters. The record is what stops a second deploy, and the push is the part that can fail for
|
||||||
|
reasons having nothing to do with the rollout. **If the push fails, say so and stop — do not retry into a
|
||||||
|
loop and do not treat it as a failed deploy.** The deploy succeeded; its paperwork is pending.
|
||||||
|
|
||||||
|
Never tag the report. A tag is the trigger, and a report that tags itself deploys itself forever.
|
||||||
|
|
||||||
Per host: what its HEAD was before and after, which steps ran, which were skipped and why, which
|
Per host: what its HEAD was before and after, which steps ran, which were skipped and why, which
|
||||||
processes restarted, and whether they came back. Then one line at the top saying how many hosts
|
processes restarted, and whether they came back. Then one line at the top saying how many hosts
|
||||||
@@ -107,7 +295,24 @@ that are hard to reconstruct afterwards.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is missing, as of writing
|
## Being woken
|
||||||
|
|
||||||
**Nothing wakes you yet.** A watcher, a cron entry or a repeating prompt has to exist and does not. Until
|
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
|
||||||
then this runs when a person starts it.
|
agent, then re-arm**. The wait happens in the shell, not in a model tick — a loop polling
|
||||||
|
`git ls-remote`, which is one network call and no tokens.
|
||||||
|
|
||||||
|
The loop cannot do a deploy: it cannot read a plan, judge a host or write a report. So on firing it spawns
|
||||||
|
a headless agent — `claude -p "<read this file and follow it>" --dangerously-skip-permissions
|
||||||
|
--output-format=stream-json --verbose` — and pipes the events through `jq` so the job panel shows progress
|
||||||
|
instead of a blank box for twenty minutes.
|
||||||
|
|
||||||
|
Two things that loop must get right, both learned by getting them wrong on 2026-08-21:
|
||||||
|
|
||||||
|
- **Claim the version first**, per the section above. This is the one that costs money when missed.
|
||||||
|
- **Set `OFFICER_ROOT` explicitly.** It is empty in the sidecar's environment, so the lock path resolves to
|
||||||
|
`/data/deploys` and the release looks permanently undeployed.
|
||||||
|
|
||||||
|
The deploy therefore runs in a **separate** agent, not in the commander's own conversation: no Command HQ
|
||||||
|
row, no cost on the commander's line, and its reasoning lives in that child's transcript. That is the price
|
||||||
|
of putting the wait in the shell, and it is worth it — a model-side tick that re-reads a whole conversation
|
||||||
|
to learn nothing is the thing being avoided.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
Give a credential to every commander that predates them.
|
||||||
|
|
||||||
|
A commander created before `credential.ts` existed has no `officer.env` and no key row, so it cannot
|
||||||
|
reach the platform at all — which is the state that made the first one curl the whisper container
|
||||||
|
directly. Deleting and recreating it would fix that and throw away the conversation, which is the one
|
||||||
|
thing a commander is: a long-running agreement about a job.
|
||||||
|
|
||||||
|
So this is additive and re-runnable:
|
||||||
|
|
||||||
|
- a commander that already has `officer.env` is left alone, whole;
|
||||||
|
- `CLAUDE.md` is APPENDED to, never rewritten — it is a living document its commander has been
|
||||||
|
extending, and the whole point of not recreating it is that it survives;
|
||||||
|
- the key is named `commander:<dirName>`, so it revokes with the commander like any other.
|
||||||
|
|
||||||
|
Run it after a deploy that introduces credentials, on each host:
|
||||||
|
|
||||||
|
bun run --env-file=.env scripts/backfill-commander-credentials.ts
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync, appendFileSync, chmodSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getUsers, listCommandHqSessions } from 'officerdb';
|
||||||
|
import { writeCommanderCredential, commanderEnvPath } from '../src/servers/api/command-hq/credential';
|
||||||
|
import { credentialSection, notifySection } from '../src/servers/api/command-hq/directory';
|
||||||
|
import { getCommanderDir } from '../src/servers/data-path';
|
||||||
|
import { lookupOsUser } from '../src/servers/os-user';
|
||||||
|
|
||||||
|
/** Best effort, and only for a member: the owner already owns everything under DATA_PATH. */
|
||||||
|
async function giveToAccount(osUser: string | null, path: string): Promise<void> {
|
||||||
|
if (!osUser) return;
|
||||||
|
const ids = await lookupOsUser(osUser);
|
||||||
|
if (!ids) return;
|
||||||
|
Bun.spawnSync(['sudo', '-n', 'chown', `${ids.uid}:${ids.gid}`, path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per user, because `listCommandHqSessions` is scoped by one — which is the right shape everywhere else
|
||||||
|
// and only awkward here. There are few accounts and few commanders; this runs once per deploy.
|
||||||
|
const rows: Array<{
|
||||||
|
dirName: string | null;
|
||||||
|
name: string | null;
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
osUser: string | null;
|
||||||
|
}> = [];
|
||||||
|
for (const user of await getUsers()) {
|
||||||
|
for (const row of await listCommandHqSessions(user.id)) {
|
||||||
|
rows.push({ dirName: row.dirName, name: row.name, userId: user.id, email: user.email, osUser: user.osUser });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) console.log('no commanders on this host');
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = `${row.dirName} (${row.name})`;
|
||||||
|
|
||||||
|
if (!row.dirName) {
|
||||||
|
console.log(`skip ${label} — no directory name`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = getCommanderDir(row.email, row.dirName);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
console.log(`skip ${label} — no directory on disk`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeMd = join(dir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a section only when it is not already described.
|
||||||
|
*
|
||||||
|
* The marker is a string the section itself contains, so a commander whose CLAUDE.md was hand-edited to
|
||||||
|
* describe the same thing does not end up with it twice.
|
||||||
|
*/
|
||||||
|
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
||||||
|
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
||||||
|
if (existing.includes(marker)) return false;
|
||||||
|
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||||
|
await giveToAccount(row.osUser, claudeMd);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const envPath = commanderEnvPath(row.email, row.dirName);
|
||||||
|
|
||||||
|
if (!existsSync(envPath)) {
|
||||||
|
const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName });
|
||||||
|
chmodSync(path, 0o600);
|
||||||
|
await giveToAccount(row.osUser, path);
|
||||||
|
await appendIfMissing('officer.env', credentialSection(path));
|
||||||
|
console.log(`MINT ${label} — ${path}`);
|
||||||
|
} else {
|
||||||
|
console.log(`ok ${label} — already has a credential`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Separately, because it arrived later and the commanders that need it most already have a credential.
|
||||||
|
Without it a commander does not know Officer HAS a notification route, so it reaches for whatever its
|
||||||
|
harness offers — a different channel, with its own rules about when it may interrupt.
|
||||||
|
*/
|
||||||
|
if (await appendIfMissing('/api/notify/_officer/notify', notifySection())) {
|
||||||
|
console.log(`NOTIFY ${label} — told how to reach the owner`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# One attempt per release, enforced by the filesystem.
|
||||||
|
#
|
||||||
|
# A watcher loop asks this before it spawns anything. Exit 0 means "you have the claim, go". Exit 1 means
|
||||||
|
# "somebody already had it" — and that answer never changes back, whatever happened to the attempt that
|
||||||
|
# held it. A release that fails is not retried; it is looked at.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A SCRIPT AND NOT A PARAGRAPH IN prompts/deploy-commander.md
|
||||||
|
#
|
||||||
|
# The watcher loop is written fresh by an agent each time it arms, so a rule that lives only in prose is
|
||||||
|
# re-implemented from scratch on every arming, and "skip a version already attempted" has a wrong version
|
||||||
|
# that looks right: test the .done file, which only exists on SUCCESS. Write that and a failing deploy
|
||||||
|
# spawns a fresh agent every poll, all night, each one costing money. That is exactly what happened on
|
||||||
|
# 2026-08-21 — the loop was careful, logged honestly, and still had an unbounded retry in it.
|
||||||
|
#
|
||||||
|
# WHY BASH AND NOT bun
|
||||||
|
#
|
||||||
|
# The thing that stops a retry storm must not depend on the checkout being healthy. A deploy that dies on
|
||||||
|
# `bun install` is precisely when this has to work.
|
||||||
|
#
|
||||||
|
# WHY mkdir
|
||||||
|
#
|
||||||
|
# It is atomic on every POSIX filesystem and it fails if the target exists — one syscall that both tests
|
||||||
|
# and claims. `[ -f x ] && ...` is two steps with a race between them, and two pollers a second apart is
|
||||||
|
# not a hypothetical when a watcher is restarted.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
DEPLOYS_DIR="${DEPLOYS_DIR:-$HOME/officer-dev/data/deploys}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'USAGE'
|
||||||
|
usage: deploy-claim.sh claim <version> take the one attempt for this release (exit 0 = yours, 1 = taken)
|
||||||
|
deploy-claim.sh done <version> record that the attempt succeeded
|
||||||
|
deploy-claim.sh state <version> print unclaimed | claimed | done
|
||||||
|
deploy-claim.sh list every release this host knows about
|
||||||
|
|
||||||
|
To retry a release deliberately: rm -rf <deploys-dir>/<version>.claim
|
||||||
|
That is a human act on purpose. A loop cannot undo its own claim.
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
version_ok() {
|
||||||
|
# The version reaches here from a git tag, so it is attacker-adjacent input used to build a path.
|
||||||
|
case "$1" in
|
||||||
|
*[!0-9.]* | '' | *..*) return 1 ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
[ -n "$CMD" ] || usage
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
list)
|
||||||
|
mkdir -p "$DEPLOYS_DIR"
|
||||||
|
found=0
|
||||||
|
for d in "$DEPLOYS_DIR"/*.claim; do
|
||||||
|
[ -e "$d" ] || continue
|
||||||
|
found=1
|
||||||
|
v=$(basename "$d" .claim)
|
||||||
|
if [ -f "$DEPLOYS_DIR/$v.done" ]; then s="done"; else s="claimed, no success recorded"; fi
|
||||||
|
printf '%-16s %s\n' "$v" "$s"
|
||||||
|
done
|
||||||
|
[ "$found" = 1 ] || echo "no releases attempted on this host"
|
||||||
|
;;
|
||||||
|
|
||||||
|
claim | done | state)
|
||||||
|
V="${2:-}"
|
||||||
|
[ -n "$V" ] || usage
|
||||||
|
version_ok "$V" || { echo "deploy-claim: refusing a version that is not digits and dots: $V" >&2; exit 2; }
|
||||||
|
mkdir -p "$DEPLOYS_DIR" || exit 2
|
||||||
|
CLAIM="$DEPLOYS_DIR/$V.claim"
|
||||||
|
DONE="$DEPLOYS_DIR/$V.done"
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
state)
|
||||||
|
if [ -f "$DONE" ]; then echo done
|
||||||
|
elif [ -d "$CLAIM" ]; then echo claimed
|
||||||
|
else echo unclaimed; fi
|
||||||
|
;;
|
||||||
|
claim)
|
||||||
|
# A pre-existing .done with no .claim is a release deployed before this script existed. Honour it.
|
||||||
|
if [ -f "$DONE" ]; then
|
||||||
|
echo "deploy-claim: $V is already deployed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if mkdir "$CLAIM" 2>/dev/null; then
|
||||||
|
printf '%s claimed on %s by pid %s\n' "$(date -Iseconds)" "$(hostname)" "$$" > "$CLAIM/by"
|
||||||
|
echo "deploy-claim: $V is yours"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'deploy-claim: %s was already attempted — %s\n' "$V" "$(cat "$CLAIM/by" 2>/dev/null || echo 'claim exists')" >&2
|
||||||
|
echo "deploy-claim: NOT retrying. Look at what failed, then rm -rf $CLAIM to allow another attempt." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
done)
|
||||||
|
[ -d "$CLAIM" ] || echo "deploy-claim: warning — recording success for $V that was never claimed" >&2
|
||||||
|
printf '%s succeeded on %s\n' "$(date -Iseconds)" "$(hostname)" >> "$DONE"
|
||||||
|
echo "deploy-claim: $V recorded as deployed"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const SCRIPT = join(import.meta.dir, 'deploy-claim.sh');
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
const run = (...args: string[]) => {
|
||||||
|
const p = Bun.spawnSync([SCRIPT, ...args], { env: { ...process.env, DEPLOYS_DIR: dir } });
|
||||||
|
return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'deploy-claim-'));
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
// The whole point of this script is that the SECOND caller is refused. Everything else is scaffolding.
|
||||||
|
describe('deploy-claim', () => {
|
||||||
|
it('grants once and refuses after, whatever the outcome was', () => {
|
||||||
|
expect(run('claim', '2026.09.01.01').code).toBe(0);
|
||||||
|
expect(run('claim', '2026.09.01.01').code).toBe(1);
|
||||||
|
// Still refused after a failure — no .done was ever written. This is the retry storm not happening.
|
||||||
|
expect(run('claim', '2026.09.01.01').code).toBe(1);
|
||||||
|
expect(run('state', '2026.09.01.01').out.trim()).toBe('claimed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a release already deployed, even with no claim beside it', () => {
|
||||||
|
// A release deployed by hand before this script existed. `deploy/2026.08.21.01` is exactly this case.
|
||||||
|
writeFileSync(join(dir, '2026.08.21.01.done'), 'deployed by hand\n');
|
||||||
|
expect(run('claim', '2026.08.21.01').code).toBe(1);
|
||||||
|
expect(run('claim', '2026.08.21.01').err).toContain('already deployed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a human hand the claim back, and only a human', () => {
|
||||||
|
expect(run('claim', '2026.09.01.02').code).toBe(0);
|
||||||
|
rmSync(join(dir, '2026.09.01.02.claim'), { recursive: true });
|
||||||
|
expect(run('claim', '2026.09.01.02').code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records success separately from the claim', () => {
|
||||||
|
run('claim', '2026.09.01.03');
|
||||||
|
run('done', '2026.09.01.03');
|
||||||
|
expect(run('state', '2026.09.01.03').out.trim()).toBe('done');
|
||||||
|
expect(existsSync(join(dir, '2026.09.01.03.done'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The version comes off a git tag, and it is concatenated into a path.
|
||||||
|
it('refuses anything that is not digits and dots', () => {
|
||||||
|
for (const bad of ['../../etc/passwd', 'x; rm -rf /', '2026.08.21.01/..', '', 'v1']) {
|
||||||
|
expect(run('claim', bad).code).toBe(2);
|
||||||
|
}
|
||||||
|
expect(existsSync(join(dir, '..', 'etc'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so plainly when nothing has been attempted', () => {
|
||||||
|
expect(run('list').out).toContain('no releases attempted');
|
||||||
|
});
|
||||||
|
});
|
||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# The wait half of the deploy commander: poll for a release tag, and when one appears that has not been
|
||||||
|
# attempted, spawn a headless agent to deploy it.
|
||||||
|
#
|
||||||
|
# WHY THIS IS IN THE REPOSITORY AND NOT WRITTEN FRESH EACH ARMING
|
||||||
|
#
|
||||||
|
# prompts/deploy-commander.md says the watcher loop is rewritten from scratch every time one arms, and
|
||||||
|
# treats that as a given. It is also how the same defect keeps arriving. The loop that was on disk at
|
||||||
|
# data/deploy-watch.sh from 2026-08-21 gated on "$v.done" — a file that exists only on SUCCESS — and never
|
||||||
|
# called deploy-claim.sh at all. That is precisely the retry storm the claim script was written to make
|
||||||
|
# impossible, reintroduced in the one place the claim script cannot reach: the caller that decides whether
|
||||||
|
# to invoke it. A rule enforced by a script nobody calls is not enforced.
|
||||||
|
#
|
||||||
|
# So the loop lives next to the claim it depends on, and changes with it.
|
||||||
|
#
|
||||||
|
# THE BASELINE IS THE CLAIM, NOT THE SUCCESS
|
||||||
|
#
|
||||||
|
# A watcher needs to know what counts as new. Here that is "no claim directory for this version" — a record
|
||||||
|
# of what has been HANDLED, not of what has SUCCEEDED. The distinction is the whole point: a release that
|
||||||
|
# failed is handled. It is looked at by a human, not retried by a loop.
|
||||||
|
#
|
||||||
|
# deploy-claim.sh honours a pre-existing .done with no .claim, so releases deployed before it existed are
|
||||||
|
# already skipped and arming this today fires on none of them.
|
||||||
|
#
|
||||||
|
# THE CLAIM IS TAKEN HERE, BEFORE THE SPAWN
|
||||||
|
#
|
||||||
|
# An action that claims its own work has claimed nothing if it dies before getting that far. So the loop
|
||||||
|
# claims, then spawns — which means the child must be TOLD the claim is already held. It is about to read
|
||||||
|
# prompts/deploy-commander.md, whose claim instruction is addressed to whoever reads it, and an obedient
|
||||||
|
# child that claims for itself gets exit 1 and correctly refuses to deploy. See PROMPT below.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PLATFORM_DIR="${PLATFORM_DIR:-$HOME/officer-dev/platform}"
|
||||||
|
# Empty in the sidecar's environment, where anything built from it resolves to /data/... and the record
|
||||||
|
# lands where nothing reads it. A watcher whose baseline is invisible to itself has none.
|
||||||
|
OFFICER_ROOT="${OFFICER_ROOT:-$HOME/officer-dev}"
|
||||||
|
DEPLOYS_DIR="${DEPLOYS_DIR:-$OFFICER_ROOT/data/deploys}"
|
||||||
|
POLL_SECONDS="${POLL_SECONDS:-120}"
|
||||||
|
export OFFICER_ROOT DEPLOYS_DIR
|
||||||
|
|
||||||
|
CLAIM_SH="$PLATFORM_DIR/scripts/deploy-claim.sh"
|
||||||
|
|
||||||
|
stamp() { date -Iseconds; }
|
||||||
|
|
||||||
|
if [ ! -x "$CLAIM_SH" ]; then
|
||||||
|
echo "$(stamp) deploy-watch: no claim script at $CLAIM_SH — refusing to arm without one" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DEPLOYS_DIR" || exit 2
|
||||||
|
|
||||||
|
echo "$(stamp) deploy-watch: armed. polling $(cd "$PLATFORM_DIR" && git remote get-url origin) every ${POLL_SECONDS}s"
|
||||||
|
echo "$(stamp) deploy-watch: baseline is $DEPLOYS_DIR — a version with a claim is never fired again"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# origin is the LOCAL gitea over the tailnet. The public hostname arrives through a proxy where every
|
||||||
|
# connection shares one source IP and SSH is refused for long stretches — which presents as an
|
||||||
|
# access-rights error and is not one. Eighty minutes of detection latency on 2026-08-21.
|
||||||
|
raw=$(cd "$PLATFORM_DIR" && timeout 30 git ls-remote --tags origin 'refs/tags/deploy/*' 2>&1)
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "$(stamp) tick: ls-remote failed (exit $rc) — ${raw%%$'\n'*}"
|
||||||
|
sleep "$POLL_SECONDS"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
versions=$(printf '%s\n' "$raw" | grep -v '\^{}' | awk '{print $2}' | sed -E 's#.*refs/tags/deploy/##' | sort -u)
|
||||||
|
|
||||||
|
pending=""
|
||||||
|
for v in $versions; do
|
||||||
|
[ -n "$v" ] || continue
|
||||||
|
[ "$("$CLAIM_SH" state "$v" 2>/dev/null)" = unclaimed ] && pending="$pending $v"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Every tick leaves a line, including the quiet ones: a fresh timestamp is the only thing that tells
|
||||||
|
# "nothing to deploy" apart from "the loop died".
|
||||||
|
echo "$(stamp) tick: $(printf '%s\n' "$versions" | grep -c .) tags,${pending:- none} pending"
|
||||||
|
|
||||||
|
for v in $pending; do
|
||||||
|
if ! "$CLAIM_SH" claim "$v"; then
|
||||||
|
# Lost a race with another watcher, or it was claimed between the state check and here. Either way
|
||||||
|
# it is not ours and never becomes ours.
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(stamp) deploy/$v claimed — spawning deploy agent"
|
||||||
|
|
||||||
|
# The child reads the procedure, which tells ITS reader to claim first. It must not: we hold the claim
|
||||||
|
# on its behalf, and its own claim would return exit 1 and stop the deploy it was spawned to do.
|
||||||
|
PROMPT="Read prompts/deploy-commander.md in $PLATFORM_DIR and follow it exactly, for the release deploy/$v.
|
||||||
|
|
||||||
|
The claim for $v is ALREADY HELD on your behalf by the watcher that spawned you. Do NOT run
|
||||||
|
'scripts/deploy-claim.sh claim $v' — it will refuse, correctly, because the claim is taken, and stopping
|
||||||
|
there would abandon a deploy you were started to perform. Everything else in that file applies unchanged,
|
||||||
|
including running 'scripts/deploy-claim.sh done $v' once the rollout has succeeded.
|
||||||
|
|
||||||
|
Roll it out across every host the plan names, skip the host you are running on, and write the report
|
||||||
|
exactly as the procedure specifies."
|
||||||
|
|
||||||
|
raw_log="$DEPLOYS_DIR/$v.child.jsonl"
|
||||||
|
|
||||||
|
# Nothing links a spawned agent to its parent and a child transcript does not last, so the stream is
|
||||||
|
# kept here as it arrives. stderr never enters the pipe: the CLI writes warnings there and one landing
|
||||||
|
# in the stream corrupts the first jq parse.
|
||||||
|
{
|
||||||
|
cd "$PLATFORM_DIR" && claude -p "$PROMPT" \
|
||||||
|
--dangerously-skip-permissions \
|
||||||
|
--output-format=stream-json \
|
||||||
|
--verbose
|
||||||
|
} 2> >(while IFS= read -r l; do echo " ! $l"; done) \
|
||||||
|
| tee -a "$raw_log" \
|
||||||
|
| jq -r --unbuffered '
|
||||||
|
if .type=="system" and .subtype=="init" then
|
||||||
|
" · child session " + .session_id
|
||||||
|
elif .type=="assistant" then
|
||||||
|
(.message.content[]? |
|
||||||
|
if .type=="tool_use" then
|
||||||
|
" ⚙ " + .name + ": " + ((.input.description // .input.command // .input.pattern // (.input | tostring)))
|
||||||
|
elif .type=="text" then
|
||||||
|
" » " + .text
|
||||||
|
else empty end)
|
||||||
|
elif .type=="user" then
|
||||||
|
(.message.content[]? | select(.type=="tool_result") |
|
||||||
|
" " + (if (.content|type)=="string" then .content else (.content // [] | map(.text? // "") | join(" ")) end))
|
||||||
|
elif .type=="result" then
|
||||||
|
" = deploy agent " + .subtype + ", " + (.num_turns|tostring) + " turns, " + (.total_cost_usd|tostring) + " usd"
|
||||||
|
else empty end
|
||||||
|
' 2>/dev/null
|
||||||
|
|
||||||
|
if [ -f "$DEPLOYS_DIR/$v.done" ]; then
|
||||||
|
echo "$(stamp) deploy/$v SUCCEEDED — recorded, transcript at $raw_log"
|
||||||
|
else
|
||||||
|
# The claim stays held. This is the loop's most important line and the one that used to say
|
||||||
|
# "will retry next poll".
|
||||||
|
echo "$(stamp) deploy/$v FAILED — no .done written. NOT retrying; the claim is held."
|
||||||
|
echo "$(stamp) transcript: $raw_log"
|
||||||
|
echo "$(stamp) to allow another attempt, deliberately: rm -rf $DEPLOYS_DIR/$v.claim"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
sleep "$POLL_SECONDS"
|
||||||
|
done
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { Glob } from 'bun';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Which settings sections actually exist, read from the screens that declare them.
|
||||||
|
|
||||||
|
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
|
||||||
|
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
|
||||||
|
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
|
||||||
|
narrow enough to be obvious when it breaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dir, '..');
|
||||||
|
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
|
||||||
|
|
||||||
|
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
|
||||||
|
export async function declaredSettingsPaths(): Promise<Set<string>> {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const glob = new Glob('**/*.tsx');
|
||||||
|
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
|
||||||
|
const body = readFileSync(file, 'utf-8');
|
||||||
|
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
|
||||||
|
if (!base) continue;
|
||||||
|
paths.add(base);
|
||||||
|
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings routes Spotlight offers, and which of them nothing declares.
|
||||||
|
*
|
||||||
|
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
|
||||||
|
* lucide and React.
|
||||||
|
*/
|
||||||
|
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
|
||||||
|
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
|
||||||
|
const body = readFileSync(file, 'utf-8');
|
||||||
|
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
|
||||||
|
const declared = await declaredSettingsPaths();
|
||||||
|
return { offered, missing: offered.filter((p) => !declared.has(p)) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
Does the documentation still match the code?
|
||||||
|
|
||||||
|
── Why a script and not a habit ──
|
||||||
|
|
||||||
|
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
|
||||||
|
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
|
||||||
|
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
|
||||||
|
|
||||||
|
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
|
||||||
|
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
|
||||||
|
number is exactly the thing nobody re-derives while editing a sentence around it.
|
||||||
|
|
||||||
|
Real examples this was written from, both live at the time:
|
||||||
|
|
||||||
|
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
|
||||||
|
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
|
||||||
|
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
|
||||||
|
|
||||||
|
── The rule for adding a check ──
|
||||||
|
|
||||||
|
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
|
||||||
|
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
|
||||||
|
the same edit that misses the doc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
type Finding = { file: string; claim: string; documented: string; actual: string };
|
||||||
|
type CheckResult = { findings: Finding[]; skipped?: string };
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dir, '..');
|
||||||
|
const WORKSPACE = resolve(ROOT, '..');
|
||||||
|
|
||||||
|
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
|
||||||
|
|
||||||
|
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
|
||||||
|
const WORDS: Record<string, number> = {
|
||||||
|
ten: 10,
|
||||||
|
eleven: 11,
|
||||||
|
twelve: 12,
|
||||||
|
thirteen: 13,
|
||||||
|
fourteen: 14,
|
||||||
|
fifteen: 15,
|
||||||
|
sixteen: 16,
|
||||||
|
seventeen: 17,
|
||||||
|
eighteen: 18,
|
||||||
|
nineteen: 19,
|
||||||
|
twenty: 20,
|
||||||
|
'twenty-one': 21,
|
||||||
|
'twenty-two': 22,
|
||||||
|
'twenty-three': 23,
|
||||||
|
'twenty-four': 24,
|
||||||
|
'twenty-five': 25,
|
||||||
|
};
|
||||||
|
const asNumber = (raw: string): number | null => {
|
||||||
|
const digits = Number(raw);
|
||||||
|
if (Number.isFinite(digits)) return digits;
|
||||||
|
return WORDS[raw.toLowerCase()] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A documented count, found by a pattern with one capture group, compared to the live value.
|
||||||
|
*
|
||||||
|
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
|
||||||
|
*/
|
||||||
|
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
|
||||||
|
const body = read(file);
|
||||||
|
if (!body) return { findings: [], skipped: `${file} not found` };
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
for (const match of body.matchAll(pattern)) {
|
||||||
|
const stated = asNumber(match[1] ?? '');
|
||||||
|
if (stated === null || stated === actual) continue;
|
||||||
|
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
|
||||||
|
}
|
||||||
|
return { findings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const all: Finding[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const record = (r: CheckResult) => {
|
||||||
|
all.push(...r.findings);
|
||||||
|
if (r.skipped) skipped.push(r.skipped);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
|
||||||
|
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||||
|
record(
|
||||||
|
checkCount(
|
||||||
|
join(ROOT, 'CLAUDE.md'),
|
||||||
|
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||||
|
PERMISSIONS.length,
|
||||||
|
'permission registry entries',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
|
||||||
|
// admission that it needs a check rather than a caveat.
|
||||||
|
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
if (pm2.exitCode === 0) {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
|
||||||
|
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
|
||||||
|
record(
|
||||||
|
checkCount(
|
||||||
|
join(WORKSPACE, 'CLAUDE.md'),
|
||||||
|
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
|
||||||
|
officer,
|
||||||
|
'officer PM2 entries',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
skipped.push('pm2 jlist did not parse');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
|
||||||
|
skipped.push('pm2 unavailable — PM2 entry count unchecked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
|
||||||
|
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
|
||||||
|
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
|
||||||
|
const declared = await SPOTLIGHT_SETTINGS_PATHS();
|
||||||
|
for (const bad of declared.missing) {
|
||||||
|
all.push({
|
||||||
|
file: 'Spotlight/useSpotlightItems.ts',
|
||||||
|
claim: 'settings route offered by Spotlight',
|
||||||
|
documented: bad,
|
||||||
|
actual: 'no screen declares that section',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
|
||||||
|
// an agent quotes it to a user with no way to tell it is out of date.
|
||||||
|
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
|
||||||
|
const expected = await renderPlatformSkill();
|
||||||
|
const onDisk = read(SKILL_PATH);
|
||||||
|
if (onDisk === null) {
|
||||||
|
all.push({
|
||||||
|
file: 'capabilities/skills/platform/SKILL.md',
|
||||||
|
claim: 'generated platform skill',
|
||||||
|
documented: 'missing',
|
||||||
|
actual: 'run `bun run skills:platform`',
|
||||||
|
});
|
||||||
|
} else if (onDisk !== expected) {
|
||||||
|
all.push({
|
||||||
|
file: 'capabilities/skills/platform/SKILL.md',
|
||||||
|
claim: 'generated platform skill is stale',
|
||||||
|
documented: `${onDisk.split('\n').length} lines on disk`,
|
||||||
|
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const note of skipped) console.log(` skipped: ${note}`);
|
||||||
|
if (all.length === 0) {
|
||||||
|
console.log('docs:check — every checkable claim matches the code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
|
||||||
|
for (const f of all) {
|
||||||
|
console.log(` ${f.file}`);
|
||||||
|
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -34,6 +34,44 @@ const fail = (what: string, fix: string) => {
|
|||||||
};
|
};
|
||||||
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
|
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
|
||||||
|
|
||||||
|
console.log('\n── Runtime ──\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bun is deliberately held at a series — `BUN_SERIES` in
|
||||||
|
* scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while.
|
||||||
|
*
|
||||||
|
* The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version
|
||||||
|
* argument (it offers latest-stable or --canary and nothing else), so it always jumps to the
|
||||||
|
* newest release and walks a pinned machine off the pin without saying so. This is the only
|
||||||
|
* thing that notices, which is why a version check sits in a script otherwise about accounts.
|
||||||
|
*
|
||||||
|
* The wanted series is READ from `engines.bun` rather than repeated here, because a second
|
||||||
|
* hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an
|
||||||
|
* install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is
|
||||||
|
* the declaration and this is the enforcement.
|
||||||
|
*/
|
||||||
|
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as {
|
||||||
|
engines?: { bun?: string };
|
||||||
|
};
|
||||||
|
const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, '');
|
||||||
|
const nowSeries = Bun.version.split('.').slice(0, 2).join('.');
|
||||||
|
|
||||||
|
if (!wantSeries) {
|
||||||
|
fail(
|
||||||
|
'package.json declares no engines.bun',
|
||||||
|
'The setup scripts pin a bun series and nothing else records which one. Add it.',
|
||||||
|
);
|
||||||
|
} else if (nowSeries === wantSeries) {
|
||||||
|
pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`);
|
||||||
|
} else {
|
||||||
|
fail(
|
||||||
|
`bun ${Bun.version} is outside the pinned ${wantSeries} series`,
|
||||||
|
`Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` +
|
||||||
|
`'curl -fsSL https://bun.sh/install | bash -s bun-v<newest ${wantSeries}.x>', or raise ` +
|
||||||
|
`engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n── Accounts ──\n');
|
console.log('\n── Accounts ──\n');
|
||||||
|
|
||||||
const users = await getUsers();
|
const users = await getUsers();
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
|
||||||
|
|
||||||
|
── Why generated ──
|
||||||
|
|
||||||
|
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
|
||||||
|
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
|
||||||
|
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
|
||||||
|
|
||||||
|
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
|
||||||
|
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
|
||||||
|
and the prose is limited to what no registry can express.
|
||||||
|
|
||||||
|
── The split, and where the line is ──
|
||||||
|
|
||||||
|
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
|
||||||
|
keyboard shortcuts
|
||||||
|
PROSE what the platform IS, what a permission KIND means for a user, where to look next
|
||||||
|
|
||||||
|
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
|
||||||
|
differs, so a stale skill is a failed check rather than a confident wrong answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dir, '..');
|
||||||
|
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
|
||||||
|
|
||||||
|
const KIND_MEANING: Record<string, string> = {
|
||||||
|
core: 'every account has it and it cannot be denied — the caller acting on themselves',
|
||||||
|
app: 'grantable per role, at read or write',
|
||||||
|
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
|
||||||
|
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
|
||||||
|
admin: 'the server owner only',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function renderPlatformSkill(): Promise<string> {
|
||||||
|
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||||
|
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||||
|
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
|
||||||
|
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
|
||||||
|
const enString = (key: string): string => {
|
||||||
|
const value = key.split('.').reduce<unknown>((node, part) => {
|
||||||
|
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
|
||||||
|
return undefined;
|
||||||
|
}, enCatalog);
|
||||||
|
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||||
|
for (const p of PERMISSIONS) {
|
||||||
|
const list = byKind.get(p.kind) ?? [];
|
||||||
|
list.push(p);
|
||||||
|
byKind.set(p.kind, list as typeof PERMISSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const out = (s = '') => lines.push(s);
|
||||||
|
|
||||||
|
out('---');
|
||||||
|
out('name: Officer platform');
|
||||||
|
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||||||
|
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||||||
|
out('description: >-');
|
||||||
|
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
|
||||||
|
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
|
||||||
|
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
|
||||||
|
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
|
||||||
|
out('version: 1');
|
||||||
|
out('---');
|
||||||
|
out();
|
||||||
|
out('<!--');
|
||||||
|
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
|
||||||
|
out(' differs from what the generator produces, and an edit here is lost on the next run.');
|
||||||
|
out();
|
||||||
|
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
|
||||||
|
out('-->');
|
||||||
|
out();
|
||||||
|
out('# What Officer is');
|
||||||
|
out();
|
||||||
|
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
|
||||||
|
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
|
||||||
|
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
|
||||||
|
out('user, and **absence denies** — there is no row meaning "no".');
|
||||||
|
out();
|
||||||
|
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
|
||||||
|
out('no, and *not shareable at all*.');
|
||||||
|
out();
|
||||||
|
out('# Permissions');
|
||||||
|
out();
|
||||||
|
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
|
||||||
|
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
|
||||||
|
out('diligence.');
|
||||||
|
out();
|
||||||
|
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
|
||||||
|
const entries = byKind.get(kind) ?? [];
|
||||||
|
if (!entries.length) continue;
|
||||||
|
out(`## \`${kind}\` — ${meaning}`);
|
||||||
|
out();
|
||||||
|
for (const p of entries) {
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
|
||||||
|
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
|
||||||
|
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
|
||||||
|
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
|
||||||
|
if (bits.length) out(` - ${bits.join(' · ')}`);
|
||||||
|
if (p.selfService?.length) {
|
||||||
|
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
|
||||||
|
}
|
||||||
|
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
|
||||||
|
if (p.readOnlyWrites?.length) {
|
||||||
|
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out();
|
||||||
|
}
|
||||||
|
|
||||||
|
out('# Keyboard shortcuts');
|
||||||
|
out();
|
||||||
|
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||||
|
out('list rather than remembering one.');
|
||||||
|
out();
|
||||||
|
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
|
||||||
|
// English, so it resolves against en-GB rather than the reader's locale.
|
||||||
|
for (const section of SHORTCUT_SECTIONS) {
|
||||||
|
out(`## ${enString(section.titleKey)}`);
|
||||||
|
if (section.noteKey) {
|
||||||
|
out();
|
||||||
|
out(enString(section.noteKey));
|
||||||
|
}
|
||||||
|
out();
|
||||||
|
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
|
||||||
|
out();
|
||||||
|
}
|
||||||
|
|
||||||
|
out('# What is NOT in here');
|
||||||
|
out();
|
||||||
|
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
|
||||||
|
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
|
||||||
|
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
|
||||||
|
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
|
||||||
|
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
|
||||||
|
out(' commander is, how one is started, and how to help somebody shape one.');
|
||||||
|
out();
|
||||||
|
out('# When you are about to guess');
|
||||||
|
out();
|
||||||
|
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
|
||||||
|
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
|
||||||
|
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
|
||||||
|
out('certain as one quoting a true one.');
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const body = await renderPlatformSkill();
|
||||||
|
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
|
||||||
|
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
|
||||||
|
if (existing === body) {
|
||||||
|
console.log('skills:platform — already current');
|
||||||
|
} else {
|
||||||
|
writeFileSync(SKILL_PATH, body);
|
||||||
|
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+292
@@ -0,0 +1,292 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Counts candidate hardcoded user-facing strings in the web UI.
|
||||||
|
*
|
||||||
|
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
|
||||||
|
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
|
||||||
|
*
|
||||||
|
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
|
||||||
|
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
|
||||||
|
* progress; a zero means "nothing this scanner can see", not "nothing left".
|
||||||
|
*
|
||||||
|
* bun scripts/i18n/scan-hardcoded.ts # summary by area
|
||||||
|
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
|
||||||
|
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
|
||||||
|
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const AREAS = [
|
||||||
|
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
|
||||||
|
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
|
||||||
|
{ label: 'shared components', root: 'src/workspaces/components' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
|
||||||
|
* zero — enabling every area on day one would just be red for weeks and get ignored.
|
||||||
|
*
|
||||||
|
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
|
||||||
|
* that must NEVER be translated; they are reported but excluded from the check, which is why the
|
||||||
|
* neverTranslate split below has to exist for this entry to be possible at all.
|
||||||
|
*
|
||||||
|
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
|
||||||
|
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
|
||||||
|
* `stripUntranslated` skips for the same reason.
|
||||||
|
*
|
||||||
|
* 'officerdev (panels)' landed 2026-08-23 (phase 3). It holds nine `new Error()` messages for whoever is
|
||||||
|
* reading a terminal, not a user — same never-translate shape as 'shared components'. Two more strings
|
||||||
|
* are marked `i18n-exempt` rather than never-translate: `useTaskRunner.ts`'s "Running:"/"Error:" framing
|
||||||
|
* is interleaved with raw stdout/stderr inside one monospace `<pre>` feed, so translating the frame alone
|
||||||
|
* would read as broken mid-buffer — the same reasoning as the never-translate list, reached by a
|
||||||
|
* different mechanism because it's not a thrown Error.
|
||||||
|
*/
|
||||||
|
const ZEROED: string[] = ['shared components', 'officer-web (shell)', 'officerdev (panels)'];
|
||||||
|
|
||||||
|
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||||
|
|
||||||
|
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
|
||||||
|
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
|
||||||
|
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
|
||||||
|
*
|
||||||
|
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
|
||||||
|
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
|
||||||
|
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
|
||||||
|
*/
|
||||||
|
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||||
|
// >Some visible words<
|
||||||
|
//
|
||||||
|
// The lookbehind rejects `=>`, so an arrow return type does not read as JSX text: `(id: string) =>
|
||||||
|
// Promise<unknown>` was reported as the string "Promise".
|
||||||
|
{ kind: 'jsx text', rx: /(?<!=)>\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||||
|
// placeholder="..." and friends
|
||||||
|
{
|
||||||
|
kind: 'attributes',
|
||||||
|
rx: new RegExp(
|
||||||
|
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
|
||||||
|
'g',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
|
||||||
|
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
|
||||||
|
{
|
||||||
|
kind: 'object labels',
|
||||||
|
rx: new RegExp(
|
||||||
|
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
|
||||||
|
'g',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
||||||
|
//
|
||||||
|
// Matches a template literal as well as a quoted one: `new Error(\`Whisper returned ${res.status}\`)`
|
||||||
|
// was falling through to `template str` and so counted as convertible, which is the opposite of true.
|
||||||
|
{
|
||||||
|
kind: 'thrown errors',
|
||||||
|
rx: new RegExp(String.raw`new Error\(\s*(?:` + Q + String.raw`|\`([^\`]{2,120})\`)`, 'g'),
|
||||||
|
neverTranslate: true,
|
||||||
|
},
|
||||||
|
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
||||||
|
//
|
||||||
|
// The lookbehind hands a thrown Error to the category above instead of counting it here as well: a
|
||||||
|
// string matched by two patterns is counted twice, and it stayed convertible, so the area could never
|
||||||
|
// reach zero however it was treated.
|
||||||
|
{ kind: 'template str', rx: /(?<!new Error\()`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
|
||||||
|
function isHuman(value: string | undefined): value is string {
|
||||||
|
if (!value) return false;
|
||||||
|
const s = value.trim();
|
||||||
|
if (s.length < 3) return false;
|
||||||
|
if (!/[A-Za-z]{3}/.test(s)) return false;
|
||||||
|
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
|
||||||
|
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
||||||
|
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
|
||||||
|
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
|
||||||
|
// A namespaced storage or channel key: an ALL-CAPS prefix, then a separator, then an interpolation —
|
||||||
|
// `FULLSCREEN_PANEL:${workspace.key}`. The SCREAMING_CONSTANT rule above misses it because the
|
||||||
|
// interpolated half is lowercase.
|
||||||
|
if (/^[A-Z0-9_]{3,}[.:/-]/.test(s)) return false;
|
||||||
|
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
|
||||||
|
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
|
||||||
|
// followed by a credential is never something a person reads.
|
||||||
|
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
|
||||||
|
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
|
||||||
|
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
|
||||||
|
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
|
||||||
|
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
|
||||||
|
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
|
||||||
|
// `description: 'group-[.toast]:text-muted-foreground'`.
|
||||||
|
//
|
||||||
|
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
|
||||||
|
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
|
||||||
|
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
|
||||||
|
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
|
||||||
|
const tokens = s.split(/\s+/);
|
||||||
|
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
|
||||||
|
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
|
||||||
|
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
|
||||||
|
*
|
||||||
|
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
|
||||||
|
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
|
||||||
|
*/
|
||||||
|
const stripComments = (source: string): string =>
|
||||||
|
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
|
||||||
|
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
|
||||||
|
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
|
||||||
|
* thing it documents.
|
||||||
|
*/
|
||||||
|
const stripCodeElements = (source: string): string =>
|
||||||
|
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An explicit, per-line opt-out: `// i18n-exempt: <reason>` exempts the NEXT line.
|
||||||
|
*
|
||||||
|
* For the cases no syntactic rule can reach — chiefly a prompt written for a model rather than a
|
||||||
|
* reader. `<Untranslated>` covers JSX; this covers the rest, and the two together mean the scanner does
|
||||||
|
* not need another heuristic every time a new shape of never-translate string turns up.
|
||||||
|
*
|
||||||
|
* A reason is mandatory: the regex will not match a bare `// i18n-exempt`. That is the whole safeguard —
|
||||||
|
* somebody has to write down why, and `grep -rn "i18n-exempt"` lists every one for review.
|
||||||
|
*/
|
||||||
|
const stripExemptLines = (source: string): string => {
|
||||||
|
const lines = source.split('\n');
|
||||||
|
return lines.map((line, i) => (/\/\/\s*i18n-exempt:\s*\S/.test(lines[i - 1] ?? '') ? '' : line)).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
|
||||||
|
* another product's UI, which docs/localization.md classifies as an identifier.
|
||||||
|
*
|
||||||
|
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
|
||||||
|
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
|
||||||
|
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
|
||||||
|
* it and say why.
|
||||||
|
*/
|
||||||
|
const stripUntranslated = (source: string): string =>
|
||||||
|
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
|
||||||
|
|
||||||
|
function* walk(dir: string): Generator<string> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const path = join(dir, entry);
|
||||||
|
if (SKIP.test(path)) continue;
|
||||||
|
let isDir = false;
|
||||||
|
try {
|
||||||
|
isDir = statSync(path).isDirectory();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isDir) yield* walk(path);
|
||||||
|
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hit = { kind: string; value: string; file: string };
|
||||||
|
|
||||||
|
function scan(root: string): { files: number; hits: Hit[] } {
|
||||||
|
const hits: Hit[] = [];
|
||||||
|
let files = 0;
|
||||||
|
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
files++;
|
||||||
|
let source: string;
|
||||||
|
try {
|
||||||
|
// Exempt lines go FIRST: stripComments would remove the marker they depend on.
|
||||||
|
source = stripUntranslated(stripCodeElements(stripComments(stripExemptLines(readFileSync(file, 'utf8')))));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const { kind, rx } of PATTERNS) {
|
||||||
|
rx.lastIndex = 0;
|
||||||
|
for (const match of source.matchAll(rx)) {
|
||||||
|
const value = match.slice(1).find((group) => group !== undefined);
|
||||||
|
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, hits };
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
|
||||||
|
|
||||||
|
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
|
||||||
|
const kinds = PATTERNS.map((p) => p.kind);
|
||||||
|
const pad = (s: string | number, n: number) => String(s).padStart(n);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
|
||||||
|
);
|
||||||
|
for (const r of results) {
|
||||||
|
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
|
||||||
|
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = results.flatMap((r) => r.hits);
|
||||||
|
const distinct = new Set(all.map((h) => h.value));
|
||||||
|
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
|
||||||
|
|
||||||
|
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
|
||||||
|
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${'TOTAL'.padEnd(22)} ${pad(
|
||||||
|
results.reduce((n, r) => n + r.files, 0),
|
||||||
|
5,
|
||||||
|
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
|
||||||
|
);
|
||||||
|
console.log();
|
||||||
|
console.log(`occurrences ${all.length}`);
|
||||||
|
console.log(` convertible ${convertible(all).length}`);
|
||||||
|
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
|
||||||
|
console.log(`distinct strings ${distinct.size}`);
|
||||||
|
console.log(` needing params ${interpolated.size}`);
|
||||||
|
|
||||||
|
if (args.has('--files')) {
|
||||||
|
console.log('\nworst files:');
|
||||||
|
const perFile = new Map<string, number>();
|
||||||
|
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
|
||||||
|
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
|
||||||
|
console.log(` ${pad(n, 4)} ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listPrefix) {
|
||||||
|
console.log(`\nstrings in files matching "${listPrefix}":`);
|
||||||
|
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.has('--check')) {
|
||||||
|
const regressed = results
|
||||||
|
.filter((r) => ZEROED.includes(r.label))
|
||||||
|
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
|
||||||
|
.filter((r) => r.hits.length > 0);
|
||||||
|
if (regressed.length > 0) {
|
||||||
|
console.error('\nThese areas were converted and have regressed:');
|
||||||
|
for (const r of regressed) {
|
||||||
|
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
||||||
|
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
|
||||||
|
}
|
||||||
|
console.error('\nUse a catalog key. See docs/localization.md.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
|
||||||
|
}
|
||||||
+42
-5
@@ -6,13 +6,25 @@
|
|||||||
* bun run notify:test --user 2 # someone else
|
* bun run notify:test --user 2 # someone else
|
||||||
* bun run notify:test --code officer/core:job --id 412
|
* bun run notify:test --code officer/core:job --id 412
|
||||||
*
|
*
|
||||||
* Talks to the sidecar over loopback, which is how every other producer reaches it — so this exercises
|
* Goes through the PLATFORM, with a key it mints and revokes for the one send — the same door an app or
|
||||||
* the real path rather than a special one. It reports what the channels actually did, because the thing
|
* a commander uses, so this exercises the real path rather than a special one. It reports what the
|
||||||
* that wastes a day here is that a send with nothing configured returns success and delivers nothing.
|
* channels actually did, because the thing that wastes a day here is that a send with nothing configured
|
||||||
|
* returns success and delivers nothing.
|
||||||
|
*
|
||||||
|
* It used to POST to the sidecar directly on loopback. That stopped working on 2026-08-25, when the
|
||||||
|
* sidecar began requiring proof that the platform is the caller — anything on the machine could otherwise
|
||||||
|
* notify any user, which on a box running agents is anything with a shell. The secret lives only in the
|
||||||
|
* server's memory, so a CLI cannot present it and should not be able to.
|
||||||
|
*
|
||||||
|
* Minting a key is legitimate HERE and nowhere else: this runs as the owner, on the owner's machine, with
|
||||||
|
* the database open in front of it. Anything that cannot already do that has no business sending either.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from '../src/servers/data-path';
|
import { DATA_PATH } from '../src/servers/data-path';
|
||||||
|
import { mintApiKey } from '../src/servers/auth-token';
|
||||||
|
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||||
|
import { PORT, OFFICER_API_URL } from '../src/servers/officer-url.mjs';
|
||||||
|
|
||||||
const arg = (name: string): string | undefined => {
|
const arg = (name: string): string | undefined => {
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
@@ -69,12 +81,37 @@ const body = {
|
|||||||
|
|
||||||
console.log(`[notify:test] sending ${JSON.stringify(body)}`);
|
console.log(`[notify:test] sending ${JSON.stringify(body)}`);
|
||||||
|
|
||||||
const res = await fetch(`${base}/_officer/notify`, {
|
// A key for this one send, revoked immediately afterwards whatever happens. Named so a leftover from a
|
||||||
|
// killed run is obvious in the key list rather than looking like something a person made.
|
||||||
|
const KEY_NAME = 'notify:test (temporary)';
|
||||||
|
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
|
||||||
|
const { secret, keyHash, prefix } = mintApiKey();
|
||||||
|
await createApiKey({ userId, name: KEY_NAME, keyHash, prefix });
|
||||||
|
|
||||||
|
const publicHost = (() => {
|
||||||
|
try {
|
||||||
|
return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${OFFICER_API_URL}/api/notify/_officer/notify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
Authorization: `Bearer ${secret}`,
|
||||||
|
// Origin validation matches Host against PUBLIC_URL, so a bare loopback request is refused.
|
||||||
|
Host: publicHost ?? `127.0.0.1:${PORT}`,
|
||||||
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
|
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mapPalette } from './themes/map';
|
||||||
|
import type { Palette } from './themes/palette';
|
||||||
|
import { paletteFromRecord, parseTomlText } from './themes/palette';
|
||||||
|
import { deriveOmarchyPalette } from './themes/from-ansi';
|
||||||
|
import { parseAlacrittyToml, parseColor16Toml, parseGhosttyConf } from './themes/ansi-source';
|
||||||
|
import { contrast, ensureContrast, TEXT, UI } from './themes/contrast';
|
||||||
|
|
||||||
|
// Port community Omarchy themes from omarchythemes.com into the same 84-token shape the official
|
||||||
|
// pipeline produces — see `generate-themes.ts` for that one. This is its sibling for themes that have no
|
||||||
|
// `colors.toml`: a single-theme repo instead ships per-app configs, so `from-ansi.ts` reconstructs the
|
||||||
|
// same `Palette` from `alacritty.toml` or `ghostty.conf`, and everything downstream (mapping, contrast
|
||||||
|
// checking) is identical. What's NOT identical: these are unofficial, one author's repo can vanish or
|
||||||
|
// change, and the reconstruction is a heuristic, not Omarchy's own curated palette — every generated file
|
||||||
|
// says so in its header.
|
||||||
|
//
|
||||||
|
// bun scripts/port-community-themes.ts [--limit=N] [--slug=<one-slug>] [--redo]
|
||||||
|
//
|
||||||
|
// State lives in `scripts/themes/community-manifest.json` — one entry per theme successfully ported, so a
|
||||||
|
// re-run only fetches what's missing (or everything, with --redo) and the registry/index blocks are
|
||||||
|
// always rewritten fresh from the whole manifest, never hand-edited.
|
||||||
|
|
||||||
|
const CSS_THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
|
||||||
|
const INDEX_CSS = join(CSS_THEME_DIR, '..', 'index.css');
|
||||||
|
const REGISTRY = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
|
||||||
|
const MANIFEST_PATH = join(import.meta.dir, 'themes', 'community-manifest.json');
|
||||||
|
const SITE = 'https://omarchythemes.com';
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
|
||||||
|
const REDO = process.argv.includes('--redo');
|
||||||
|
const ONLY_SLUG = process.argv.find((a) => a.startsWith('--slug='))?.split('=')[1];
|
||||||
|
const LIMIT = Number(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] ?? Infinity);
|
||||||
|
|
||||||
|
type ManifestEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
repo: string;
|
||||||
|
/** Omitted means GitHub, which is where all but a couple of these live. */
|
||||||
|
host?: 'gitlab';
|
||||||
|
kind: string;
|
||||||
|
dark: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Repo = { host: 'github' | 'gitlab'; path: string };
|
||||||
|
|
||||||
|
const repoUrl = (r: Repo): string => `https://${r.host === 'gitlab' ? 'gitlab.com' : 'github.com'}/${r.path}`;
|
||||||
|
|
||||||
|
// Same pairs `generate-themes.ts` checks, kept in sync by hand since this is the only other place that
|
||||||
|
// needs them and duplicating a 20-line array beats a shared module for one caller each.
|
||||||
|
const PAIRS: [string, string, number][] = [
|
||||||
|
['--foreground', '--background', TEXT],
|
||||||
|
['--card-foreground', '--card', TEXT],
|
||||||
|
['--popover-foreground', '--popover', TEXT],
|
||||||
|
['--muted-foreground', '--muted', TEXT],
|
||||||
|
['--accent-foreground', '--accent', TEXT],
|
||||||
|
['--primary-foreground', '--primary', TEXT],
|
||||||
|
['--secondary-foreground', '--secondary', TEXT],
|
||||||
|
['--cta-foreground', '--cta', TEXT],
|
||||||
|
['--success-foreground', '--success', TEXT],
|
||||||
|
['--warning-foreground', '--warning', TEXT],
|
||||||
|
['--destructive-foreground', '--destructive', TEXT],
|
||||||
|
['--info-foreground', '--info', TEXT],
|
||||||
|
['--busy-foreground', '--busy', TEXT],
|
||||||
|
['--busy-ink', '--background', TEXT],
|
||||||
|
['--input', '--background', 1.35],
|
||||||
|
['--ring', '--background', UI],
|
||||||
|
];
|
||||||
|
|
||||||
|
const title = (id: string): string =>
|
||||||
|
id
|
||||||
|
.split('-')
|
||||||
|
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
function loadManifest(): ManifestEntry[] {
|
||||||
|
if (!existsSync(MANIFEST_PATH)) return [];
|
||||||
|
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveManifest(entries: ManifestEntry[]): void {
|
||||||
|
writeFileSync(MANIFEST_PATH, `${JSON.stringify(entries, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghApiDefaultBranch(repo: string): string {
|
||||||
|
try {
|
||||||
|
const out = execSync(`gh api repos/${repo} --jq .default_branch`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
return out.trim() || 'main';
|
||||||
|
} catch {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitlabDefaultBranch(path: string): Promise<string> {
|
||||||
|
const json = await fetchText(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`);
|
||||||
|
if (!json) return 'main';
|
||||||
|
try {
|
||||||
|
return (JSON.parse(json).default_branch as string) || 'main';
|
||||||
|
} catch {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
return await res.text();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverRepo(slug: string): Promise<Repo | undefined> {
|
||||||
|
const html = await fetchText(`${SITE}/themes/${slug}`);
|
||||||
|
if (!html) return undefined;
|
||||||
|
const gh = html.match(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/);
|
||||||
|
if (gh) return { host: 'github', path: `${gh[1]}/${gh[2]}` };
|
||||||
|
// A couple of authors host on GitLab instead. Its group paths can nest, hence the greedier match.
|
||||||
|
const gl = html.match(/https:\/\/gitlab\.com\/([\w.-]+(?:\/[\w.-]+)+?)(?=["'\s<]|$)/);
|
||||||
|
if (gl) return { host: 'gitlab', path: gl[1]!.replace(/\.git$/, '') };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceFor(
|
||||||
|
repo: Repo,
|
||||||
|
slug: string,
|
||||||
|
): Promise<{ palette: Palette; kind: string; derived: boolean } | undefined> {
|
||||||
|
const branch = repo.host === 'gitlab' ? await gitlabDefaultBranch(repo.path) : ghApiDefaultBranch(repo.path);
|
||||||
|
const raw = (p: string) =>
|
||||||
|
repo.host === 'gitlab'
|
||||||
|
? `https://gitlab.com/${repo.path}/-/raw/${branch}/${p}`
|
||||||
|
: `https://raw.githubusercontent.com/${repo.path}/${branch}/${p}`;
|
||||||
|
|
||||||
|
// Most repos hold one theme at the root. A few are packs that nest one directory per theme, so also
|
||||||
|
// look under the directory named after THIS slug — an exact match is the only safe pick, since a pack
|
||||||
|
// with variant names of its own (`abyss-dark`, `lagoon-light`, …) has no one theme that is "the" one.
|
||||||
|
const prefixes = ['', `themes/${slug}/`, `omarchy/themes/${slug}/`, `${slug}/`];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
// `colors.toml` is ambiguous across community repos: it might genuinely be Omarchy 4's own semantic
|
||||||
|
// schema (in which case no derivation is needed at all — same quality as the official 22), or it might
|
||||||
|
// reuse the filename for an unrelated flat `color0..color15` ANSI scheme. Try the real schema first;
|
||||||
|
// only fall through to the ANSI reading if the required keys aren't there.
|
||||||
|
const tomlText = await fetchText(raw(`${prefix}colors.toml`));
|
||||||
|
if (tomlText) {
|
||||||
|
const kv = parseTomlText(tomlText);
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
palette: paletteFromRecord(kv, `${repo.path}/${prefix}colors.toml`),
|
||||||
|
kind: 'colors.toml (Omarchy schema)',
|
||||||
|
derived: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const ansi = parseColor16Toml(kv);
|
||||||
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'colors.toml (16-colour ANSI)', derived: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'alacritty.toml',
|
||||||
|
'.config/alacritty.toml',
|
||||||
|
'alacritty/alacritty.toml',
|
||||||
|
'config/alacritty.toml',
|
||||||
|
]) {
|
||||||
|
const text = await fetchText(raw(`${prefix}${path}`));
|
||||||
|
if (!text) continue;
|
||||||
|
const ansi = parseAlacrittyToml(text);
|
||||||
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'alacritty.toml', derived: true };
|
||||||
|
}
|
||||||
|
for (const path of ['ghostty.conf', 'ghostty-theme', '.config/ghostty/config']) {
|
||||||
|
const text = await fetchText(raw(`${prefix}${path}`));
|
||||||
|
if (!text) continue;
|
||||||
|
const ansi = parseGhosttyConf(text);
|
||||||
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'ghostty.conf', derived: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function portOne(slug: string): Promise<{ entry: ManifestEntry; css: string } | { skip: string }> {
|
||||||
|
const repo = await discoverRepo(slug);
|
||||||
|
if (!repo) return { skip: 'no github/gitlab repo link found on its omarchythemes.com page' };
|
||||||
|
|
||||||
|
const source = await sourceFor(repo, slug);
|
||||||
|
if (!source) return { skip: `no parseable colors.toml/alacritty.toml/ghostty.conf in ${repo.path}` };
|
||||||
|
|
||||||
|
const palette = source.palette;
|
||||||
|
const tokens = mapPalette(palette);
|
||||||
|
|
||||||
|
// `--input` carries a real accessibility requirement (a field boundary you can't see) where `--border`
|
||||||
|
// is decoration and isn't held to one — see the README. `map.ts` derives both from the same mix for
|
||||||
|
// every theme, official ones included, and it normally clears 1.35:1 on its own; a few derived palettes
|
||||||
|
// land just under it, so nudge only this one rather than loosening the check.
|
||||||
|
tokens['--input'] = ensureContrast(tokens['--input']!, tokens['--background']!, 1.35);
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const [fgKey, bgKey, min] of PAIRS) {
|
||||||
|
const fg = tokens[fgKey]!;
|
||||||
|
const bg = tokens[bgKey]!;
|
||||||
|
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
|
||||||
|
const ratio = contrast(fg, bg);
|
||||||
|
if (ratio < min) failures.push(`${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
|
||||||
|
}
|
||||||
|
if (failures.length > 0) return { skip: `contrast failed after derivation: ${failures.join('; ')}` };
|
||||||
|
|
||||||
|
const name = title(slug);
|
||||||
|
const body = Object.entries(tokens)
|
||||||
|
.map(([k, v]) => ` ${k}: ${v};`)
|
||||||
|
.join('\n');
|
||||||
|
const modeLine = source.kind.startsWith('colors.toml (Omarchy')
|
||||||
|
? `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}) — the source repo's own colors.toml says so directly.`
|
||||||
|
: `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}), by luminance of its own background — the source repo carries no mode flag of its own to read.`;
|
||||||
|
const provenanceLine = source.derived
|
||||||
|
? `went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4\n colors.toml — some tokens here are reconstructed, not authored.`
|
||||||
|
: `came straight from the repo's own colors.toml — the same schema and reader the official 22 themes\n use, so nothing here is reconstructed.`;
|
||||||
|
const css = `/*
|
||||||
|
${name} — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
${modeLine}
|
||||||
|
|
||||||
|
Source: ${repoUrl(repo)} (${source.kind}), read by scripts/port-community-themes.ts. Values
|
||||||
|
${provenanceLine} Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='${slug}'] {
|
||||||
|
${body}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry: {
|
||||||
|
id: slug,
|
||||||
|
name,
|
||||||
|
repo: repo.path,
|
||||||
|
...(repo.host === 'gitlab' ? { host: 'gitlab' as const } : {}),
|
||||||
|
kind: source.kind,
|
||||||
|
dark: palette.mode === 'dark',
|
||||||
|
},
|
||||||
|
css,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteIndexCss(entries: ManifestEntry[]): void {
|
||||||
|
const index = readFileSync(INDEX_CSS, 'utf8');
|
||||||
|
const marker = '/* Community themes';
|
||||||
|
const startIdx = index.indexOf(marker);
|
||||||
|
const head = startIdx >= 0 ? index.slice(0, startIdx) : index.replace(/\n@import '\.\/chrome\.css';\n$/, '\n');
|
||||||
|
const imports = entries.map((e) => `@import './themes/${e.id}.css';`).join('\n');
|
||||||
|
writeFileSync(
|
||||||
|
INDEX_CSS,
|
||||||
|
`${head}/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRegistry(entries: ManifestEntry[]): void {
|
||||||
|
const registry = readFileSync(REGISTRY, 'utf8');
|
||||||
|
const START = ' /* COMMUNITY THEMES — start */';
|
||||||
|
const END = ' /* COMMUNITY THEMES — end */';
|
||||||
|
const rows = entries
|
||||||
|
.map((e) => ` { id: '${e.id}', name: '${e.name}', family: '${e.name}', applyDarkClass: ${e.dark} },`)
|
||||||
|
.join('\n');
|
||||||
|
const block = `${START}\n${rows}\n${END}`;
|
||||||
|
|
||||||
|
if (registry.includes(START) && registry.includes(END)) {
|
||||||
|
// Snap to the start of the marker's own line — `indexOf` on a string with leading spaces can match
|
||||||
|
// partway into stray indentation left by an earlier write, which would otherwise accumulate.
|
||||||
|
const startLineBegin = registry.lastIndexOf('\n', registry.indexOf(START)) + 1;
|
||||||
|
const before = registry.slice(0, startLineBegin);
|
||||||
|
const after = registry.slice(registry.indexOf(END) + END.length);
|
||||||
|
writeFileSync(REGISTRY, `${before}${block}${after}`);
|
||||||
|
} else {
|
||||||
|
const anchor = '] as const;';
|
||||||
|
const idx = registry.indexOf(anchor);
|
||||||
|
writeFileSync(REGISTRY, `${registry.slice(0, idx)}${block}\n${registry.slice(idx)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slugsFile = process.argv.find((a) => a.startsWith('--slugs='))?.split('=')[1];
|
||||||
|
const allSlugs: string[] = slugsFile ? readFileSync(slugsFile, 'utf8').trim().split('\n').filter(Boolean) : [];
|
||||||
|
|
||||||
|
const manifest = loadManifest();
|
||||||
|
const known = new Set(manifest.map((e) => e.id));
|
||||||
|
// Never take a slug the OFFICIAL/hand-authored part of the registry already owns — that's everything
|
||||||
|
// before this script's own block, which it is free to redo.
|
||||||
|
const registryHead = readFileSync(REGISTRY, 'utf8').split('/* COMMUNITY THEMES — start */')[0]!;
|
||||||
|
const registryIds = new Set(Array.from(registryHead.matchAll(/id: '([\w-]+)'/g)).map((m) => m[1]!));
|
||||||
|
|
||||||
|
let todo = ONLY_SLUG ? [ONLY_SLUG] : allSlugs.filter((s) => !registryIds.has(s) && (REDO || !known.has(s)));
|
||||||
|
todo = todo.slice(0, LIMIT);
|
||||||
|
|
||||||
|
console.log(`community theme port: ${todo.length} to try, ${manifest.length} already in the manifest`);
|
||||||
|
|
||||||
|
const results: ManifestEntry[] = [...manifest.filter((e) => !todo.includes(e.id))];
|
||||||
|
let done = 0;
|
||||||
|
let ok = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (todo.length > 0) {
|
||||||
|
const slug = todo.shift();
|
||||||
|
if (!slug) break;
|
||||||
|
try {
|
||||||
|
const result = await portOne(slug);
|
||||||
|
done++;
|
||||||
|
if ('skip' in result) {
|
||||||
|
skipped++;
|
||||||
|
console.log(`[${done}] SKIP ${slug} — ${result.skip}`);
|
||||||
|
} else {
|
||||||
|
writeFileSync(join(CSS_THEME_DIR, `${slug}.css`), result.css);
|
||||||
|
results.push(result.entry);
|
||||||
|
ok++;
|
||||||
|
console.log(`[${done}] OK ${slug} — from ${result.entry.repo} (${result.entry.kind})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
done++;
|
||||||
|
skipped++;
|
||||||
|
console.log(`[${done}] ERROR ${slug} — ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
||||||
|
|
||||||
|
results.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
saveManifest(results);
|
||||||
|
rewriteIndexCss(results);
|
||||||
|
rewriteRegistry(results);
|
||||||
|
|
||||||
|
console.log(`\ndone: ${ok} ported, ${skipped} skipped, ${results.length} total in manifest`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Seed the `roles` table with the roles the platform's own code names.
|
||||||
|
*
|
||||||
|
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
|
||||||
|
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
|
||||||
|
* mechanism `backfill-session-models.ts` relies on.
|
||||||
|
*
|
||||||
|
* ── Why these four are `isSystem` ──
|
||||||
|
*
|
||||||
|
* Each is named by name somewhere in the code: this file matches `'Developer'`, `'Admin'` and `'Member'`
|
||||||
|
* by name on every run, and `bootstrap.ts` writes `'Super Admin'`. Renaming or deleting one of those is not
|
||||||
|
* a data change, it is a code change that has not happened — so the flag exists for the delete and rename
|
||||||
|
* paths to refuse on, rather than for the failure to turn up later as a missing database role or an
|
||||||
|
* account that cannot log in.
|
||||||
|
*
|
||||||
|
* ── `description` versus `grantsPostgres`/`grantsDocker` ──
|
||||||
|
*
|
||||||
|
* `seedRole` treats these two kinds of column differently, and it matters here specifically because this
|
||||||
|
* is the one caller that seeds `isSystem: true` rows. `description` is written once, on insert, and never
|
||||||
|
* touched again — an edit made on one server must not be reverted by the next deploy. `grantsPostgres` and
|
||||||
|
* `grantsDocker` ARE re-asserted every run for a system role, because they are not the operator's data,
|
||||||
|
* they are what "Developer" MEANS — the same standing as the name itself. `rolePermitsDevTools()` used to
|
||||||
|
* be that declaration, hardcoded as `role === 'Developer'`; this table is where it lives now, and this
|
||||||
|
* SEED array is the only place either flag is set for these four roles. See `seedRole`'s own comment in
|
||||||
|
* `officer_db/src/roles/queries.ts` for the mechanics.
|
||||||
|
*
|
||||||
|
* ── What this deliberately does NOT do ──
|
||||||
|
*
|
||||||
|
* Never overwrite `description`, as above. And never touch a CUSTOM role's grants — those are set through
|
||||||
|
* `PATCH /api/roles/:name/grants` (`setRoleGrants`), which itself refuses to touch a system role for the
|
||||||
|
* mirror-image reason: writing there would look like it worked and then silently revert on the next run of
|
||||||
|
* this script.
|
||||||
|
*/
|
||||||
|
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||||
|
|
||||||
|
type SeedRoleEntry = { name: string; description: string; grantsPostgres?: boolean; grantsDocker?: boolean };
|
||||||
|
|
||||||
|
// Match today's behaviour exactly: only Developer passed `rolePermitsDevTools` before this table existed,
|
||||||
|
// so Developer is the only role seeded with either flag true.
|
||||||
|
const SEED: SeedRoleEntry[] = [
|
||||||
|
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||||
|
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||||
|
{
|
||||||
|
name: 'Developer',
|
||||||
|
description: 'A member who is also trusted with developer tooling on the machine',
|
||||||
|
grantsPostgres: true,
|
||||||
|
grantsDocker: true,
|
||||||
|
},
|
||||||
|
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await waitForDatabase();
|
||||||
|
let created = 0;
|
||||||
|
for (const role of SEED) {
|
||||||
|
if (await seedRole({ ...role, isSystem: true })) created += 1;
|
||||||
|
}
|
||||||
|
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
|
||||||
|
// a successful push look like a failed one — the rows are additive and the next push tries again.
|
||||||
|
main()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
|
||||||
|
})
|
||||||
|
.finally(() => process.exit(0));
|
||||||
@@ -206,7 +206,7 @@ nvim_clone_config() {
|
|||||||
# every process here. officer-pty imports node-pty, a native addon with
|
# every process here. officer-pty imports node-pty, a native addon with
|
||||||
# no Linux prebuild — it compiles against the installed Node on every
|
# no Linux prebuild — it compiles against the installed Node on every
|
||||||
# machine. Either one alone makes Node load-bearing.
|
# machine. Either one alone makes Node load-bearing.
|
||||||
# bun the platform itself and nineteen of the twenty pm2 apps.
|
# bun the platform itself, and every pm2 app except officer-pty.
|
||||||
# pm2 the process manager the ecosystem files are written for.
|
# pm2 the process manager the ecosystem files are written for.
|
||||||
#
|
#
|
||||||
# Deno is not. Nothing in the platform imports it — checked across the whole
|
# Deno is not. Nothing in the platform imports it — checked across the whole
|
||||||
@@ -243,6 +243,69 @@ install_node() {
|
|||||||
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Held at the 1.3 series, deliberately and temporarily ──
|
||||||
|
#
|
||||||
|
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
|
||||||
|
# newest, so a machine provisioned two days later puts a brand-new release under
|
||||||
|
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
|
||||||
|
# for a while. That is the owner's call and it is meant to be revisited in a few
|
||||||
|
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
|
||||||
|
#
|
||||||
|
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
|
||||||
|
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
|
||||||
|
# exactly the way setup_22.x did for Node.
|
||||||
|
#
|
||||||
|
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
|
||||||
|
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
|
||||||
|
# offers latest-stable or --canary and nothing else — so it always jumps to the
|
||||||
|
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
|
||||||
|
# setup script can prevent that. `bun run doctor` is where that drift is caught.
|
||||||
|
BUN_SERIES="${BUN_SERIES:-1.3}"
|
||||||
|
|
||||||
|
# The newest stable release inside the series.
|
||||||
|
#
|
||||||
|
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
|
||||||
|
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
|
||||||
|
# IP, and it returned 504 twice while this was being written. An empty answer here
|
||||||
|
# means no bun gets installed at all (see install_bun), so where it comes from is
|
||||||
|
# load-bearing rather than a matter of taste. bun publishes every release to npm.
|
||||||
|
#
|
||||||
|
# `sort -V` rather than trusting the order the registry returns them in, and the
|
||||||
|
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
|
||||||
|
bun_series_latest() {
|
||||||
|
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
|
||||||
|
jq -r '.versions | keys[]' 2>/dev/null |
|
||||||
|
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
|
||||||
|
sort -V | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The major.minor of a full version — "1.3.14" is in series "1.3".
|
||||||
|
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
|
||||||
|
|
||||||
|
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
|
||||||
|
#
|
||||||
|
# Above and below are different problems and get different answers — above is a
|
||||||
|
# machine that has upgraded itself past the pin, below is one that is simply old —
|
||||||
|
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
|
||||||
|
# nothing here has to parse a version.
|
||||||
|
bun_series_position() {
|
||||||
|
local now
|
||||||
|
now="$(bun_series_of "${1:-}")"
|
||||||
|
[[ -n "$now" ]] || {
|
||||||
|
echo unknown
|
||||||
|
return
|
||||||
|
}
|
||||||
|
[[ "$now" == "$BUN_SERIES" ]] && {
|
||||||
|
echo in
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
|
||||||
|
echo above
|
||||||
|
else
|
||||||
|
echo below
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Present anywhere: on PATH for this root shell, or in the account's own
|
# Present anywhere: on PATH for this root shell, or in the account's own
|
||||||
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
||||||
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
||||||
@@ -260,8 +323,8 @@ bun_version() {
|
|||||||
# The system-wide link, ensured on every run rather than only after an install.
|
# The system-wide link, ensured on every run rather than only after an install.
|
||||||
#
|
#
|
||||||
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
||||||
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
|
# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
|
||||||
# then fails to start on reboot while working perfectly when started by hand. A
|
# reboot while working perfectly when started by hand. A
|
||||||
# machine that already had bun before this script ran would never get the link if
|
# machine that already had bun before this script ran would never get the link if
|
||||||
# it were only made as part of installing.
|
# it were only made as part of installing.
|
||||||
#
|
#
|
||||||
@@ -280,8 +343,22 @@ ensure_bun_symlink() {
|
|||||||
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
||||||
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
||||||
# started by hand, which is a miserable thing to debug.
|
# started by hand, which is a miserable thing to debug.
|
||||||
|
#
|
||||||
|
# The version goes in as the installer's first positional argument, which it
|
||||||
|
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
|
||||||
|
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
|
||||||
|
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
|
||||||
|
# rather than assuming the baseline the newest release was built for — so this both
|
||||||
|
# installs and pins back down.
|
||||||
|
#
|
||||||
|
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
|
||||||
|
# the registry, and installing whatever is newest is the single outcome the pin
|
||||||
|
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
|
||||||
|
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
|
||||||
install_bun() {
|
install_bun() {
|
||||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
|
local tag="${1:-}"
|
||||||
|
[[ -n "$tag" ]] || return 1
|
||||||
|
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
|
||||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ LAST_SKIPPED=()
|
|||||||
# distribution's default alongside it. It stays in core because anything that
|
# distribution's default alongside it. It stays in core because anything that
|
||||||
# compiles a native module needs it, but it is the one to move out first if that
|
# compiles a native module needs it, but it is the one to move out first if that
|
||||||
# ever bites.
|
# ever bites.
|
||||||
|
#
|
||||||
|
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
|
||||||
|
# moment a file lands instead of polling for it. Without it a watcher can only
|
||||||
|
# ask "is there anything new?" on a timer, which is both slower and more work —
|
||||||
|
# and the commander has no way to know the tool is missing until it tries. macOS
|
||||||
|
# has no equivalent in the package sets here; a commander there polls.
|
||||||
pkgs_core() {
|
pkgs_core() {
|
||||||
case "$PM" in
|
case "$PM" in
|
||||||
apt)
|
apt)
|
||||||
@@ -103,17 +109,17 @@ pkgs_core() {
|
|||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
apt-transport-https lsb-release software-properties-common \
|
apt-transport-https lsb-release software-properties-common \
|
||||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
poppler-utils radicale fail2ban unattended-upgrades
|
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
|
||||||
;;
|
;;
|
||||||
pacman)
|
pacman)
|
||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||||
poppler radicale fail2ban
|
inotify-tools poppler radicale fail2ban
|
||||||
;;
|
;;
|
||||||
dnf)
|
dnf)
|
||||||
echo curl ca-certificates gnupg2 git jq unzip \
|
echo curl ca-certificates gnupg2 git jq unzip \
|
||||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
poppler-utils radicale fail2ban
|
inotify-tools poppler-utils radicale fail2ban
|
||||||
;;
|
;;
|
||||||
brew)
|
brew)
|
||||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||||
|
|||||||
@@ -466,32 +466,73 @@ disable_sleep() {
|
|||||||
# systemd-networkd actually manages the network — a server or cloud image, via
|
# systemd-networkd actually manages the network — a server or cloud image, via
|
||||||
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
|
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
|
||||||
#
|
#
|
||||||
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
|
# Where NetworkManager owns the network instead, systemd-networkd configures
|
||||||
# but the wait-online unit is still enabled and waits for a link that will never
|
# nothing, but the wait-online unit is still enabled and waits for a link that
|
||||||
# be configured. It gives up after its full timeout, on every boot.
|
# will never come up. It gives up after its full timeout, on every boot.
|
||||||
#
|
#
|
||||||
# So the fix is masking one unit, and only on the second stack. Do NOT
|
# So the fix is masking one unit, and only on the second stack. Do NOT
|
||||||
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
|
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
|
||||||
# networkd-managed box that brings it up with no network on the next boot, no
|
# networkd-managed box that brings it up with no network on the next boot, no
|
||||||
# ssh, and nothing but the provider's rescue console.
|
# ssh, and nothing but the provider's rescue console.
|
||||||
|
#
|
||||||
|
# ── "is networkd running" is the wrong question ──
|
||||||
|
#
|
||||||
|
# It was the test here until 2026-08-24, and it is why this section did nothing
|
||||||
|
# on the machine it was written for. Both stacks can be ACTIVE at once: on the
|
||||||
|
# dev box NetworkManager owned enp1s0 and wlp2s0, systemd-networkd was up and
|
||||||
|
# managing not one of eighty links, and wait-online still burned its full two
|
||||||
|
# minutes on every boot — `Timeout occurred while waiting for network
|
||||||
|
# connectivity`, at 18:31:05 to 18:33:05, in the journal.
|
||||||
|
#
|
||||||
|
# What matters is whether networkd CONFIGURED anything, which `networkctl` will
|
||||||
|
# say. A link it owns reads `configured`; a link it is ignoring reads
|
||||||
|
# `unmanaged`. If every link is unmanaged, the wait cannot ever be satisfied and
|
||||||
|
# masking it is safe.
|
||||||
|
|
||||||
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
|
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
|
||||||
|
|
||||||
|
# Reported to the operator, so it has to describe the machine rather than pick a
|
||||||
|
# winner: both daemons running with networkd idle is the exact case this section
|
||||||
|
# used to get wrong, and printing "NetworkManager" for it hid that.
|
||||||
network_manager_name() {
|
network_manager_name() {
|
||||||
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
|
local nm=no nd=no
|
||||||
|
systemctl is-active --quiet NetworkManager.service 2>/dev/null && nm=yes
|
||||||
|
systemctl is-active --quiet systemd-networkd.service 2>/dev/null && nd=yes
|
||||||
|
|
||||||
|
if [[ $nm == yes && $nd == yes ]]; then
|
||||||
|
if networkd_configures_a_link; then
|
||||||
|
echo "both — networkd configures at least one link"
|
||||||
|
else
|
||||||
|
echo "NetworkManager (networkd is up but configures nothing)"
|
||||||
|
fi
|
||||||
|
elif [[ $nm == yes ]]; then
|
||||||
echo "NetworkManager"
|
echo "NetworkManager"
|
||||||
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
|
elif [[ $nd == yes ]]; then
|
||||||
echo "systemd-networkd"
|
echo "systemd-networkd"
|
||||||
else
|
else
|
||||||
echo "neither — unclear"
|
echo "neither — unclear"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
|
# Does networkd actually configure a link on this machine?
|
||||||
# Anything else, including "cannot tell", is left alone.
|
#
|
||||||
|
# `networkctl list --no-legend` puts the SETUP state last on each line. Anything
|
||||||
|
# other than unmanaged/pending/n-a means networkd owns that link and the wait is
|
||||||
|
# real work. Missing networkctl counts as "yes, it does" — an unanswerable
|
||||||
|
# question must not be read as permission to mask.
|
||||||
|
networkd_configures_a_link() {
|
||||||
|
command -v networkctl &>/dev/null || return 0
|
||||||
|
networkctl list --no-legend 2>/dev/null | awk '{ print $NF }' |
|
||||||
|
grep -qvE '^(unmanaged|pending|n/a)$'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Is the wait actually pointless here? NetworkManager in charge, and networkd
|
||||||
|
# either absent or configuring nothing. Anything else, including "cannot tell",
|
||||||
|
# is left alone.
|
||||||
wait_online_is_spurious() {
|
wait_online_is_spurious() {
|
||||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
|
systemctl is-active --quiet NetworkManager.service 2>/dev/null || return 1
|
||||||
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
|
systemctl is-active --quiet systemd-networkd.service 2>/dev/null || return 0
|
||||||
|
! networkd_configures_a_link
|
||||||
}
|
}
|
||||||
|
|
||||||
# What that unit actually cost on this boot, straight from systemd's own
|
# What that unit actually cost on this boot, straight from systemd's own
|
||||||
|
|||||||
@@ -1170,18 +1170,22 @@ elif ! skip; then
|
|||||||
echo ""
|
echo ""
|
||||||
info "Boot hang — a unit that can add two minutes to every boot"
|
info "Boot hang — a unit that can add two minutes to every boot"
|
||||||
echo " On a machine where NetworkManager owns the network, systemd-networkd"
|
echo " On a machine where NetworkManager owns the network, systemd-networkd"
|
||||||
echo " runs nothing — but systemd-networkd-wait-online is still enabled, and"
|
echo " configures nothing — but systemd-networkd-wait-online is still"
|
||||||
echo " waits for a link that will never come up. It gives up after its full"
|
echo " enabled, and waits for a link that will never come up. It gives up"
|
||||||
echo " timeout, every single boot."
|
echo " after its full timeout, every single boot."
|
||||||
|
echo ""
|
||||||
|
echo " Note both daemons can be running at once. What decides it is whether"
|
||||||
|
echo " networkd CONFIGURED a link, not whether it is up."
|
||||||
echo ""
|
echo ""
|
||||||
echo " network managed by: $(network_manager_name)"
|
echo " network managed by: $(network_manager_name)"
|
||||||
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
|
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
|
||||||
|
|
||||||
if ! wait_online_is_spurious; then
|
if ! wait_online_is_spurious; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " systemd-networkd is live here, so that unit is doing real work."
|
echo " systemd-networkd configures a link here, so that unit is doing"
|
||||||
|
echo " real work."
|
||||||
echo " Masking it would be how this machine comes up with no network."
|
echo " Masking it would be how this machine comes up with no network."
|
||||||
SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
|
SUMMARY+=("Boot hang: nothing to fix — networkd configures a link")
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
if is_role dev; then
|
if is_role dev; then
|
||||||
@@ -1981,14 +1985,18 @@ step "JavaScript runtimes"
|
|||||||
if ! skip; then
|
if ! skip; then
|
||||||
NODE_LTS="$(node_lts_major)"
|
NODE_LTS="$(node_lts_major)"
|
||||||
NODE_NOW="$(node_installed_major)"
|
NODE_NOW="$(node_installed_major)"
|
||||||
|
# Resolved before the banner so it can state the pin rather than only the version
|
||||||
|
# that happens to be here. One network call, same as node_lts_label above it.
|
||||||
|
BUN_WANT="$(bun_series_latest)"
|
||||||
|
BUN_NOW="$(bun_version)"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
info "JavaScript runtimes — required, so these are installed rather than offered"
|
info "JavaScript runtimes — required, so these are installed rather than offered"
|
||||||
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
|
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
|
||||||
echo " pm2 is a Node application, and officer-pty compiles node-pty"
|
echo " pm2 is a Node application, and officer-pty compiles node-pty"
|
||||||
echo " against whatever Node is installed — there is no Linux prebuild."
|
echo " against whatever Node is installed — there is no Linux prebuild."
|
||||||
echo " bun $(bun_installed && bun_version || echo 'not installed')"
|
echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}"
|
||||||
echo " the platform itself, and nineteen of the twenty pm2 apps."
|
echo " the platform itself, and every pm2 app except officer-pty."
|
||||||
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
|
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
|
||||||
echo " supervises all of them, and the ecosystem files are written for it."
|
echo " supervises all of them, and the ecosystem files are written for it."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -2014,18 +2022,93 @@ if ! skip; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── bun ──
|
# ── bun, held at the pinned series ──
|
||||||
|
#
|
||||||
|
# Four states, because "installed" is not one answer. A machine can be inside the
|
||||||
|
# pin, behind it, or past it — and past it is the case worth catching, since that
|
||||||
|
# is what a `bun upgrade` leaves behind and nothing else reports it.
|
||||||
|
if [[ -z "$BUN_WANT" ]]; then
|
||||||
|
# No pin resolved means no install: taking whatever bun.sh serves is the one
|
||||||
|
# thing the pin exists to stop.
|
||||||
|
warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x"
|
||||||
if bun_installed; then
|
if bun_installed; then
|
||||||
ok "bun $(bun_version) already installed"
|
echo " leaving bun ${BUN_NOW} where it is"
|
||||||
|
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone")
|
||||||
else
|
else
|
||||||
info " installing bun..."
|
echo " nothing installed — this will not install an unpinned bun, and"
|
||||||
if install_bun && bun_installed; then
|
echo " officer-setup stops on a missing bun rather than building on 1.4."
|
||||||
|
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed")
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif ! bun_installed; then
|
||||||
|
info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..."
|
||||||
|
if install_bun "bun-v${BUN_WANT}" && bun_installed; then
|
||||||
ok "bun $(bun_version)"
|
ok "bun $(bun_version)"
|
||||||
SUMMARY+=("Bun: $(bun_version)")
|
SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)")
|
||||||
else
|
else
|
||||||
warn "the bun install did not complete"
|
warn "the bun install did not complete"
|
||||||
ERRORS+=("Bun: install failed")
|
ERRORS+=("Bun: install failed")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
case "$(bun_series_position "$BUN_NOW")" in
|
||||||
|
in)
|
||||||
|
if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then
|
||||||
|
ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series"
|
||||||
|
else
|
||||||
|
info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out"
|
||||||
|
echo " A patch inside the series is what the pin is meant to let"
|
||||||
|
echo " through. Note that 'bun upgrade' would not stop there — it"
|
||||||
|
echo " takes the newest release, which is past the pin."
|
||||||
|
if confirm "Move to ${BUN_WANT}?"; then
|
||||||
|
if install_bun "bun-v${BUN_WANT}"; then
|
||||||
|
ok "bun $(bun_version)"
|
||||||
|
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
|
||||||
|
else
|
||||||
|
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||||
|
ERRORS+=("Bun: could not move to ${BUN_WANT}")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " left at ${BUN_NOW}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
above)
|
||||||
|
# The `bun upgrade` case. Reported rather than corrected silently: moving
|
||||||
|
# somebody's runtime backwards is a decision, and this is the only place
|
||||||
|
# that knows it happened.
|
||||||
|
warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series"
|
||||||
|
echo " Most likely a 'bun upgrade', which takes no version argument and"
|
||||||
|
echo " always jumps to the newest release. The platform and every pm2"
|
||||||
|
echo " app except officer-pty run on this."
|
||||||
|
if confirm "Pin it back to ${BUN_WANT}?"; then
|
||||||
|
if install_bun "bun-v${BUN_WANT}"; then
|
||||||
|
ok "bun $(bun_version) — back inside the pin"
|
||||||
|
SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)")
|
||||||
|
else
|
||||||
|
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||||
|
ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "left at ${BUN_NOW}, outside the pin"
|
||||||
|
SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice")
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series"
|
||||||
|
if confirm "Move to ${BUN_WANT}?"; then
|
||||||
|
if install_bun "bun-v${BUN_WANT}"; then
|
||||||
|
ok "bun $(bun_version)"
|
||||||
|
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
|
||||||
|
else
|
||||||
|
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||||
|
ERRORS+=("Bun: could not move to ${BUN_WANT}")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " left at ${BUN_NOW}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensured every run, not only after an install. A machine that already had bun
|
# Ensured every run, not only after an install. A machine that already had bun
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ if ! skip; then
|
|||||||
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
||||||
echo " install and nothing else, so nothing in git can drift from it."
|
echo " install and nothing else, so nothing in git can drift from it."
|
||||||
echo ""
|
echo ""
|
||||||
echo " six processes:"
|
echo " ${#CORE_PROCESSES[@]} processes:"
|
||||||
for entry in "${CORE_PROCESSES[@]}"; do
|
for entry in "${CORE_PROCESSES[@]}"; do
|
||||||
IFS='|' read -r _name _script _args <<<"$entry"
|
IFS='|' read -r _name _script _args <<<"$entry"
|
||||||
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
||||||
@@ -772,7 +772,7 @@ if ! skip; then
|
|||||||
if confirm "Write it and start them?"; then
|
if confirm "Write it and start them?"; then
|
||||||
write_ecosystem
|
write_ecosystem
|
||||||
ok "written — $(ecosystem_file)"
|
ok "written — $(ecosystem_file)"
|
||||||
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
report_changed "wrote $(ecosystem_file) — ${#CORE_PROCESSES[@]} pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||||
|
|
||||||
# Starting against a database that is not answering is not fatal — the server
|
# Starting against a database that is not answering is not fatal — the server
|
||||||
# waits and the agent retries forever — but it makes the Verify section below
|
# waits and the agent retries forever — but it makes the Verify section below
|
||||||
@@ -792,13 +792,13 @@ if ! skip; then
|
|||||||
if pm2_enable_startup; then
|
if pm2_enable_startup; then
|
||||||
ok "pm2 will resurrect them at boot"
|
ok "pm2 will resurrect them at boot"
|
||||||
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
||||||
SUMMARY+=("Services: 6 processes started, enabled at boot")
|
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
|
||||||
else
|
else
|
||||||
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
||||||
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
|
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
SUMMARY+=("Services: 6 processes started; not enabled at boot")
|
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; not enabled at boot")
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "pm2 did not start cleanly"
|
warn "pm2 did not start cleanly"
|
||||||
@@ -945,7 +945,7 @@ fi
|
|||||||
#
|
#
|
||||||
# Root, and that surprises people — reasonably, because everything this script just
|
# Root, and that surprises people — reasonably, because everything this script just
|
||||||
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
||||||
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
|
# node_modules, .env, the secret store and every pm2 process are theirs. Root was
|
||||||
# the installer's privilege, never the platform's.
|
# the installer's privilege, never the platform's.
|
||||||
#
|
#
|
||||||
# Saying so matters for two things that are invisible until they bite:
|
# Saying so matters for two things that are invisible until they bite:
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ tool_why() {
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
git) echo "to clone and update the platform" ;;
|
git) echo "to clone and update the platform" ;;
|
||||||
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
||||||
bun) echo "the platform itself and nineteen of the twenty processes" ;;
|
bun) echo "the platform itself and every process except the pty sidecar" ;;
|
||||||
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
||||||
docker) echo "Postgres, and anything the app store provisions" ;;
|
docker) echo "Postgres, and anything the app store provisions" ;;
|
||||||
*) echo "" ;;
|
*) echo "" ;;
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Reading a community theme's actual shipped format.
|
||||||
|
//
|
||||||
|
// Omarchy's own 22 themes carry a `colors.toml` in the semantic shape `palette.ts` expects — three
|
||||||
|
// background depths, four foreground weights, an accent, a selection, a muted. A community theme on
|
||||||
|
// omarchythemes.com ships none of that: it's per-app config files in the classic 16-ANSI-slot style,
|
||||||
|
// because that's what predates Omarchy 4's own format and what most single-theme repos still use.
|
||||||
|
//
|
||||||
|
// This file reads the two shapes that cover almost every repo: `alacritty.toml` (structured, typed,
|
||||||
|
// preferred) and `ghostty.conf`/`ghostty-theme` (flat `key = value` lines, fallback). Either becomes an
|
||||||
|
// `AnsiSource` — the 16 ANSI slots plus primary background/foreground — which `from-ansi.ts` turns into
|
||||||
|
// the same `Palette` shape the official pipeline consumes, so every downstream guarantee (contrast
|
||||||
|
// checking, the 84-token mapping) applies unchanged.
|
||||||
|
|
||||||
|
export type AnsiSource = {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
black: string;
|
||||||
|
red: string;
|
||||||
|
green: string;
|
||||||
|
yellow: string;
|
||||||
|
blue: string;
|
||||||
|
magenta: string;
|
||||||
|
cyan: string;
|
||||||
|
white: string;
|
||||||
|
brightBlack: string;
|
||||||
|
brightRed: string;
|
||||||
|
brightGreen: string;
|
||||||
|
brightYellow: string;
|
||||||
|
brightBlue: string;
|
||||||
|
brightMagenta: string;
|
||||||
|
brightCyan: string;
|
||||||
|
brightWhite: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `#rrggbb` is standard; `0xrrggbb` shows up in a handful of community alacritty.toml files (their
|
||||||
|
// authors apparently ported from a scheme that used C-style hex literals).
|
||||||
|
const HEX = /#[0-9a-fA-F]{3,8}|0x[0-9a-fA-F]{3,8}/;
|
||||||
|
|
||||||
|
function normalizeHex(v: string): string | undefined {
|
||||||
|
const m = v.match(HEX);
|
||||||
|
if (!m) return undefined;
|
||||||
|
let h = m[0].replace(/^(#|0x)/, '').toLowerCase();
|
||||||
|
if (h.length === 3)
|
||||||
|
h = h
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('');
|
||||||
|
if (h.length >= 6) h = h.slice(0, 6);
|
||||||
|
return `#${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliberately small TOML reader: flat `key = "value"` lines plus `[section]` headers. */
|
||||||
|
export function parseAlacrittyToml(text: string): AnsiSource | undefined {
|
||||||
|
let section = '';
|
||||||
|
const rows: Record<string, Record<string, string>> = {};
|
||||||
|
for (const rawLine of text.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
const sectionMatch = line.match(/^\[([\w.]+)]$/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
section = sectionMatch[1]!;
|
||||||
|
rows[section] ??= {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Quoted specifically, not "anything but a comment" — a hex value starts with `#`, the same
|
||||||
|
// character that opens a trailing comment, so the two can only be told apart by the quotes.
|
||||||
|
const kv = line.match(/^([\w-]+)\s*=\s*(['"])(.*?)\2/);
|
||||||
|
if (kv && section) {
|
||||||
|
const hex = normalizeHex(kv[3]!);
|
||||||
|
if (hex) rows[section]![kv[1]!] = hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = rows['colors.primary'];
|
||||||
|
const normal = rows['colors.normal'];
|
||||||
|
const bright = rows['colors.bright'];
|
||||||
|
if (!primary?.background || !primary?.foreground || !normal || !bright) return undefined;
|
||||||
|
|
||||||
|
const need = (r: Record<string, string>, k: string): string | undefined => r[k];
|
||||||
|
const n = (k: string) => need(normal, k);
|
||||||
|
const b = (k: string) => need(bright, k);
|
||||||
|
if (
|
||||||
|
!n('black') ||
|
||||||
|
!n('red') ||
|
||||||
|
!n('green') ||
|
||||||
|
!n('yellow') ||
|
||||||
|
!n('blue') ||
|
||||||
|
!n('magenta') ||
|
||||||
|
!n('cyan') ||
|
||||||
|
!n('white')
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
if (
|
||||||
|
!b('black') ||
|
||||||
|
!b('red') ||
|
||||||
|
!b('green') ||
|
||||||
|
!b('yellow') ||
|
||||||
|
!b('blue') ||
|
||||||
|
!b('magenta') ||
|
||||||
|
!b('cyan') ||
|
||||||
|
!b('white')
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: primary.background,
|
||||||
|
foreground: primary.foreground,
|
||||||
|
black: n('black')!,
|
||||||
|
red: n('red')!,
|
||||||
|
green: n('green')!,
|
||||||
|
yellow: n('yellow')!,
|
||||||
|
blue: n('blue')!,
|
||||||
|
magenta: n('magenta')!,
|
||||||
|
cyan: n('cyan')!,
|
||||||
|
white: n('white')!,
|
||||||
|
brightBlack: b('black')!,
|
||||||
|
brightRed: b('red')!,
|
||||||
|
brightGreen: b('green')!,
|
||||||
|
brightYellow: b('yellow')!,
|
||||||
|
brightBlue: b('blue')!,
|
||||||
|
brightMagenta: b('magenta')!,
|
||||||
|
brightCyan: b('cyan')!,
|
||||||
|
brightWhite: b('white')!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ghostty's config: flat `key = value`, ANSI palette as sixteen `palette = N=#hex` lines. */
|
||||||
|
export function parseGhosttyConf(text: string): AnsiSource | undefined {
|
||||||
|
let background: string | undefined;
|
||||||
|
let foreground: string | undefined;
|
||||||
|
const palette: Record<number, string> = {};
|
||||||
|
|
||||||
|
for (const rawLine of text.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (line.startsWith('#')) continue;
|
||||||
|
const bg = line.match(/^background\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||||
|
if (bg) background = normalizeHex(bg[1]!);
|
||||||
|
const fg = line.match(/^foreground\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||||
|
if (fg) foreground = normalizeHex(fg[1]!);
|
||||||
|
const pal = line.match(/^palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||||
|
if (pal) {
|
||||||
|
const hex = normalizeHex(pal[2]!);
|
||||||
|
if (hex) palette[Number(pal[1])] = hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!background || !foreground) return undefined;
|
||||||
|
for (let i = 0; i <= 15; i++) if (!palette[i]) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
black: palette[0]!,
|
||||||
|
red: palette[1]!,
|
||||||
|
green: palette[2]!,
|
||||||
|
yellow: palette[3]!,
|
||||||
|
blue: palette[4]!,
|
||||||
|
magenta: palette[5]!,
|
||||||
|
cyan: palette[6]!,
|
||||||
|
white: palette[7]!,
|
||||||
|
brightBlack: palette[8]!,
|
||||||
|
brightRed: palette[9]!,
|
||||||
|
brightGreen: palette[10]!,
|
||||||
|
brightYellow: palette[11]!,
|
||||||
|
brightBlue: palette[12]!,
|
||||||
|
brightMagenta: palette[13]!,
|
||||||
|
brightCyan: palette[14]!,
|
||||||
|
brightWhite: palette[15]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A third shape some community repos name `colors.toml` too, unrelated to Omarchy 4's own — flat
|
||||||
|
* `color0`..`color15` in standard ANSI order, plus `background`/`foreground`. Try this only after
|
||||||
|
* `paletteFromRecord` (the real Omarchy schema) has already failed on the same file: the two are
|
||||||
|
* distinguished only by which keys are present, and a file can't be both.
|
||||||
|
*/
|
||||||
|
export function parseColor16Toml(t: Record<string, string>): AnsiSource | undefined {
|
||||||
|
const at = (i: number): string | undefined => (t[`color${i}`] ? normalizeHex(t[`color${i}`]!) : undefined);
|
||||||
|
const background = t.background ? normalizeHex(t.background) : undefined;
|
||||||
|
const foreground = t.foreground ? normalizeHex(t.foreground) : undefined;
|
||||||
|
if (!background || !foreground) return undefined;
|
||||||
|
const slots = Array.from({ length: 16 }, (_, i) => at(i));
|
||||||
|
if (slots.some((s) => !s)) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
black: slots[0]!,
|
||||||
|
red: slots[1]!,
|
||||||
|
green: slots[2]!,
|
||||||
|
yellow: slots[3]!,
|
||||||
|
blue: slots[4]!,
|
||||||
|
magenta: slots[5]!,
|
||||||
|
cyan: slots[6]!,
|
||||||
|
white: slots[7]!,
|
||||||
|
brightBlack: slots[8]!,
|
||||||
|
brightRed: slots[9]!,
|
||||||
|
brightGreen: slots[10]!,
|
||||||
|
brightYellow: slots[11]!,
|
||||||
|
brightBlue: slots[12]!,
|
||||||
|
brightMagenta: slots[13]!,
|
||||||
|
brightCyan: slots[14]!,
|
||||||
|
brightWhite: slots[15]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Palette } from './palette';
|
||||||
|
import type { AnsiSource } from './ansi-source';
|
||||||
|
import { UI, contrast, ensureContrast, luminance, mix, parseHex } from './contrast';
|
||||||
|
|
||||||
|
// An `AnsiSource` (16 ANSI slots + primary bg/fg) has no notion of "three background depths" or "an
|
||||||
|
// accent" — Omarchy 4's `colors.toml` is a richer, hand-curated shape that a sixteen-slot terminal scheme
|
||||||
|
// was never designed to fill. This derives one anyway, by role rather than by guessing a fixed value:
|
||||||
|
//
|
||||||
|
// - accent is whichever blue-ish slot actually stands out from the background (every official palette
|
||||||
|
// picks blue too — this just guards against a scheme that reused the background for it).
|
||||||
|
// - the three depths are the background pushed a fixed distance toward black or white, in the direction
|
||||||
|
// each mode's surfaces already move (dark themes rise lighter, light themes recede darker — see the
|
||||||
|
// ratios comment below, calibrated against Nord and Rosé Pine's real colors.toml).
|
||||||
|
// - the three foreground weights are the foreground pulled toward the background by a fixed fraction,
|
||||||
|
// standing in for "faint", "muted" and "strong" text.
|
||||||
|
//
|
||||||
|
// None of this has to be exact. `mapPalette`'s `ensureContrast` re-checks every text/surface pair
|
||||||
|
// afterward and nudges anything that fails — the approximation only has to be plausible, not correct.
|
||||||
|
|
||||||
|
function pickAccent(a: AnsiSource): string {
|
||||||
|
const candidates = [a.blue, a.brightBlue, a.cyan, a.brightCyan];
|
||||||
|
const best = candidates.reduce((hi, c) => (contrast(c, a.background) > contrast(hi, a.background) ? c : hi));
|
||||||
|
// `--ring` is this colour laid directly on `--background` (see `map.ts`), and it's in the contract's
|
||||||
|
// PAIRS check same as the official themes — so guarantee it the way `ensureContrast` guarantees text,
|
||||||
|
// rather than only guarding against a background-coloured accent.
|
||||||
|
return ensureContrast(best, a.background, UI);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOmarchyPalette(a: AnsiSource): Palette {
|
||||||
|
const dark = luminance(parseHex(a.background)) < 0.5;
|
||||||
|
const mode: Palette['mode'] = dark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
const darkerBackground = dark ? mix(a.background, '#000000', 0.45) : mix(a.background, '#000000', 0.1);
|
||||||
|
const darkBackground = dark ? mix(a.background, '#000000', 0.22) : mix(a.background, '#000000', 0.06);
|
||||||
|
const lighterBackground = dark ? mix(a.background, '#ffffff', 0.07) : mix(a.background, '#000000', 0.035);
|
||||||
|
|
||||||
|
// Faintest → strongest, all pulled toward the background rather than toward a fixed grey, so a warm
|
||||||
|
// palette's dim text stays warm instead of turning neutral.
|
||||||
|
const darkForeground = mix(a.foreground, a.background, 0.55);
|
||||||
|
const lightForeground = mix(a.foreground, a.background, 0.3);
|
||||||
|
|
||||||
|
const accent = pickAccent(a);
|
||||||
|
const muted = contrast(a.brightBlack, a.background) >= 1.15 ? a.brightBlack : mix(a.foreground, a.background, 0.6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
accent,
|
||||||
|
// Unused by `mapPalette` today (grep confirms `p.selection` has no reader) — kept only because the
|
||||||
|
// `Palette` type requires it.
|
||||||
|
selection: mix(a.background, a.foreground, 0.2),
|
||||||
|
muted,
|
||||||
|
background: a.background,
|
||||||
|
darkBackground,
|
||||||
|
darkerBackground,
|
||||||
|
lighterBackground,
|
||||||
|
foreground: a.foreground,
|
||||||
|
darkForeground,
|
||||||
|
lightForeground,
|
||||||
|
brightForeground: a.foreground,
|
||||||
|
red: a.red,
|
||||||
|
yellow: a.yellow,
|
||||||
|
green: a.green,
|
||||||
|
cyan: a.cyan,
|
||||||
|
blue: a.blue,
|
||||||
|
magenta: a.magenta,
|
||||||
|
// Standard ANSI has no orange slot. Mixing red and yellow reads better as "busy" than falling back to
|
||||||
|
// plain yellow the way `loadPalette` does for the 3 official themes that omit it.
|
||||||
|
orange: mix(a.red, a.yellow, 0.5),
|
||||||
|
brightRed: a.brightRed,
|
||||||
|
brightYellow: a.brightYellow,
|
||||||
|
brightGreen: a.brightGreen,
|
||||||
|
brightCyan: a.brightCyan,
|
||||||
|
brightBlue: a.brightBlue,
|
||||||
|
brightMagenta: a.brightMagenta,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ export function mapPalette(p: Palette): Tokens {
|
|||||||
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
|
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
|
||||||
// person eyeballing a screenshot would have shipped.
|
// person eyeballing a screenshot would have shipped.
|
||||||
const fgMuted = ensureContrast(p.lightForeground, s.high);
|
const fgMuted = ensureContrast(p.lightForeground, s.high);
|
||||||
|
// Same reasoning as `fgMuted`, for the same surface: `--accent` and `--secondary` are BOTH `s.high`,
|
||||||
|
// so their text has to be checked against `s.high` too. It was deriving from `fgRaised` — the weight
|
||||||
|
// for `s.raised` — which is the identical near-miss the comment above describes, just one token over.
|
||||||
|
// A no-op for all 22 official palettes (each already clears 4.5:1 on `s.high` by 1.4x or more, and
|
||||||
|
// `ensureContrast` returns its input untouched when it passes); what it fixes is the ported palettes
|
||||||
|
// whose foreground sits between the two surfaces' thresholds.
|
||||||
|
const fgHigh = ensureContrast(p.foreground, s.high);
|
||||||
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
|
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,13 +95,13 @@ export function mapPalette(p: Palette): Tokens {
|
|||||||
'--muted': s.high,
|
'--muted': s.high,
|
||||||
'--muted-foreground': fgMuted,
|
'--muted-foreground': fgMuted,
|
||||||
'--accent': s.high,
|
'--accent': s.high,
|
||||||
'--accent-foreground': fgRaised,
|
'--accent-foreground': fgHigh,
|
||||||
|
|
||||||
// ── Actions ──
|
// ── Actions ──
|
||||||
'--primary': accent,
|
'--primary': accent,
|
||||||
'--primary-foreground': on(accent),
|
'--primary-foreground': on(accent),
|
||||||
'--secondary': s.high,
|
'--secondary': s.high,
|
||||||
'--secondary-foreground': fgRaised,
|
'--secondary-foreground': fgHigh,
|
||||||
'--cta': accent,
|
'--cta': accent,
|
||||||
'--cta-foreground': on(accent),
|
'--cta-foreground': on(accent),
|
||||||
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
|
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
|
||||||
|
|||||||
@@ -38,21 +38,28 @@ export type Palette = {
|
|||||||
brightMagenta: string;
|
brightMagenta: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A deliberately small TOML reader: this file is flat `key = "value"` and nothing else. */
|
/**
|
||||||
function readToml(path: string): Record<string, string> {
|
* A deliberately small TOML reader: this file is flat `key = "value"` and nothing else.
|
||||||
|
*
|
||||||
|
* Exported (not just used by `loadPalette` below) because a community theme can ship this exact shape
|
||||||
|
* too — see `port-community-themes.ts`, which reads it from a fetched string rather than a file path.
|
||||||
|
*/
|
||||||
|
export function parseTomlText(text: string): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
for (const line of text.split('\n')) {
|
||||||
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
|
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
|
||||||
if (m) out[m[1]!] = m[2]!;
|
// basecamp/omarchy's own files are already lowercase hex; a community `colors.toml` isn't guaranteed
|
||||||
|
// to be, so normalize here rather than leaving the two sources visibly inconsistent.
|
||||||
|
if (m) out[m[1]!] = m[2]!.toLowerCase();
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPalette(tomlPath: string): Palette {
|
/** The 24-key contract, given an already-parsed record rather than a path — see `parseTomlText`. */
|
||||||
const t = readToml(tomlPath);
|
export function paletteFromRecord(t: Record<string, string>, label: string): Palette {
|
||||||
const need = (key: string): string => {
|
const need = (key: string): string => {
|
||||||
const v = t[key];
|
const v = t[key];
|
||||||
if (!v) throw new Error(`${tomlPath}: missing "${key}"`);
|
if (!v) throw new Error(`${label}: missing "${key}"`);
|
||||||
return v;
|
return v;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,3 +91,7 @@ export function loadPalette(tomlPath: string): Palette {
|
|||||||
brightMagenta: need('bright_magenta'),
|
brightMagenta: need('bright_magenta'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadPalette(tomlPath: string): Palette {
|
||||||
|
return paletteFromRecord(parseTomlText(readFileSync(tomlPath, 'utf8')), tomlPath);
|
||||||
|
}
|
||||||
|
|||||||
+5
-135
@@ -1,140 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Regenerate `TODO.md` — the index over `todo/`.
|
* Regenerate `TODO.md` — the index over `todo/`. `bun run todo:index` after any change to the tree.
|
||||||
*
|
*
|
||||||
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
|
* The logic lives in `src/servers/api/todos/todo-tree.ts`, shared with the `/api/todos` router so the
|
||||||
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
|
* board and this CLI cannot disagree about the format; this file is only the command.
|
||||||
* it describes.
|
|
||||||
*/
|
*/
|
||||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
import { writeIndex } from '../src/servers/api/todos/todo-tree';
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const ROOT = new URL('..', import.meta.url).pathname;
|
writeIndex();
|
||||||
const OUT = join(ROOT, 'todo');
|
|
||||||
|
|
||||||
export type Item = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
theme: string;
|
|
||||||
status: string;
|
|
||||||
created: string;
|
|
||||||
/** `urgent` or empty — see todo/README.md for why there is no scale. */
|
|
||||||
priority: string;
|
|
||||||
due: string;
|
|
||||||
order: number;
|
|
||||||
file: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
|
|
||||||
export function frontmatter(text: string): Record<string, string> {
|
|
||||||
const end = text.indexOf('\n---', 4);
|
|
||||||
if (!text.startsWith('---') || end === -1) return {};
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
for (const line of text.slice(4, end).split('\n')) {
|
|
||||||
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
||||||
if (!m || !m[1]) continue;
|
|
||||||
const raw = (m[2] ?? '').trim();
|
|
||||||
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
|
|
||||||
const themes = readdirSync(OUT)
|
|
||||||
.filter((d) => statSync(join(OUT, d)).isDirectory())
|
|
||||||
.sort()
|
|
||||||
.map((theme) => {
|
|
||||||
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
|
|
||||||
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
|
|
||||||
const items = files
|
|
||||||
.filter((f) => f !== '_index.md')
|
|
||||||
.map((f) => {
|
|
||||||
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
|
|
||||||
return {
|
|
||||||
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
|
|
||||||
title: fm.title ?? f,
|
|
||||||
theme,
|
|
||||||
status: fm.status ?? 'open',
|
|
||||||
created: fm.created ?? '',
|
|
||||||
priority: fm.priority ?? '',
|
|
||||||
due: fm.due ?? '',
|
|
||||||
order: Number(fm.order ?? 0),
|
|
||||||
file: `todo/${theme}/${f}`,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.order - b.order);
|
|
||||||
return { theme, heading: meta.heading ?? theme, items };
|
|
||||||
});
|
|
||||||
return { themes };
|
|
||||||
}
|
|
||||||
|
|
||||||
const HEADER = `# TODO — the index
|
|
||||||
|
|
||||||
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
|
|
||||||
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
|
|
||||||
Format and the two-way-sync contract: \`todo/README.md\`.
|
|
||||||
|
|
||||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
|
||||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
|
||||||
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
|
|
||||||
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
|
|
||||||
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
|
|
||||||
deleting it have been removed rather than left to mislead the next reader.
|
|
||||||
|
|
||||||
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
|
||||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
|
||||||
`;
|
|
||||||
|
|
||||||
function render(): string {
|
|
||||||
const { themes } = readTree();
|
|
||||||
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
|
|
||||||
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
|
|
||||||
|
|
||||||
const rows = themes
|
|
||||||
.filter((t) => t.items.length)
|
|
||||||
.map((t) => {
|
|
||||||
const o = t.items.filter((i) => i.status !== 'done').length;
|
|
||||||
const d = t.items.length - o;
|
|
||||||
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const recent = themes
|
|
||||||
.flatMap((t) => t.items)
|
|
||||||
.filter((i) => i.status !== 'done')
|
|
||||||
.sort((a, b) => b.created.localeCompare(a.created))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
|
|
||||||
|
|
||||||
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
|
|
||||||
// whole split risks. List them explicitly rather than letting them age out of view.
|
|
||||||
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
|
|
||||||
|
|
||||||
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
|
|
||||||
// by theme, is an item nobody sees on the day it matters.
|
|
||||||
const urgent = themes
|
|
||||||
.flatMap((t) => t.items)
|
|
||||||
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
|
|
||||||
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
|
|
||||||
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
|
|
||||||
|
|
||||||
return [
|
|
||||||
HEADER,
|
|
||||||
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
|
|
||||||
`## Themes\n`,
|
|
||||||
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
|
|
||||||
`| Theme | Open | Closed |`,
|
|
||||||
`| --- | --- | --- |`,
|
|
||||||
...rows,
|
|
||||||
``,
|
|
||||||
`## Records — no items, kept for the reasoning\n`,
|
|
||||||
...notes,
|
|
||||||
``,
|
|
||||||
`## Newest ten\n`,
|
|
||||||
...recent,
|
|
||||||
``,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
writeFileSync(join(ROOT, 'TODO.md'), render());
|
|
||||||
console.log('TODO.md regenerated');
|
console.log('TODO.md regenerated');
|
||||||
}
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export function App() {
|
|||||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||||
|
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||||
|
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export function ForgotPassword() {
|
export function ForgotPassword() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const client = useClient('/api/auth');
|
const client = useClient('/api/auth');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
@@ -34,29 +36,29 @@ export function ForgotPassword() {
|
|||||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||||
{done ? (
|
{done ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
<h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
|
||||||
<div className="text-sm text-duck-dark/60">
|
<div className="text-sm text-duck-dark/60">
|
||||||
<p>
|
<p>
|
||||||
A password reset link has been sent to <strong>{state.email}</strong>.
|
A password reset link has been sent to <strong>{state.email}</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p>Click it to reset your password.</p>
|
<p>{t('auth.checkEmailHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
|
<div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -74,7 +76,7 @@ export function ForgotPassword() {
|
|||||||
<div className="text-center text-sm text-duck-dark/60">
|
<div className="text-center text-sm text-duck-dark/60">
|
||||||
<p>
|
<p>
|
||||||
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||||
Back to login
|
{t('auth.backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useResetPassword } from './useResetPassword';
|
import { useResetPassword } from './useResetPassword';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export function ResetPassword() {
|
export function ResetPassword() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
||||||
const loading = tokenStatus === 'loading';
|
const loading = tokenStatus === 'loading';
|
||||||
const invalid = tokenStatus === 'invalid';
|
const invalid = tokenStatus === 'invalid';
|
||||||
@@ -15,45 +17,45 @@ export function ResetPassword() {
|
|||||||
<>
|
<>
|
||||||
{loading && (
|
{loading && (
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
<div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invalid && (
|
{invalid && (
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
|
||||||
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
|
<div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Enter your new password</div>
|
<div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
<div className="grid md:flex gap-4">
|
<div className="grid md:flex gap-4">
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Min 12 characters"
|
placeholder={t('auth.minChars')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm your password"
|
placeholder={t('auth.confirmPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -65,7 +67,7 @@ export function ResetPassword() {
|
|||||||
disabled={!isValid || isSubmitting}
|
disabled={!isValid || isSubmitting}
|
||||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
{isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
|
|||||||
import { useMounted } from 'hooks/useMounted';
|
import { useMounted } from 'hooks/useMounted';
|
||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export const useResetPassword = () => {
|
export const useResetPassword = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isMounted = useMounted();
|
const isMounted = useMounted();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useClient('/api/auth');
|
const client = useClient('/api/auth');
|
||||||
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
|
|||||||
password: state.password,
|
password: state.password,
|
||||||
verificationCode,
|
verificationCode,
|
||||||
});
|
});
|
||||||
toast.success('Password reset successfully. Please sign in.');
|
toast.success(t('auth.resetOk'));
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
const error = ex as { message?: string };
|
const error = ex as { message?: string };
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export function Bootstrap() {
|
export function Bootstrap() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const authClient = useClient('/api/auth');
|
const authClient = useClient('/api/auth');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -30,7 +32,7 @@ export function Bootstrap() {
|
|||||||
password: state.password,
|
password: state.password,
|
||||||
confirmPassword: state.confirmPassword,
|
confirmPassword: state.confirmPassword,
|
||||||
});
|
});
|
||||||
toast.success('Administrator account created. Please sign in.');
|
toast.success(t('auth.bootstrapOk'));
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
const error = ex as { message?: string };
|
const error = ex as { message?: string };
|
||||||
@@ -43,35 +45,35 @@ export function Bootstrap() {
|
|||||||
return (
|
return (
|
||||||
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Create your administrator account</div>
|
<div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Name</span>
|
<span className="text-duck-dark/70">{t('auth.name')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Your name"
|
placeholder={t('auth.namePlaceholder')}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="admin@example.com"
|
placeholder={t('auth.adminEmailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Username</span>
|
<span className="text-duck-dark/70">{t('auth.username')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -83,23 +85,23 @@ export function Bootstrap() {
|
|||||||
|
|
||||||
<div className="grid md:flex gap-4">
|
<div className="grid md:flex gap-4">
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Min 12 characters"
|
placeholder={t('auth.minChars')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm your password"
|
placeholder={t('auth.confirmPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
|
|||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
const initialState: LoginFormState = {
|
const initialState: LoginFormState = {
|
||||||
// email: 'pastilhas@pastilhas.dev',
|
// email: 'pastilhas@pastilhas.dev',
|
||||||
// password: '',
|
// password: '',
|
||||||
};
|
};
|
||||||
export function Login() {
|
export function Login() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
||||||
const { signin } = useAuth();
|
const { signin } = useAuth();
|
||||||
@@ -40,20 +42,20 @@ export function Login() {
|
|||||||
return (
|
return (
|
||||||
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
<div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email or username</span>
|
<span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
|
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
|
||||||
// field would have validated the user out of an account they have.
|
// field would have validated the user out of an account they have.
|
||||||
type="text"
|
type="text"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
// `username` rather than `email`, which is what password managers expect for a field that
|
// `username` rather than `email`, which is what password managers expect for a field that
|
||||||
// takes either — and it is the value they already store beside the password.
|
// takes either — and it is the value they already store beside the password.
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -61,12 +63,12 @@ export function Login() {
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Your password"
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -90,7 +92,7 @@ export function Login() {
|
|||||||
{/* </p> */}
|
{/* </p> */}
|
||||||
<p>
|
<p>
|
||||||
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||||
Forgot password?
|
{t('auth.forgotPassword')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type DebugPanelProps = {
|
type DebugPanelProps = {
|
||||||
fullControlMode: boolean;
|
fullControlMode: boolean;
|
||||||
cameraInfo: {
|
cameraInfo: {
|
||||||
@@ -13,9 +15,10 @@ type DebugPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
||||||
<div className="font-bold text-blue-400">Controls:</div>
|
<div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
|
||||||
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
||||||
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
||||||
</div>
|
</div>
|
||||||
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
|
|||||||
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
||||||
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
||||||
|
|
||||||
<div className="font-bold text-green-400 pt-2">Camera:</div>
|
<div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
|
||||||
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
||||||
|
|
||||||
<div className="font-bold text-yellow-400 pt-2">Model:</div>
|
<div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
|
||||||
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { Link, useParams } from 'react-router';
|
import { Link, useParams } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
|
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
|
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
|
||||||
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
|
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
|
||||||
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
|
|||||||
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
|
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
|
||||||
// the screen re-derives the param from the registry row — one address shape for both kinds.
|
// the screen re-derives the param from the registry row — one address shape for both kinds.
|
||||||
export const ActivityScreen = () => {
|
export const ActivityScreen = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { token, get } = useClient();
|
const { token, get } = useClient();
|
||||||
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
||||||
const [regLoaded, setRegLoaded] = useState(false);
|
const [regLoaded, setRegLoaded] = useState(false);
|
||||||
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
|
|||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
|
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Background tasks
|
{t('activity.backgroundTasks')}
|
||||||
</div>
|
</div>
|
||||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
{reg.tasks.length === 0 && (
|
||||||
{reg.tasks.map((t) => (
|
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
|
||||||
|
)}
|
||||||
|
{reg.tasks.map((task) => (
|
||||||
<Link
|
<Link
|
||||||
key={t.id}
|
key={task.id}
|
||||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
to={`/activity/${encodeURIComponent(task.id)}`}
|
||||||
className={rowCls(selectedId === t.id)}
|
className={rowCls(selectedId === task.id)}
|
||||||
title={t.cwd}
|
title={task.cwd}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||||
/>
|
/>
|
||||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
<span className="truncate font-mono text-xs">{task.id}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{reg.detached.length > 0 && (
|
{reg.detached.length > 0 && (
|
||||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Detached
|
{t('activity.detached')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reg.detached.map((d) => (
|
{reg.detached.map((d) => (
|
||||||
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
|
|||||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||||
it never started.{' '}
|
it never started.{' '}
|
||||||
<Link to="/activity" className="underline">
|
<Link to="/activity" className="underline">
|
||||||
Back to the list
|
{t('activity.backToList')}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
Select a task to follow its live output
|
{t('activity.selectTask')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { useChat, EmbeddableChat } from 'officerdev';
|
import { useChat, EmbeddableChat } from 'officerdev';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
type CapabilitySummary = {
|
type CapabilitySummary = {
|
||||||
dirName: string;
|
dirName: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +24,8 @@ export type CapabilityDetail = CapabilitySummary & {
|
|||||||
rawFrontmatter: string;
|
rawFrontmatter: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
chatSessionId: string | null;
|
chatSessionId: string | null;
|
||||||
|
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
|
||||||
|
plugin?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CapabilityListProps = {
|
type CapabilityListProps = {
|
||||||
@@ -277,12 +280,18 @@ export const CapabilityChat = ({
|
|||||||
description,
|
description,
|
||||||
onResponseEnd,
|
onResponseEnd,
|
||||||
}: CapabilityChatProps) => {
|
}: CapabilityChatProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const seedFile = `${kind.toUpperCase()}.md`;
|
const seedFile = `${kind.toUpperCase()}.md`;
|
||||||
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||||
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
|
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
|
||||||
|
/*
|
||||||
|
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
|
||||||
|
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
|
||||||
|
machine-facing rather than something anybody reads.
|
||||||
|
*/
|
||||||
const defaultInput = isNew
|
const defaultInput = isNew
|
||||||
? (description ?? `Help me create the content for this new ${kind} file`)
|
? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
|
||||||
: `Help me understand and improve this ${kind} file`;
|
: t('capability.improvePrompt', { kind: kind.toLowerCase() });
|
||||||
|
|
||||||
const chat = useChat(undefined, undefined, { replaceUrl: false });
|
const chat = useChat(undefined, undefined, { replaceUrl: false });
|
||||||
|
|
||||||
@@ -301,6 +310,7 @@ export const CapabilityChat = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -310,7 +320,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
|||||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||||
<span className="text-xs font-medium">Frontmatter</span>
|
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
||||||
@@ -330,6 +340,7 @@ export const CapabilityList = ({
|
|||||||
showCreate,
|
showCreate,
|
||||||
onShowCreateChange,
|
onShowCreateChange,
|
||||||
}: CapabilityListProps) => {
|
}: CapabilityListProps) => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [internalCreating, setInternalCreating] = useState(false);
|
const [internalCreating, setInternalCreating] = useState(false);
|
||||||
@@ -356,7 +367,7 @@ export const CapabilityList = ({
|
|||||||
setNewName('');
|
setNewName('');
|
||||||
onCreate(res.dirName);
|
onCreate(res.dirName);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to create ${kind}`);
|
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,7 +438,7 @@ export const CapabilityList = ({
|
|||||||
<input
|
<input
|
||||||
value={internalSearch}
|
value={internalSearch}
|
||||||
onChange={(ev) => setInternalSearch(ev.target.value)}
|
onChange={(ev) => setInternalSearch(ev.target.value)}
|
||||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
|
||||||
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,7 +463,7 @@ export const CapabilityList = ({
|
|||||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
||||||
)}
|
)}
|
||||||
{items.length > 0 && filtered.length === 0 && (
|
{items.length > 0 && filtered.length === 0 && (
|
||||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -469,6 +480,7 @@ export const CapabilityList = ({
|
|||||||
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
||||||
*/
|
*/
|
||||||
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -497,7 +509,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
|||||||
navigate(basePath, { replace: true });
|
navigate(basePath, { replace: true });
|
||||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to delete ${kind}`);
|
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -526,9 +538,19 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
|||||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||||
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
||||||
</span>
|
</span>
|
||||||
{detail && (
|
{/*
|
||||||
|
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
|
||||||
|
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
|
||||||
|
and the delete refuses server-side (409). Say whose it is instead of showing two controls
|
||||||
|
that cannot work.
|
||||||
|
*/}
|
||||||
|
{detail?.plugin ? (
|
||||||
|
<span className="text-xs text-duck-dark/40">
|
||||||
|
{t('capability.fromPlugin', { plugin: detail.plugin })}
|
||||||
|
</span>
|
||||||
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(!editing)}
|
onClick={() => setEditing(!editing)}
|
||||||
@@ -543,7 +565,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
|||||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto flex-1 p-6">
|
<div className="overflow-y-auto flex-1 p-6">
|
||||||
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
||||||
@@ -554,10 +576,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</article>
|
</article>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
|
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
||||||
Select a {kind.toLowerCase()} to view its contents
|
{t('capability.selectToView', { kind: kind.toLowerCase() })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -606,10 +628,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||||
Cancel
|
{tUi('button.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||||
Delete
|
{t('capability.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||||
import { defaultLayout, hasAppType } from './defaultLayout';
|
import { defaultLayout, hasAppType } from './defaultLayout';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||||
const CHAT_TAIL = 20;
|
const CHAT_TAIL = 20;
|
||||||
@@ -18,6 +19,7 @@ type SessionListPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
|
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
|
||||||
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
||||||
const groupCwd = cwdFromSplat(splat);
|
const groupCwd = cwdFromSplat(splat);
|
||||||
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
|
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
|
||||||
// no hint that the transcript could not be read, which is indistinguishable from a new session.
|
// no hint that the transcript could not be read, which is indistinguishable from a new session.
|
||||||
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
|
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
|
||||||
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
|
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
|
||||||
setSelected({ id: sessionId });
|
setSelected({ id: sessionId });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { CodeEditorView } from 'officerdev';
|
import { CodeEditorView } from 'officerdev';
|
||||||
import { Widget } from 'widgets/Widget';
|
import { Widget } from 'widgets/Widget';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export const CodeEditor = () => (
|
export const CodeEditor = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<div className="h-full w-full flex items-center justify-center">
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||||
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
|
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
|
||||||
<CodeEditorView className="h-full w-full" urlState />
|
<CodeEditorView className="h-full w-full" urlState />
|
||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView, useCommanderFilesPanel } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||||
|
|
||||||
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
|
// /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
|
||||||
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
|
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
|
||||||
// "Route, surface, placement" and "Layout, v1".
|
// "Route, surface, placement".
|
||||||
|
|
||||||
export const CommandHqScreen = () => {
|
export const CommandHqScreen = () => {
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
||||||
|
const ephemeral = useCommanderFilesPanel();
|
||||||
|
|
||||||
|
/*
|
||||||
|
Move a saved three-column layout onto the two-column one.
|
||||||
|
|
||||||
|
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
|
||||||
|
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
|
||||||
|
appType check, and why replacing outright is the safe direction on a `locked` screen.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace.isLoaded) return;
|
||||||
|
if (!isLegacyThreeColumn(workspace.value)) return;
|
||||||
|
workspace.setValue(defaultLayout);
|
||||||
|
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
|
{/* The commander's own file browser is a layout of its own, rendered beside this one — it never
|
||||||
|
enters `workspace.value`, so this screen stays locked and the allow-list does not have to know
|
||||||
|
about it. Same trade `/files` makes for the file viewer. */}
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
locked
|
locked
|
||||||
@@ -19,6 +37,7 @@ export const CommandHqScreen = () => {
|
|||||||
allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'],
|
allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'],
|
||||||
fallback: 'command-hq-detail',
|
fallback: 'command-hq-detail',
|
||||||
}}
|
}}
|
||||||
|
ephemeral={ephemeral}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,60 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
|
/*
|
||||||
// detail pane gets the most room because it is a live chat transcript once something is selected.
|
Two columns, with the navigation stacked into the left one.
|
||||||
|
|
||||||
|
── Why it changed from three ──
|
||||||
|
|
||||||
|
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
|
||||||
|
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
|
||||||
|
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
|
||||||
|
enough to see whole.
|
||||||
|
|
||||||
|
Now: commanders on top, categories beneath them, transcript taking three quarters.
|
||||||
|
|
||||||
|
── Why the categories are BELOW the commanders ──
|
||||||
|
|
||||||
|
Because the commanders are the thing you came for and the categories are how you narrow them when there
|
||||||
|
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
|
||||||
|
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
|
||||||
|
the categories can grow their own scrollbar without ever taking room from the commanders.
|
||||||
|
*/
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'command-hq-root',
|
id: 'command-hq-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
|
{
|
||||||
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
|
node: {
|
||||||
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
|
type: 'group',
|
||||||
|
id: 'command-hq-nav',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
|
||||||
|
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 24,
|
||||||
|
},
|
||||||
|
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this a saved copy of the three-column layout this replaced?
|
||||||
|
*
|
||||||
|
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
|
||||||
|
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
|
||||||
|
* horizontal root with THREE children, and the new one has two.
|
||||||
|
*
|
||||||
|
* It terminates for that reason: the replacement does not satisfy the test.
|
||||||
|
*
|
||||||
|
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
|
||||||
|
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
|
||||||
|
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
|
||||||
|
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
|
||||||
|
* copies of one picker.
|
||||||
|
*/
|
||||||
|
export function isLegacyThreeColumn(node: LayoutNode): boolean {
|
||||||
|
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useParams, Link } from 'react-router';
|
|||||||
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
|
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type TFn = ReturnType<typeof useTranslation>['t'];
|
||||||
|
|
||||||
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
|
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
|
||||||
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
|
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
|
||||||
@@ -29,26 +32,40 @@ type DownloadJob = {
|
|||||||
const POLL_MS = 1500;
|
const POLL_MS = 1500;
|
||||||
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
|
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
|
||||||
|
|
||||||
const statusBadge = (status: string) => {
|
// Takes `t` because it is a module-level helper, not a component — the alternative is a hook
|
||||||
|
// call outside a component body.
|
||||||
|
const statusBadge = (status: string, t: TFn) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
return {
|
return {
|
||||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
label: 'Running',
|
label: t('jobs.status.running'),
|
||||||
cls: 'text-amber-600 dark:text-amber-500',
|
cls: 'text-amber-600 dark:text-amber-500',
|
||||||
};
|
};
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
|
return {
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
label: t('jobs.status.queued'),
|
||||||
|
cls: 'text-blue-600 dark:text-blue-400',
|
||||||
|
};
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
|
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return { icon: <XCircle className="h-4 w-4" />, label: 'Failed', cls: 'text-red-600 dark:text-red-400' };
|
return {
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
label: t('jobs.status.failed'),
|
||||||
|
cls: 'text-red-600 dark:text-red-400',
|
||||||
|
};
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
|
return {
|
||||||
|
icon: <Ban className="h-4 w-4" />,
|
||||||
|
label: t('jobs.status.stopped'),
|
||||||
|
cls: 'text-duck-dark/50 dark:text-foreground/50',
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: <XCircle className="h-4 w-4" />,
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
label: 'Interrupted',
|
label: t('jobs.status.interrupted'),
|
||||||
cls: 'text-orange-600 dark:text-orange-400',
|
cls: 'text-orange-600 dark:text-orange-400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,6 +117,7 @@ export const PhaseBar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadJobDetail = () => {
|
export const DownloadJobDetail = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [job, setJob] = useState<DownloadJob | null>(null);
|
const [job, setJob] = useState<DownloadJob | null>(null);
|
||||||
@@ -134,9 +152,9 @@ export const DownloadJobDetail = () => {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
await client.post(`/jobs/${id}/stop`, {});
|
await client.post(`/jobs/${id}/stop`, {});
|
||||||
toast.success('Stop requested');
|
toast.success(t('jobs.stopRequested'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to stop');
|
toast.error(t('jobs.stopFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,14 +167,14 @@ export const DownloadJobDetail = () => {
|
|||||||
if (!job)
|
if (!job)
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
||||||
<span>Job not found.</span>
|
<span>{t('jobs.notFound')}</span>
|
||||||
<Link to="/jobs" className="text-duck-teal hover:underline">
|
<Link to="/jobs" className="text-duck-teal hover:underline">
|
||||||
Back to jobs
|
{t('jobs.backToJobs')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const badge = statusBadge(job.status);
|
const badge = statusBadge(job.status, t);
|
||||||
const running = !isTerminal(job.status);
|
const running = !isTerminal(job.status);
|
||||||
const p = job.progress;
|
const p = job.progress;
|
||||||
const isAudio = job.inputs?.format !== 'video';
|
const isAudio = job.inputs?.format !== 'video';
|
||||||
@@ -197,7 +215,7 @@ export const DownloadJobDetail = () => {
|
|||||||
onClick={stop}
|
onClick={stop}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3" /> Stop
|
<Square className="h-3 w-3" /> {t('jobs.stop')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,14 +224,14 @@ export const DownloadJobDetail = () => {
|
|||||||
<div className="mx-auto flex max-w-md flex-col gap-5">
|
<div className="mx-auto flex max-w-md flex-col gap-5">
|
||||||
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
|
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
|
||||||
<PhaseBar
|
<PhaseBar
|
||||||
label="Titles"
|
label={t('jobs.titles')}
|
||||||
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
|
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
|
||||||
active={p?.phase === 'metadata'}
|
active={p?.phase === 'metadata'}
|
||||||
savedLabel="found"
|
savedLabel="found"
|
||||||
failedLabel="skipped"
|
failedLabel="skipped"
|
||||||
/>
|
/>
|
||||||
<PhaseBar
|
<PhaseBar
|
||||||
label="Download"
|
label={t('jobs.download')}
|
||||||
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
|
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
|
||||||
active={p?.phase === 'download'}
|
active={p?.phase === 'download'}
|
||||||
savedLabel="saved"
|
savedLabel="saved"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { randomId } from 'helpers/random-id';
|
import { randomId } from 'helpers/random-id';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ type ToolCallEntryProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,14 +185,14 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
|||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
|
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.input')}</div>
|
||||||
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
|
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
|
||||||
{JSON.stringify(entry.toolInput, null, 2)}
|
{JSON.stringify(entry.toolInput, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
{entry.output !== undefined && (
|
{entry.output !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
|
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.output')}</div>
|
||||||
<pre
|
<pre
|
||||||
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
|
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||||
>
|
>
|
||||||
@@ -236,6 +238,7 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StepsPanel = () => {
|
const StepsPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const ctx = useJobPanel();
|
const ctx = useJobPanel();
|
||||||
const {
|
const {
|
||||||
displaySteps,
|
displaySteps,
|
||||||
@@ -255,7 +258,7 @@ const StepsPanel = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="px-3 py-2 border-b border-duck-dark/10">
|
<div className="px-3 py-2 border-b border-duck-dark/10">
|
||||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
|
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.steps')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{displaySteps.map((step, i) => {
|
{displaySteps.map((step, i) => {
|
||||||
@@ -336,6 +339,7 @@ const StepsPanel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OutputPanel = () => {
|
const OutputPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const ctx = useJobPanel();
|
const ctx = useJobPanel();
|
||||||
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
|
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
|
||||||
|
|
||||||
@@ -345,7 +349,7 @@ const OutputPanel = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
||||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
|
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.output')}</h2>
|
||||||
{selectedKey && (
|
{selectedKey && (
|
||||||
<span className="text-xs text-duck-dark/40 truncate">
|
<span className="text-xs text-duck-dark/40 truncate">
|
||||||
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
|
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
|
||||||
@@ -355,12 +359,12 @@ const OutputPanel = () => {
|
|||||||
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
||||||
{!selectedKey && (
|
{!selectedKey && (
|
||||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
||||||
Select a step or sub-task to view output
|
{t('jobs.selectStep')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
|
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
|
||||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
||||||
No output yet
|
{t('jobs.noOutput')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedOutputs.map((entry) => {
|
{selectedOutputs.map((entry) => {
|
||||||
@@ -385,6 +389,7 @@ const OutputPanel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PipelineJobDetail = () => {
|
export const PipelineJobDetail = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
@@ -792,15 +797,15 @@ export const PipelineJobDetail = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
|
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">{t('jobs.loading')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
||||||
<span>Job not found</span>
|
<span>{t('jobs.notFound')}</span>
|
||||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
|
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
|
||||||
Back to jobs
|
{t('jobs.backToJobs')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -821,7 +826,11 @@ export const PipelineJobDetail = () => {
|
|||||||
<Card className="px-4 py-3 shrink-0">
|
<Card className="px-4 py-3 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
|
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
|
||||||
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
<Link
|
||||||
|
to="/jobs"
|
||||||
|
aria-label={t('jobs.backToJobs')}
|
||||||
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||||
|
>
|
||||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -837,7 +846,7 @@ export const PipelineJobDetail = () => {
|
|||||||
{isRunning && (
|
{isRunning && (
|
||||||
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
|
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
|
||||||
<Square className="h-3 w-3" />
|
<Square className="h-3 w-3" />
|
||||||
Stop
|
{t('jobs.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -852,13 +861,13 @@ export const PipelineJobDetail = () => {
|
|||||||
{displayCost && (
|
{displayCost && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
||||||
<span className="text-duck-dark/40">Tokens:</span>
|
<span className="text-duck-dark/40">{t('jobs.tokens')}</span>
|
||||||
<span className="font-mono tabular-nums">
|
<span className="font-mono tabular-nums">
|
||||||
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
|
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
||||||
<span className="text-duck-dark/40">Cost:</span>
|
<span className="text-duck-dark/40">{t('jobs.cost')}</span>
|
||||||
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
|
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Card } from '@/components/Card';
|
|||||||
import { ScriptJobDetail } from './ScriptJobDetail';
|
import { ScriptJobDetail } from './ScriptJobDetail';
|
||||||
import { DownloadJobDetail } from './DownloadJobDetail';
|
import { DownloadJobDetail } from './DownloadJobDetail';
|
||||||
import { PipelineJobDetail } from './JobDetail';
|
import { PipelineJobDetail } from './JobDetail';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type JobSummary = {
|
type JobSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -99,6 +100,7 @@ const useJobsData = () => {
|
|||||||
|
|
||||||
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
|
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
|
||||||
const JobRow = ({ job, onAction }: JobRowProps) => {
|
const JobRow = ({ job, onAction }: JobRowProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id: activeId } = useParams<{ id: string }>();
|
const { id: activeId } = useParams<{ id: string }>();
|
||||||
const isRunning = job.status === 'running';
|
const isRunning = job.status === 'running';
|
||||||
const target = basename(job.target);
|
const target = basename(job.target);
|
||||||
@@ -126,7 +128,7 @@ const JobRow = ({ job, onAction }: JobRowProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={(ev) => onAction(ev, job)}
|
onClick={(ev) => onAction(ev, job)}
|
||||||
title={isRunning ? 'Stop' : 'Delete'}
|
title={isRunning ? t('jobs.stop') : 'Delete'}
|
||||||
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
|
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
|
||||||
@@ -144,6 +146,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => (
|
|||||||
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
|
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
|
||||||
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
|
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
|
||||||
const TestNotificationButton = () => {
|
const TestNotificationButton = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
@@ -157,12 +160,12 @@ const TestNotificationButton = () => {
|
|||||||
);
|
);
|
||||||
const results = res?.results ?? [];
|
const results = res?.results ?? [];
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
toast.warning('No channels configured');
|
toast.warning(t('jobs.noChannels'));
|
||||||
} else {
|
} else {
|
||||||
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
|
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to send notification');
|
toast.error(t('jobs.notifyFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -172,7 +175,7 @@ const TestNotificationButton = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
title="Send a test notification to every configured channel"
|
title={t('jobs.testNotification')}
|
||||||
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
|
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Bell className="h-3.5 w-3.5" />
|
<Bell className="h-3.5 w-3.5" />
|
||||||
@@ -182,6 +185,7 @@ const TestNotificationButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ActiveJobsPanel = () => {
|
const ActiveJobsPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { jobs, isLoading, act } = useJobsData();
|
const { jobs, isLoading, act } = useJobsData();
|
||||||
const active = [
|
const active = [
|
||||||
...jobs.filter((j) => j.status === 'running'),
|
...jobs.filter((j) => j.status === 'running'),
|
||||||
@@ -191,15 +195,17 @@ const ActiveJobsPanel = () => {
|
|||||||
<div className="h-full p-2">
|
<div className="h-full p-2">
|
||||||
<Card className="h-full flex flex-col overflow-hidden">
|
<Card className="h-full flex flex-col overflow-hidden">
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<h2 className="text-sm font-semibold text-duck-dark">Running & Queued</h2>
|
<h2 className="text-sm font-semibold text-duck-dark">{t('jobs.runningQueued')}</h2>
|
||||||
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
|
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
|
||||||
<TestNotificationButton />
|
<TestNotificationButton />
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
|
||||||
) : active.length === 0 ? (
|
) : active.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Nothing running</div>
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
||||||
|
{t('jobs.nothingRunning')}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
|
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
|
||||||
)}
|
)}
|
||||||
@@ -211,6 +217,7 @@ const ActiveJobsPanel = () => {
|
|||||||
|
|
||||||
// Bottom-left panel: finished / failed / stopped, newest first, searchable.
|
// Bottom-left panel: finished / failed / stopped, newest first, searchable.
|
||||||
const HistoryJobsPanel = () => {
|
const HistoryJobsPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { jobs, isLoading, act, clearHistory } = useJobsData();
|
const { jobs, isLoading, act, clearHistory } = useJobsData();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const history = jobs
|
const history = jobs
|
||||||
@@ -224,12 +231,12 @@ const HistoryJobsPanel = () => {
|
|||||||
<div className="h-full p-2">
|
<div className="h-full p-2">
|
||||||
<Card className="h-full flex flex-col overflow-hidden">
|
<Card className="h-full flex flex-col overflow-hidden">
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<h2 className="text-sm font-semibold text-duck-dark shrink-0">History</h2>
|
<h2 className="text-sm font-semibold text-duck-dark shrink-0">{t('jobs.history')}</h2>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search…"
|
placeholder={t('jobs.search')}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||||
@@ -238,16 +245,16 @@ const HistoryJobsPanel = () => {
|
|||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearHistory}
|
onClick={clearHistory}
|
||||||
title="Delete all finished jobs"
|
title={t('jobs.deleteFinished')}
|
||||||
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
|
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
Clear all
|
{t('jobs.clearAll')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
|
||||||
) : history.length === 0 ? (
|
) : history.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
||||||
{search ? 'No matches' : 'No finished jobs'}
|
{search ? 'No matches' : 'No finished jobs'}
|
||||||
@@ -263,6 +270,7 @@ const HistoryJobsPanel = () => {
|
|||||||
|
|
||||||
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
|
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
|
||||||
const JobDetailPanel = () => {
|
const JobDetailPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [mode, setMode] = useState<string | null>(null);
|
const [mode, setMode] = useState<string | null>(null);
|
||||||
@@ -284,7 +292,7 @@ const JobDetailPanel = () => {
|
|||||||
<div className="h-full p-2">
|
<div className="h-full p-2">
|
||||||
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
|
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
|
||||||
<Inbox className="h-8 w-8" />
|
<Inbox className="h-8 w-8" />
|
||||||
<span className="text-sm">Select a job</span>
|
<span className="text-sm">{t('jobs.selectJob')}</span>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -301,7 +309,7 @@ const JobDetailPanel = () => {
|
|||||||
if (mode === 'notfound') {
|
if (mode === 'notfound') {
|
||||||
return (
|
return (
|
||||||
<div className="h-full p-2">
|
<div className="h-full p-2">
|
||||||
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">Job not found.</Card>
|
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">{t('jobs.notFound')}</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'l
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
|
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type TFn = ReturnType<typeof useTranslation>['t'];
|
||||||
|
|
||||||
type ScriptJob = {
|
type ScriptJob = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,36 +30,46 @@ const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === '
|
|||||||
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
|
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
|
||||||
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
|
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
|
||||||
|
|
||||||
const statusBadge = (status: string, exitCode: number | null) => {
|
// Takes `t` because it is a module-level helper, not a component.
|
||||||
|
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
return {
|
return {
|
||||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
label: 'Running',
|
label: t('jobs.status.running'),
|
||||||
cls: 'text-amber-600 dark:text-amber-500',
|
cls: 'text-amber-600 dark:text-amber-500',
|
||||||
};
|
};
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
|
return {
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
label: t('jobs.status.queued'),
|
||||||
|
cls: 'text-blue-600 dark:text-blue-400',
|
||||||
|
};
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
|
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return {
|
return {
|
||||||
icon: <XCircle className="h-4 w-4" />,
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`,
|
label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
|
||||||
cls: 'text-red-600 dark:text-red-400',
|
cls: 'text-red-600 dark:text-red-400',
|
||||||
};
|
};
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
|
return {
|
||||||
|
icon: <Ban className="h-4 w-4" />,
|
||||||
|
label: t('jobs.status.stopped'),
|
||||||
|
cls: 'text-duck-dark/50 dark:text-foreground/50',
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: <XCircle className="h-4 w-4" />,
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
label: 'Interrupted',
|
label: t('jobs.status.interrupted'),
|
||||||
cls: 'text-orange-600 dark:text-orange-400',
|
cls: 'text-orange-600 dark:text-orange-400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScriptJobDetail = () => {
|
export const ScriptJobDetail = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
@@ -137,9 +150,9 @@ export const ScriptJobDetail = () => {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
await client.post(`/jobs/${id}/stop`, {});
|
await client.post(`/jobs/${id}/stop`, {});
|
||||||
toast.success('Stop requested');
|
toast.success(t('jobs.stopRequested'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to stop');
|
toast.error(t('jobs.stopFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,15 +166,15 @@ export const ScriptJobDetail = () => {
|
|||||||
if (!job) {
|
if (!job) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
||||||
<span>Job not found.</span>
|
<span>{t('jobs.notFound')}</span>
|
||||||
<Link to="/jobs" className="text-duck-teal hover:underline">
|
<Link to="/jobs" className="text-duck-teal hover:underline">
|
||||||
Back to jobs
|
{t('jobs.backToJobs')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const badge = statusBadge(job.status, job.exitCode);
|
const badge = statusBadge(job.status, job.exitCode, t);
|
||||||
const running = !isTerminal(job.status);
|
const running = !isTerminal(job.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,7 +198,7 @@ export const ScriptJobDetail = () => {
|
|||||||
onClick={stop}
|
onClick={stop}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3" /> Stop
|
<Square className="h-3 w-3" /> {t('jobs.stop')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,14 +206,14 @@ export const ScriptJobDetail = () => {
|
|||||||
{isDownloadProgress(job.progress) && (
|
{isDownloadProgress(job.progress) && (
|
||||||
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
|
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
|
||||||
<PhaseBar
|
<PhaseBar
|
||||||
label="Titles"
|
label={t('jobs.titles')}
|
||||||
c={job.progress.meta}
|
c={job.progress.meta}
|
||||||
active={job.progress.phase === 'metadata'}
|
active={job.progress.phase === 'metadata'}
|
||||||
savedLabel="found"
|
savedLabel="found"
|
||||||
failedLabel="skipped"
|
failedLabel="skipped"
|
||||||
/>
|
/>
|
||||||
<PhaseBar
|
<PhaseBar
|
||||||
label="Download"
|
label={t('jobs.download')}
|
||||||
c={job.progress.dl}
|
c={job.progress.dl}
|
||||||
active={job.progress.phase === 'download'}
|
active={job.progress.phase === 'download'}
|
||||||
savedLabel="saved"
|
savedLabel="saved"
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Bug } from 'lucide-react';
|
import { Bug } from 'lucide-react';
|
||||||
import { useBugReport } from './use-bug-report';
|
import { useBugReport } from './use-bug-report';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { BugReportDialog } from './BugReportDialog';
|
import { BugReportDialog } from './BugReportDialog';
|
||||||
|
|
||||||
export const BugReportButton = () => {
|
export const BugReportButton = () => {
|
||||||
const bugReport = useBugReport();
|
const bugReport = useBugReport();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={bugReport.handleOpen}
|
onClick={bugReport.handleOpen}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
|
||||||
aria-label="Report a bug"
|
aria-label={t('bugReport.label')}
|
||||||
>
|
>
|
||||||
<Bug size={16} />
|
<Bug size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type BugReportDialogProps = {
|
type BugReportDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: BugReportDialogProps) => {
|
}: BugReportDialogProps) => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||||
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Report a Bug</DialogTitle>
|
<DialogTitle>{t('bugReport.title')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
|
|||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : previewUrl ? (
|
) : previewUrl ? (
|
||||||
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
|
<img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||||
No screenshot captured
|
{t('bugReport.noScreenshot')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Describe what went wrong..."
|
placeholder={t('bugReport.describePlaceholder')}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
|
||||||
Console logs, browser info, and recent API errors are collected automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||||
Cancel
|
{tUi('button.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
{submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
|
||||||
Submit
|
{t('bugReport.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
|
|||||||
import type { ErrorDetails } from 'hooks/useClient';
|
import type { ErrorDetails } from 'hooks/useClient';
|
||||||
import { getConsoleBuffer } from '@/lib/console-buffer';
|
import { getConsoleBuffer } from '@/lib/console-buffer';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export const useBugReport = () => {
|
export const useBugReport = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -14,6 +15,7 @@ export const useBugReport = () => {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
||||||
|
|
||||||
const handleOpen = useCallback(async () => {
|
const handleOpen = useCallback(async () => {
|
||||||
@@ -57,15 +59,15 @@ export const useBugReport = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.post('/bug-report', formData);
|
await client.post('/bug-report', formData);
|
||||||
toast.success('Bug report submitted');
|
toast.success(t('bugReport.submitted'));
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to submit bug report');
|
toast.error(t('bugReport.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[screenshot, client, user, apiError, handleClose],
|
[screenshot, client, user, apiError, handleClose, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
|
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|||||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||||
import { Background } from './Background';
|
import { Background } from './Background';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
import { Dock, useDockItems, defaultDockPaths } from './Dock';
|
||||||
import { Spotlight } from './Spotlight/Spotlight';
|
import { Spotlight } from './Spotlight/Spotlight';
|
||||||
|
import { ShortcutsModal } from './ShortcutsModal';
|
||||||
import { useIsTouch } from './useIsTouch';
|
import { useIsTouch } from './useIsTouch';
|
||||||
import { usePageTitleSync } from '@/state/usePageTitle';
|
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||||
import { RouteGate } from './RouteGate';
|
import { RouteGate } from './RouteGate';
|
||||||
@@ -23,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
// will then slam is worse than one that never showed it.
|
// will then slam is worse than one that never showed it.
|
||||||
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
||||||
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
||||||
const permitted = useMemo(
|
const allDockItems = useDockItems(plugins);
|
||||||
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
|
const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
|
||||||
[canVisit, plugins],
|
|
||||||
);
|
|
||||||
const { items: visibleItems } = useDock(
|
const { items: visibleItems } = useDock(
|
||||||
permitted,
|
permitted,
|
||||||
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
||||||
@@ -51,6 +50,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||||
<Spotlight />
|
<Spotlight />
|
||||||
|
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
|
||||||
|
panel, an error boundary, or mid-navigation. */}
|
||||||
|
<ShortcutsModal />
|
||||||
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
||||||
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
||||||
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { resolveIcon } from 'officerdev';
|
import { resolveIcon } from 'officerdev';
|
||||||
import type { PluginManifest } from 'hooks/usePermissions';
|
import type { PluginManifest } from 'hooks/usePermissions';
|
||||||
|
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||||
|
|
||||||
export type DockItem = {
|
export type DockItem = {
|
||||||
|
/** Already resolved for the active locale — see `useDockItems`. */
|
||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -155,6 +157,7 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Radar,
|
Radar,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,18 +171,22 @@ import {
|
|||||||
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
||||||
* the light profile because it fronts a remote instance and installs nothing locally.
|
* the light profile because it fronts a remote instance and installs nothing locally.
|
||||||
*/
|
*/
|
||||||
export const CORE_DOCK_ITEMS: DockItem[] = [
|
export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
{ labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
|
||||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||||
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
||||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
{ labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
{ labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
{ labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
{ labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
|
// Core because it is the door into another account's filesystem, and the permission that gates it is
|
||||||
|
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
|
||||||
|
// every other grantable tile here gets.
|
||||||
|
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
||||||
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
||||||
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
||||||
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
||||||
@@ -189,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
|||||||
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
|
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
|
||||||
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
|
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
|
||||||
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
|
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
|
||||||
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
||||||
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
||||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||||
// things that disappears when uninstalled.
|
// things that disappears when uninstalled.
|
||||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
{ labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
|
||||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||||
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
{ labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
|||||||
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
||||||
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
|
||||||
|
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
|
||||||
|
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
|
||||||
|
*/
|
||||||
|
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composed dock, with the shell's own labels in the active locale.
|
||||||
|
*
|
||||||
|
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
|
||||||
|
* spreading the same two sources, and each would otherwise have to resolve labels itself.
|
||||||
|
*/
|
||||||
|
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
|
||||||
|
...dockItemsFromPlugins(plugins),
|
||||||
|
],
|
||||||
|
[t, plugins],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||||
return plugins.flatMap((plugin) => {
|
return plugins.flatMap((plugin) => {
|
||||||
const tile = (t: {
|
const tile = (t: {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { JobsIndicator } from './JobsIndicator';
|
|||||||
import { ThemeSwitcher } from './ThemeSwitcher';
|
import { ThemeSwitcher } from './ThemeSwitcher';
|
||||||
import { RescanButton } from '../Rescan/RescanButton';
|
import { RescanButton } from '../Rescan/RescanButton';
|
||||||
import { usePageTitle } from '@/state/usePageTitle';
|
import { usePageTitle } from '@/state/usePageTitle';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
||||||
@@ -19,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
|||||||
// the field hand the tab back to the route's own name.
|
// the field hand the tab back to the route's own name.
|
||||||
function EditablePageTitle() {
|
function EditablePageTitle() {
|
||||||
const [title, rename] = usePageTitle();
|
const [title, rename] = usePageTitle();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditableTitle
|
<EditableTitle
|
||||||
value={title}
|
value={title}
|
||||||
onCommit={rename}
|
onCommit={rename}
|
||||||
allowEmpty
|
allowEmpty
|
||||||
ariaLabel="Tab name"
|
ariaLabel={t('header.tabName')}
|
||||||
hint="Click to rename this tab — clear it to go back to the page name"
|
hint={t('header.tabNameHint')}
|
||||||
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
|
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
|
||||||
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
|
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
|
||||||
/>
|
/>
|
||||||
@@ -46,6 +49,8 @@ type HeaderProps = {
|
|||||||
export function Header({ dockItems, hidden }: HeaderProps) {
|
export function Header({ dockItems, hidden }: HeaderProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
|
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
|
||||||
const logo = useThemeAsset('--logo-src');
|
const logo = useThemeAsset('--logo-src');
|
||||||
|
|
||||||
@@ -79,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
|||||||
*/}
|
*/}
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Officer"
|
aria-label={t('header.logoAlt')}
|
||||||
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
|
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
|
||||||
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
|
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
|
||||||
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
|
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
|
||||||
@@ -100,9 +105,15 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
|||||||
*/}
|
*/}
|
||||||
{/* Bug Report — hidden */}
|
{/* Bug Report — hidden */}
|
||||||
{/* <BugReportButton /> */}
|
{/* <BugReportButton /> */}
|
||||||
|
<ThemeSwitcher />
|
||||||
|
{/* Owner only, and tidiness rather than access control — /jobs stays reachable and the rescan
|
||||||
|
endpoint is gated server-side, because a hidden button is not a permission. */}
|
||||||
|
{user?.role === 'Super Admin' && (
|
||||||
|
<>
|
||||||
<JobsIndicator />
|
<JobsIndicator />
|
||||||
<RescanButton />
|
<RescanButton />
|
||||||
<ThemeSwitcher />
|
</>
|
||||||
|
)}
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
|||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetContent side="left">
|
<SheetContent side="left">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Navigation</SheetTitle>
|
<SheetTitle>{t('header.navigation')}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className="flex flex-col gap-1 mt-4 px-2">
|
<nav className="flex flex-col gap-1 mt-4 px-2">
|
||||||
{dockItems.map((item) => (
|
{dockItems.map((item) => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
|
|||||||
import { Loader2, ListOrdered } from 'lucide-react';
|
import { Loader2, ListOrdered } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePermissions } from 'hooks/usePermissions';
|
import { usePermissions } from 'hooks/usePermissions';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type Counts = { running: number; runningJobId: string | null; queued: number };
|
type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
|
|||||||
export const JobsIndicator = () => {
|
export const JobsIndicator = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
const { t } = useTranslation();
|
||||||
const allowed = can('tasks');
|
const allowed = can('tasks');
|
||||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Link
|
<Link
|
||||||
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
|
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
|
||||||
title={counts.running ? 'Running job' : 'Nothing running'}
|
title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
|
||||||
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
|
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
|
||||||
>
|
>
|
||||||
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
|
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
|
||||||
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/jobs"
|
to="/jobs"
|
||||||
title="Queued jobs"
|
title={t('header.jobsQueued')}
|
||||||
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
|
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
|
||||||
>
|
>
|
||||||
<ListOrdered className="h-3.5 w-3.5" />
|
<ListOrdered className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Palette, Check } from 'lucide-react';
|
import { Check, Star } from 'lucide-react';
|
||||||
|
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import type { Theme } from '@/components/ui/ThemeProvider';
|
|
||||||
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
||||||
|
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -21,7 +24,7 @@ const {
|
|||||||
*
|
*
|
||||||
* ── The overlay ──
|
* ── The overlay ──
|
||||||
*
|
*
|
||||||
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||||
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
||||||
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
||||||
* you browse, so as little of it as possible should be covered.
|
* you browse, so as little of it as possible should be covered.
|
||||||
@@ -30,43 +33,123 @@ const {
|
|||||||
*/
|
*/
|
||||||
export const ThemeSwitcher = () => {
|
export const ThemeSwitcher = () => {
|
||||||
const { theme, themeId, setTheme, themes } = useTheme();
|
const { theme, themeId, setTheme, themes } = useTheme();
|
||||||
const { open, setOpen, focused, setFocused } = useThemeKeys();
|
const { t } = useTranslation();
|
||||||
|
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||||
|
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||||
|
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||||
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||||
|
|
||||||
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
|
/*
|
||||||
// readonly, so this builds mutable arrays rather than pushing into its own type.
|
One ordering, used by both lists.
|
||||||
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
|
|
||||||
(acc[t.family] ??= []).push(t);
|
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
||||||
return acc;
|
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
||||||
}, {});
|
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
||||||
|
`theme/rotate.ts`.
|
||||||
|
|
||||||
|
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
||||||
|
the order it was given, so the theme you are wearing still leads.
|
||||||
|
*/
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const rotated = rotateToSelected(themes, themeId);
|
||||||
|
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
||||||
|
return favouritesFirst(listed, favorites);
|
||||||
|
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||||
|
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||||
|
|
||||||
|
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
|
title={t('theme.pillTitle', {
|
||||||
aria-label="Theme"
|
name: theme.name,
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer"
|
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
|
||||||
|
})}
|
||||||
|
aria-label={t('theme.pillLabel', { name: theme.name })}
|
||||||
|
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
||||||
|
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
||||||
|
// the cluster stays one shape.
|
||||||
|
className="flex h-8 cursor-pointer items-center rounded-full bg-black/25 px-2.5 text-xs font-semibold transition-colors hover:bg-black/35"
|
||||||
style={{ color: 'var(--header-fg)' }}
|
style={{ color: 'var(--header-fg)' }}
|
||||||
>
|
>
|
||||||
<Palette className="h-4 w-4" />
|
{theme.name}
|
||||||
|
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
||||||
|
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
||||||
|
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
|
{/*
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
The CONTENT does not scroll — the list inside it does.
|
||||||
ctrl+shift+←/→ · ctrl+shift+space
|
|
||||||
|
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
||||||
|
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
||||||
|
visible for the first half-second and then gone for good, which is the same as not having written
|
||||||
|
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
||||||
|
*/}
|
||||||
|
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
||||||
|
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
||||||
|
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
||||||
|
<span className="block">{t('theme.cycleHint')}</span>
|
||||||
|
<span className="block">{t('theme.everyShortcutHint')}</span>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<button
|
||||||
{Object.entries(families).map(([family, entries]) => (
|
type="button"
|
||||||
|
onClick={(ev) => {
|
||||||
|
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
setOnlyFavorites((was) => !was);
|
||||||
|
}}
|
||||||
|
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
||||||
|
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||||
|
{onlyFavorites
|
||||||
|
? t('theme.favouritesOnly', { count: favorites.size })
|
||||||
|
: t('theme.favourites', { count: favorites.size })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||||
|
{ordered.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
|
||||||
|
) : (
|
||||||
|
families.map(([family, entries]) => (
|
||||||
<div key={family}>
|
<div key={family}>
|
||||||
{entries.map((t) => (
|
{entries.map((entry) => (
|
||||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
|
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
|
||||||
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
<Check
|
||||||
{t.name}
|
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||||
|
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={
|
||||||
|
isFavorite(entry.id)
|
||||||
|
? t('theme.unstarNamed', { name: entry.name })
|
||||||
|
: t('theme.starNamed', { name: entry.name })
|
||||||
|
}
|
||||||
|
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggle(entry.id);
|
||||||
|
}}
|
||||||
|
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
|
||||||
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
@@ -87,31 +170,64 @@ export const ThemeSwitcher = () => {
|
|||||||
}}
|
}}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ul className="flex flex-col gap-1">
|
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
||||||
{themes.map((t) => {
|
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||||
const isFocused = t.id === focused;
|
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||||
|
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||||
|
{ordered.map((entry) => {
|
||||||
|
const isFocused = entry.id === focused;
|
||||||
return (
|
return (
|
||||||
<li key={t.id}>
|
<li key={entry.id}>
|
||||||
<button
|
<button
|
||||||
onMouseEnter={() => setFocused(t.id)}
|
onMouseEnter={() => setFocused(entry.id)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTheme(t.id);
|
setTheme(entry.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||||
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.name}
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||||
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
|
{entry.id === themeId && (
|
||||||
|
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
|
||||||
|
)}
|
||||||
|
{/*
|
||||||
|
A span, not a button: this sits inside the row's own <button>, and a button inside
|
||||||
|
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
||||||
|
handler and the role are set explicitly instead, and `f` does the same thing from
|
||||||
|
the keyboard.
|
||||||
|
*/}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={
|
||||||
|
isFavorite(entry.id)
|
||||||
|
? t('theme.unstarNamed', { name: entry.name })
|
||||||
|
: t('theme.starNamed', { name: entry.name })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
||||||
|
}
|
||||||
|
onClick={(ev) => {
|
||||||
|
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||||
|
// the opposite of what starring is for.
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggle(entry.id);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-6 text-center text-xs opacity-60">
|
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
||||||
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
|
header does. */}
|
||||||
</p>
|
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ export function UserMenu() {
|
|||||||
<DropdownMenuContent align="end" className="z-10 w-48">
|
<DropdownMenuContent align="end" className="z-10 w-48">
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/profile">
|
<Link to="/settings/profile">
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="me-2 h-4 w-4" />
|
||||||
{t('header.userMenu.profile')}
|
{t('header.userMenu.profile')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/system">
|
<Link to="/settings/system">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="me-2 h-4 w-4" />
|
||||||
{t('header.userMenu.systemSettings')}
|
{t('header.userMenu.systemSettings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -42,14 +42,14 @@ export function UserMenu() {
|
|||||||
(navigation-audit M8.) */}
|
(navigation-audit M8.) */}
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/ai">
|
<Link to="/settings/ai">
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
<Bot className="me-2 h-4 w-4" />
|
||||||
AI
|
{t('header.userMenu.ai')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/integrations">
|
<Link to="/settings/integrations">
|
||||||
<Puzzle className="mr-2 h-4 w-4" />
|
<Puzzle className="me-2 h-4 w-4" />
|
||||||
Integrations
|
{t('header.userMenu.integrations')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{/* Owner only. This is tidiness, not access control — the route stays reachable and its
|
{/* Owner only. This is tidiness, not access control — the route stays reachable and its
|
||||||
@@ -58,15 +58,15 @@ export function UserMenu() {
|
|||||||
{user?.role === 'Super Admin' && (
|
{user?.role === 'Super Admin' && (
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/user-management">
|
<Link to="/settings/user-management">
|
||||||
<UserCog className="mr-2 h-4 w-4" />
|
<UserCog className="me-2 h-4 w-4" />
|
||||||
User Management
|
{t('header.userMenu.userManagement')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/auth/signout">
|
<Link to="/auth/signout">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="me-2 h-4 w-4" />
|
||||||
{t('header.userMenu.signOut')}
|
{t('header.userMenu.signOut')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePermissions } from 'hooks/usePermissions';
|
import { usePermissions } from 'hooks/usePermissions';
|
||||||
|
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
||||||
|
|
||||||
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One key per counted noun rather than one sentence with six numbers in it.
|
||||||
|
*
|
||||||
|
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
|
||||||
|
* more than two categories needs it for every value. Assembling the sentence in code and translating only
|
||||||
|
* the frame would have hardcoded English plural rules into the frame.
|
||||||
|
*
|
||||||
|
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
|
||||||
|
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
|
||||||
|
*/
|
||||||
|
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
|
||||||
|
['tasks', 'rescan.counts.tasks'],
|
||||||
|
['agents', 'rescan.counts.agents'],
|
||||||
|
['tools', 'rescan.counts.tools'],
|
||||||
|
['skills', 'rescan.counts.skills'],
|
||||||
|
['processes', 'rescan.counts.processes'],
|
||||||
|
['affordances', 'rescan.counts.affordances'],
|
||||||
|
];
|
||||||
|
|
||||||
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
|
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
|
||||||
// Automation and Capability pages).
|
// Automation and Capability pages).
|
||||||
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
|
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
|
||||||
@@ -15,6 +35,7 @@ export function RescanButton() {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
|
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
|
||||||
@@ -32,9 +53,8 @@ export function RescanButton() {
|
|||||||
// you next open that page. This refreshes every item cache immediately, wherever you press it.
|
// you next open that page. This refreshes every item cache immediately, wherever you press it.
|
||||||
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
|
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
|
||||||
const c = res.counts ?? {};
|
const c = res.counts ?? {};
|
||||||
toast.success(
|
const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
|
||||||
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
|
toast.success(t('rescan.done', { summary }), {
|
||||||
{
|
|
||||||
// Two things said here rather than left to be discovered.
|
// Two things said here rather than left to be discovered.
|
||||||
//
|
//
|
||||||
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
|
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
|
||||||
@@ -45,19 +65,16 @@ export function RescanButton() {
|
|||||||
// disagreeing where nobody is comparing them.
|
// disagreeing where nobody is comparing them.
|
||||||
description:
|
description:
|
||||||
[
|
[
|
||||||
c.affordances || c.skillsOffered
|
c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
|
||||||
? 'Applies to new chats — an open one keeps what it started with.'
|
|
||||||
: null,
|
|
||||||
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
|
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
|
||||||
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.`
|
? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ') || undefined,
|
.join(' ') || undefined,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to rescan items');
|
toast.error(t('rescan.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -68,8 +85,8 @@ export function RescanButton() {
|
|||||||
onClick={rescan}
|
onClick={rescan}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
|
||||||
aria-label="Rescan items"
|
aria-label={t('rescan.label')}
|
||||||
title="Rescan officer-items (apply changes without restarting)"
|
title={t('rescan.title')}
|
||||||
>
|
>
|
||||||
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
|
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
|||||||
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
|
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
|
|
||||||
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
|
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-xl font-semibold">This screen failed to render</h1>
|
<h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
|
||||||
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
|
|
||||||
untouched.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="bg-muted/40 rounded-md border p-3 text-sm">
|
<details className="bg-muted/40 rounded-md border p-3 text-sm">
|
||||||
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
|
<summary className="cursor-pointer select-none font-medium">
|
||||||
|
{error.message || t('screenError.unknownError')}
|
||||||
|
</summary>
|
||||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
|
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={reset}>Try again</Button>
|
<Button onClick={reset}>{t('screenError.tryAgain')}</Button>
|
||||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||||
Reload
|
{t('screenError.reload')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" asChild>
|
<Button variant="secondary" asChild>
|
||||||
<Link to="/">Go home</Link>
|
<Link to="/">{t('screenError.goHome')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
|
{t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
|
||||||
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
|
|
||||||
</p>
|
</p>
|
||||||
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
|
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
|
||||||
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
|
{resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Keyboard } from 'lucide-react';
|
||||||
|
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
|
/*
|
||||||
|
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
||||||
|
|
||||||
|
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
|
||||||
|
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
|
||||||
|
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
|
||||||
|
interesting part is `state/shortcuts.ts`.
|
||||||
|
|
||||||
|
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
|
||||||
|
controls grid, and for the same reason: these are short lists that read badly as one long column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Row = ({ keys, description }: { keys: string; description: string }) => (
|
||||||
|
<div className="flex items-baseline gap-3 py-1">
|
||||||
|
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
|
||||||
|
putting them on the left. */}
|
||||||
|
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
|
||||||
|
{keys}
|
||||||
|
</kbd>
|
||||||
|
<span className="text-sm text-muted-foreground">{description}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ShortcutsModal = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
|
if (isShortcutsChord(ev)) {
|
||||||
|
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
|
||||||
|
// composer is a help list you cannot open — the lesson from the Spotlight guard.
|
||||||
|
ev.preventDefault();
|
||||||
|
setOpen((was) => !was);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'Escape' && open) setOpen(false);
|
||||||
|
};
|
||||||
|
// Capture, so a panel with its own key handling cannot swallow it first.
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
|
||||||
|
onMouseDown={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
||||||
|
onMouseDown={(ev) => ev.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
||||||
|
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
|
||||||
|
<span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||||
|
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
||||||
|
{SHORTCUT_SECTIONS.map((section) => (
|
||||||
|
<section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
{t(section.titleKey)}
|
||||||
|
</h3>
|
||||||
|
{section.noteKey && (
|
||||||
|
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
{section.shortcuts.map((shortcut) => (
|
||||||
|
<Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { rank } from './match';
|
import { rank } from './match';
|
||||||
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
// Spotlight: type a few letters, go somewhere.
|
// Spotlight: type a few letters, go somewhere.
|
||||||
//
|
//
|
||||||
@@ -11,34 +12,71 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
|||||||
//
|
//
|
||||||
// ── Keyboard first, and the trigger ──
|
// ── Keyboard first, and the trigger ──
|
||||||
//
|
//
|
||||||
// `shift-space`. The two obvious chords were both taken: `ctrl-space` focuses the address bar in Brave,
|
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
|
||||||
// and `alt-space` is load-bearing for the owner elsewhere. `cmd-k` belongs to the browser on some
|
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
|
||||||
// platforms and to text fields on others.
|
|
||||||
//
|
//
|
||||||
// **Shift-space is a real key combination inside a text field** — it types a space, and people hold shift
|
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
|
||||||
// through one more often than they think while typing capitals. So the trigger is IGNORED when focus is
|
// preventDefault would win. It does not — the key never reaches this handler.
|
||||||
// in an input, a textarea or anything contenteditable, and the space is typed as normal.
|
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
|
||||||
|
// would rather move the app than the window manager.
|
||||||
|
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
|
||||||
//
|
//
|
||||||
// The cost of that is worth stating: focus usually IS in the chat composer, so opening Spotlight from
|
// ── Shift-space overrides two things, on purpose ──
|
||||||
// there means clicking out first. If that turns out to be the common case, a double-tap of shift is the
|
//
|
||||||
// escape hatch — it conflicts with nothing anywhere.
|
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
|
||||||
|
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
|
||||||
|
// and typing a space is what the unmodified spacebar is for.
|
||||||
|
//
|
||||||
|
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
|
||||||
|
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
|
||||||
|
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
|
||||||
|
// what made the launcher unopenable from the composer, where it is most wanted.
|
||||||
|
//
|
||||||
|
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
|
||||||
|
// is the argument for making them configurable, not for choosing better.
|
||||||
|
//
|
||||||
|
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
|
||||||
|
// combination inside a text field: it types a space, and people hold shift through one more often than
|
||||||
|
// they think while typing capitals. Guarding it meant IGNORING the trigger whenever focus was in an input
|
||||||
|
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
|
||||||
|
// A launcher you have to prepare for is not a launcher.
|
||||||
|
//
|
||||||
|
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
|
||||||
|
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
|
||||||
|
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
|
||||||
|
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
|
||||||
|
//
|
||||||
|
// Nothing else opens it. There is deliberately no second chord — two triggers for one thing means two
|
||||||
|
// things to collide with, and this one moved twice already.
|
||||||
//
|
//
|
||||||
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
|
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
|
||||||
|
//
|
||||||
|
// ── Sizing ──
|
||||||
|
//
|
||||||
|
// Loosened 2026-08-22: the first pass reused the app's list metrics (7px tile, text-sm, py-2) and read as
|
||||||
|
// cramped, because this is not a list inside a panel — it is a modal you look at for two seconds with
|
||||||
|
// nothing else competing for the eye. So the search input leads at `text-base`, rows are `py-2.5` with a
|
||||||
|
// 9px tile, and the label is `text-[15px]` with the hint and path kept deliberately subordinate.
|
||||||
|
//
|
||||||
|
// The panel widened with them (560 → 640). Rows now carry a label, a hint and a route on one line, and the
|
||||||
|
// route is the first thing to be squeezed out — it is also the thing that disambiguates two rows with the
|
||||||
|
// same name.
|
||||||
|
//
|
||||||
|
// The results box grew 52vh → 60vh for the arithmetic reason: taller rows in the same box show fewer
|
||||||
|
// results, and the cap on how many are listed was removed for a reason.
|
||||||
|
|
||||||
const MAX_RESULTS = 8;
|
/*
|
||||||
|
No cap, deliberately, and it used to be 8.
|
||||||
|
|
||||||
/**
|
Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock
|
||||||
* Is the user typing into something?
|
carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the
|
||||||
*
|
list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to
|
||||||
* `shift-space` types a space in any text field, so the trigger has to stand down there or it eats a
|
type it. A launcher you can only use for things you can already name is not doing the job.
|
||||||
* keystroke people make by accident all day. `isContentEditable` covers the editor and any rich input a
|
|
||||||
* panel brings with it.
|
The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a
|
||||||
|
layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a
|
||||||
|
cap becomes indistinguishable from a missing feature.
|
||||||
*/
|
*/
|
||||||
function isTyping(target: EventTarget | null): boolean {
|
|
||||||
const el = target as HTMLElement | null;
|
|
||||||
if (!el || !el.tagName) return false;
|
|
||||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
|
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
|
||||||
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
|
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
|
||||||
@@ -71,8 +109,15 @@ const Row = ({
|
|||||||
onPick: () => void;
|
onPick: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
// Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the
|
||||||
|
// row is actually out of sight, so arrowing through the visible middle of the list does not jump it.
|
||||||
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [active]);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
|
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
|
||||||
// overlay on the blur handler before the navigation ever ran.
|
// overlay on the blur handler before the navigation ever ran.
|
||||||
@@ -80,29 +125,52 @@ const Row = ({
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
onPick();
|
onPick();
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
|
className={`flex w-full items-center gap-3.5 rounded-lg px-3.5 py-2.5 text-left transition-colors ${
|
||||||
active ? 'bg-accent' : 'hover:bg-accent/50'
|
active ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
|
||||||
|
these are the SAME things and a second visual language for them is what made this list read as
|
||||||
|
somebody else's menu:
|
||||||
|
|
||||||
|
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
|
||||||
|
invisible, which is why the fill is not decoration
|
||||||
|
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
|
||||||
|
mistake, and every plugin shipping its own art is this case
|
||||||
|
|
||||||
|
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
|
||||||
|
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
||||||
|
*/}
|
||||||
<span
|
<span
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
||||||
style={item.color ? { backgroundColor: `${item.color}22`, color: item.color } : undefined}
|
style={{
|
||||||
|
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
||||||
|
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
||||||
|
color: item.color ? '#fff' : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.image ? (
|
{item.image ? (
|
||||||
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt=""
|
||||||
|
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
|
||||||
|
/>
|
||||||
) : Icon ? (
|
) : Icon ? (
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className={`h-[18px] w-[18px] ${item.color ? '' : 'text-muted-foreground'}`} />
|
||||||
) : (
|
) : (
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-[18px] w-[18px] text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm text-foreground">
|
<span className="block truncate text-[15px] leading-snug text-foreground">
|
||||||
<Highlighted label={item.label} hits={hits} />
|
<Highlighted label={item.label} hits={hits} />
|
||||||
</span>
|
</span>
|
||||||
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null}
|
{item.hint ? (
|
||||||
|
<span className="block truncate text-[13px] leading-snug text-muted-foreground">{item.hint}</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">{item.to}</span>
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground/60">{item.to}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -114,8 +182,22 @@ export const Spotlight = () => {
|
|||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const items = useSpotlightItems();
|
const items = useSpotlightItems();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [items, query]);
|
const results = useMemo(() => rank(items, query), [items, query]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The window handler reads these through refs.
|
||||||
|
|
||||||
|
It has to: the listener is registered once per open, and closing over `results` and `active` would pin
|
||||||
|
it to their values at registration — so arrowing down would read a stale list and Enter would open
|
||||||
|
whatever was first when the panel appeared. Adding them to the effect's deps instead would tear down and
|
||||||
|
re-register the listener on every keystroke, which is the same bug wearing a costume.
|
||||||
|
*/
|
||||||
|
const resultsRef = useRef(results);
|
||||||
|
resultsRef.current = results;
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -136,16 +218,51 @@ export const Spotlight = () => {
|
|||||||
// anywhere — including inside a panel that has its own key handling.
|
// anywhere — including inside a panel that has its own key handling.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (ev: KeyboardEvent) => {
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
if (ev.shiftKey && ev.code === 'Space' && !isTyping(ev.target)) {
|
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
|
||||||
|
// the physical key is `Space` everywhere.
|
||||||
|
//
|
||||||
|
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
|
||||||
|
// both is the bug that took a capture-phase listener to fix on the theme side.
|
||||||
|
//
|
||||||
|
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
|
||||||
|
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
|
||||||
|
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
|
||||||
|
// below covers the only thing the guard was protecting against — a layout that would otherwise type
|
||||||
|
// a character.
|
||||||
|
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
|
||||||
|
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
|
||||||
|
// that sometimes did nothing: focus stayed in the composer, so ArrowDown went to the textarea and
|
||||||
|
// the launcher never saw it. Blurring the outgoing element is one line and removes the race rather
|
||||||
|
// than narrowing it.
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
setOpen((wasOpen) => !wasOpen);
|
setOpen((wasOpen) => !wasOpen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'Escape' && open) close();
|
if (!open) return;
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Navigation is handled HERE rather than on the input, so it works no matter what has focus — a
|
||||||
|
// panel that grabbed it back, a blur that lost the race, an element with its own key handling. The
|
||||||
|
// input's own onKeyDown used to own this, which made every keystroke conditional on focus having
|
||||||
|
// landed where we expected.
|
||||||
|
if (ev.key === 'ArrowDown') {
|
||||||
|
ev.preventDefault();
|
||||||
|
setActive((i) => Math.min(i + 1, resultsRef.current.length - 1));
|
||||||
|
} else if (ev.key === 'ArrowUp') {
|
||||||
|
ev.preventDefault();
|
||||||
|
setActive((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
pick(resultsRef.current[activeRef.current]?.item);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [open, close]);
|
}, [open, close, pick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) inputRef.current?.focus();
|
if (open) inputRef.current?.focus();
|
||||||
@@ -162,36 +279,24 @@ export const Spotlight = () => {
|
|||||||
onMouseDown={close}
|
onMouseDown={close}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-[min(92vw,560px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
className="w-[min(92vw,640px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
||||||
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
|
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
|
||||||
onMouseDown={(ev) => ev.stopPropagation()}
|
onMouseDown={(ev) => ev.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4">
|
<div className="flex items-center gap-2 border-b border-border px-4">
|
||||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Search className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
onChange={(ev) => setQuery(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
placeholder={t('spotlight.placeholder')}
|
||||||
if (ev.key === 'ArrowDown') {
|
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
ev.preventDefault();
|
|
||||||
setActive((i) => Math.min(i + 1, results.length - 1));
|
|
||||||
} else if (ev.key === 'ArrowUp') {
|
|
||||||
ev.preventDefault();
|
|
||||||
setActive((i) => Math.max(i - 1, 0));
|
|
||||||
} else if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
pick(results[active]?.item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Go to…"
|
|
||||||
className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[52vh] overflow-y-auto p-2">
|
<div className="max-h-[60vh] overflow-y-auto p-2">
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">{t('spotlight.noResults', { query })}</p>
|
||||||
) : (
|
) : (
|
||||||
results.map((result, i) => (
|
results.map((result, i) => (
|
||||||
<Row
|
<Row
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user