Compare commits

..

30 Commits

Author SHA1 Message Date
sulthan e064a9ea09 Merge #173: The page explains every fault the message names 2026-08-23 11:27:50 +07:00
sulthan 8e6f4d2053 review: fix overviewView docstring to document fail-open fault reads 2026-08-23 11:26:30 +07:00
sulthan 820f57a7fa #173: page verdict shares FaultsFrom judgement so push and landing cannot disagree 2026-08-23 11:24:09 +07:00
sulthan a7bbeb50e5 Merge #172: The other three conditions the rule selects 2026-08-23 02:49:07 +07:00
sulthan c655586202 #172: clear the empty-Site suppression row only for sidecar-down
Only sidecar-down ever writes a Site=='' row, so the loop's empty-Site
clear for the other three conditions was a no-op delete on every pass.
Move it out of the per-condition loop, scoped to ConditionSidecarDown.
2026-08-23 02:48:14 +07:00
sulthan b58945894d #172: floor sidecar-down's age at the window when no Lane ever reached the sidecar
A fresh database whose browser Lanes skipped from day one has no
sidecar-reaching pass, so sidecarDownSince returned Since 0 and the notice
rendered the age since the epoch. Report the window itself: 'at least
twelve hours' is the honest floor the condition guarantees.
2026-08-23 02:47:21 +07:00
sulthan 28fef689ec #172: three more owner-notice conditions: no-browser-route, sidecar-down, adapter-broken
FaultsFrom judges three more faults at the shared twelve-hour OwnerWindow:
a no-browser-route Site's refusing run (browser-backed Sites excluded), a
sidecar no Lane has reached (one site-wide fault), and a Site where more
than half of Series hold an old no-chapter failure row. recordPass fills
the inputs from three new store reads (RefusingSince, SidecarOK,
NoChapterShare), each failing independently and never failing a pass, and
the clear loop forgets the empty-Site row too so a lifted sidecar-down
fires again on return.
2026-08-23 02:44:45 +07:00
sulthan 2861f055b4 Merge #170: The completion hint reaches the owner, and decides nothing
# Conflicts:
#	backend/internal/web/admin_series_detail.go
2026-08-23 00:53:29 +07:00
sulthan 08ffd1f93c Merge #167: A failure names itself on the page 2026-08-23 00:52:58 +07:00
sulthan f85908d3b4 web: a failure names itself on the page (issue #167)
The Series list row's fact line gains the failure word and its age in one
mark before the Notes chips, spelled through outcomeWord — the one
vocabulary shared with the Lanes chips. The detail page adds one sentence
beside the correction control while a Reader's number stands behind a
failure past the owner window, without naming the Reader. Each Lanes Site
name is a plain link to that Site's failing Series; the outcome chips stay
unlinked, and outcomeChips records why the promise is withdrawn.
2026-08-23 00:36:12 +07:00
sulthan a533e4f769 web: site-completed hint reaches the owner, decides nothing (#170)
The Site's own completed marker (issue #168) joins the owner's surface as
an informational hint, never a decision:

- store: SeriesFilterSiteCompleted ('site_completed') sits beside Finished
  in the filter vocabulary; its predicate carries the same finished_at
  guard as the clock-driven filters, because the Site's stamp keeps
  standing after the owner retires the row. The projection and SeriesPage
  GROUP BY carry site_completed_at so the detail page can age it.
- web: the filter joins the Series list select as 'Site says completed'
  and rides the tail of seriesFilterOrder, so the landing stats figure
  comes for free and sits after the finished figure.
- detail: one hint line beside the Finish control - 'the site says this
  work is completed (since 3d ago)' - rendered only while the stamp
  stands and no Finish stands; the Finish control itself is untouched.
- tests: filter's finished exclusion and un-finish return to the Poll
  query (store); figure at zero and nonzero, hint present/absent, and the
  Finish control's markup byte-identical with a hint present (web).
2026-08-23 00:35:10 +07:00
sulthan 3d746092ab Merge #171: Outbound owner notification, and the stall it exists for 2026-08-23 00:12:03 +07:00
sulthan 4bb26c6225 Merge #166: Two new filters: failing, and the subset nothing stands behind 2026-08-23 00:12:00 +07:00
sulthan 6af8859c70 Review fixes: compose duplicate depends_on, .env.example splice, restore COVER_DIR test (#171) 2026-08-23 00:10:49 +07:00
sulthan 7b22460f5e Owner notices: the stall, one webhook path from env to Discord (#171)
Rollout note: the owner_notices table starts empty, so the first pass after deploy sends for conditions already true — correct per one-row-per-episode; say so rather than have it reported as a bug. Prod step: create the webhook, set DISCORD_WEBHOOK_URL on the deployment, redeploy — unset is silent by design, and without that step the feature ships dark. Security invariants preserved: the webhook address is a secret in the class of TOKEN_KEY (never logged, never on a config-printing line), and the owner gate is unchanged.
2026-08-23 00:10:45 +07:00
sulthan a560fc76d3 Update stale Cutoff comments: the failing pair reads it too (#166) 2026-08-22 23:46:19 +07:00
sulthan 8a56907558 Two new Series filters: failing, and the unverified Reader subset (#166) 2026-08-22 23:43:47 +07:00
sulthan 5a32943528 Merge #169: The pass remembers when a Site called a work completed
# Conflicts:
#	backend/internal/latest/poller_test.go
2026-08-22 23:27:29 +07:00
sulthan 4d6773692d Merge #165: poll_failures: the row is the failure state 2026-08-22 23:26:40 +07:00
sulthan caabdff15b Tidy blank line before RecordSeriesFailure (#165) 2026-08-22 23:25:26 +07:00
sulthan 417f809d75 Record poll failures as rows: the row is the failure state (#165) 2026-08-22 23:22:57 +07:00
sulthan 86da47850c Address review nits on #169: stamp name, migration comment (#169) 2026-08-22 23:18:45 +07:00
sulthan b517858619 Remember when a Site called a work completed (#169)
Add series.site_completed_at (epoch-ms, default 0): the durable answer
#168's predicates compute. checkOne writes it after the successful read
on the zero/non-zero transition only, so a Series still completed keeps
its original stamp (the age #170 prints is 'since the Site first said
so'), one that stopped is zeroed, one that became completed is stamped.
The due query projects the column, so the transition is a field
comparison on the snapshot checkOne already holds.

Refused, unreachable and errored reads reach nothing (AC4 is placement,
not a guard); the write sits before the no-chapter and unchanged-number
returns, so a completed page whose chapter did not change still writes.
Store failure logs and carries on — the outcome word never changes.

Series-level like Latest Chapter: a bookmark's updated_at is never
touched. #170 is the surface; nothing reads the column yet.
2026-08-22 23:15:46 +07:00
sulthan 4708703044 Merge #168: All six Sites' completed marker, read by their adapters 2026-08-22 18:27:04 +07:00
sulthan 7ef9f8c433 Merge #164: A sixth outcome word: not_found splits out of errors 2026-08-22 18:27:04 +07:00
sulthan b38dfe54e8 Record per-site probe provenance in completed fixtures (#168)
Spec review flagged the live-verification acceptance criterion as
unproven in the diff: the brief's adapter rule wants the probe
recorded, not just the date. Each fixture comment now names how the
live body was fetched on 2026-08-22 — curl probe for asura,
demonic and lightnovelworld; cleared Chrome tab (CDP sidecar) for
comix, kagane and novelfull, including the challenge/403 fallback
story for novelfull. Also renamed lnwCompletedRe to lnwStatusRe for
symmetry with the other site-marker vars.
2026-08-22 18:26:34 +07:00
sulthan f6782e212a Note comix key-encoding fidelity in fixture comments (#168) 2026-08-22 18:24:58 +07:00
sulthan e69e513be4 Add completed markers for all six sites (#168)
site gains a Completed predicate answering whether a fetched body
carries the Site's own completed value: asura escaped props status,
demonic info-block Status pair, comix scoped detail entry (shared
comixDetailQuery helper with the cover read), kagane
publication_status only, novelfull status link, lightnovelworld
JSON-LD creativeWorkStatus. siteCompletedFrom dispatches it; an
absent hint stays false. Fixtures trimmed from live pages fetched
2026-08-22; TestSiteCompletedFrom covers completed, ongoing,
selector-removed, and challenge bodies per site, the kagane
upload/publication divergence, and an unknown site.
2026-08-22 18:23:42 +07:00
sulthan f4e4055a13 Review: drop duplicate 404 log, scope comment wording to 4xx (Spec #164) 2026-08-22 18:22:03 +07:00
sulthan 62c2cea74e Spec #164: A sixth outcome word: not_found splits out of errors 2026-08-22 18:19:23 +07:00
5 changed files with 5 additions and 69 deletions
-9
View File
@@ -50,15 +50,6 @@ Backend (`cd backend`):
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP). Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
**Clean up Docker after testing.** Storage on the dev machine is scarce, so
anything you started for a test you also tear down before calling the work
done: `docker compose down -v` for a stack you brought up, `docker rm -f -v`
for a container you ran by hand (`-v`, or the image's anonymous data volume
survives). Then check `docker volume ls` and `docker system df` for leftovers
and reclaim them — a dangling volume nobody notices is the leak that fills the
disk. Never remove the `postgres-data` volume of a stack the user is actually
running, and never blanket-`docker system prune` their images or build cache.
Live CDP proof (needs that browser and network, skipped otherwise): Live CDP proof (needs that browser and network, skipped otherwise):
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest` `SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is — fetches a real kagane and comix cover and chapter list. A red run means the challenge is
+3 -5
View File
@@ -40,9 +40,7 @@ func Main(m *testing.M) int {
fmt.Println("pgtest:", err) fmt.Println("pgtest:", err)
return 1 return 1
} }
// -v: the postgres image declares a VOLUME, so an explicit rm without it defer exec.Command("docker", "rm", "-f", id).Run()
// leaves the anonymous data volume behind on every test run.
defer exec.Command("docker", "rm", "-f", "-v", id).Run()
adminURL = url adminURL = url
return m.Run() return m.Run()
@@ -87,7 +85,7 @@ func start() (id, url string, err error) {
port, err := exec.Command("docker", "port", id, "5432/tcp").Output() port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
if err != nil { if err != nil {
exec.Command("docker", "rm", "-f", "-v", id).Run() exec.Command("docker", "rm", "-f", id).Run()
return "", "", fmt.Errorf("docker port: %w", err) return "", "", fmt.Errorf("docker port: %w", err)
} }
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want. // "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
@@ -96,7 +94,7 @@ func start() (id, url string, err error) {
first[strings.LastIndex(first, ":")+1:]) first[strings.LastIndex(first, ":")+1:])
if err := waitReady(url); err != nil { if err := waitReady(url); err != nil {
exec.Command("docker", "rm", "-f", "-v", id).Run() exec.Command("docker", "rm", "-f", id).Run()
return "", "", err return "", "", err
} }
return id, url, nil return id, url, nil
+2 -5
View File
@@ -505,13 +505,10 @@ func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) {
band = 1 band = 1
} }
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
// The heading is an out-of-band append to a response whose status line has
// already gone out with the row, so it is executed straight onto w —
// h.render would send a second WriteHeader.
if head, err := h.seriesListHeadView(r); err != nil { if head, err := h.seriesListHeadView(r); err != nil {
log.Printf("series remove %s: %v", site+":"+seriesID, err) log.Printf("series remove %s: %v", site+":"+seriesID, err)
} else if err := h.tmpl.ExecuteTemplate(w, "series-list-head", head); err != nil { } else {
log.Printf("series remove %s: render series-list-head oob: %v", site+":"+seriesID, err) h.render(w, http.StatusOK, "series-list-head", head)
} }
} }
-48
View File
@@ -1,13 +1,11 @@
package main package main
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -4287,52 +4285,6 @@ func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
} }
} }
// The removal answers two fragments on one response. The second is an
// out-of-band append, so it must not send a second status line: net/http
// answers a double WriteHeader with "superfluous response.WriteHeader call"
// on the server's error log, which a ResponseRecorder never sees. Hence a
// real server here.
func TestRemoveFromListSendsOneStatusLine(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
var errLog bytes.Buffer
srv := httptest.NewUnstartedServer(newRouter(st, testConfig()))
srv.Config.ErrorLog = log.New(&errLog, "", 0)
srv.Start()
defer srv.Close()
req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/series/asura:a/remove",
strings.NewReader("filter=no_readers&band=0"))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("removal request: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("removal status = %d, want 200", resp.StatusCode)
}
// Both fragments still travel: the fix must not have dropped the heading.
if !strings.Contains(string(body), "Title of asura:a") ||
!strings.Contains(string(body), `hx-swap-oob="true"`) {
t.Errorf("answer lost a fragment:\n%s", body)
}
if strings.Contains(errLog.String(), "superfluous") {
t.Errorf("removal wrote the status line twice: %s", errLog.String())
}
}
// A removal from the detail page navigates to the No-Readers list: htmx gets // A removal from the detail page navigates to the No-Readers list: htmx gets
// a full navigation (HX-Redirect — a 303 would be followed by the request // a full navigation (HX-Redirect — a 303 would be followed by the request
// and the list page swapped into the press's target), plain clients the 303 // and the list page swapped into the press's target), plain clients the 303
-2
View File
@@ -75,8 +75,6 @@ services:
# Host header is an IP address or "localhost" — confirmed 2026-08-03, # Host header is an IP address or "localhost" — confirmed 2026-08-03,
# independent of chromedp's own dial logic. The same trap that used to # independent of chromedp's own dial logic. The same trap that used to
# force a pinned Docker IP now forbids the tailnet name. # force a pinned Docker IP now forbids the tailnet name.
BROWSER_WS_URL: ${BROWSER_WS_URL:-}
#
# Owner-notice webhook (issue #171). Empty default, never a # Owner-notice webhook (issue #171). Empty default, never a
# required-guard: unset means the whole path is off, so a local stack # required-guard: unset means the whole path is off, so a local stack
# runs exactly as it does today. An env var not listed here never # runs exactly as it does today. An env var not listed here never