Skip to content

Go Philo Optic#1

Open
Phoscur wants to merge 25 commits into
mainfrom
a0
Open

Go Philo Optic#1
Phoscur wants to merge 25 commits into
mainfrom
a0

Conversation

@Phoscur

@Phoscur Phoscur commented Jul 1, 2026

Copy link
Copy Markdown
Owner

No description provided.

Phoscur and others added 25 commits July 3, 2026 14:50
PLAN.md captures the agreed roadmap: philo-optic as a single-flight camera
daemon (Philo's "eyes"), orchestration staying in TS, Telegram now / Matrix as
Phase 3 C2, and a later cadence-in-Go milestone. Documents the WIP compile
breakage on this branch and the warn+copy-forward capture policy (fill a gap
with the previous frame up to 5 times, then abort).

SOUL.md grounds Philo's persona and architecture: agent-vs-eyes split, no sleep
(that is Phedora), astronomical rhythm, single-flight zero-interference, and the
real feature set summarized from FEATURES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish the seams the atomic-folder refactor left dangling so the branch builds,
lints clean, and tests pass:

- Director.photo uses stateless camera.photo(); director.timelapse() creates the
  atomic event folder + its public repo and returns { events, repo }. Removes the
  {} as Repo placeholder in scheduleSunset and the deleted setupPrivateRepo/
  repoTimelapse calls in Producer; restores cancelSunset().
- Drop the half-baked source/ subfolder: frames and the rendered mp4 live in the
  one event folder (Repo.upload / makeTimelapsePage / ffmpeg expect them together).
- Frame naming: .jpg is now added in the Timelapse layer (previously supplied by
  the old stateful Camera.filename); the file event emits the bare filename.

SOUL capture policy (warn, never abort on a hiccup): a failed capture is filled
by copying the previous good frame so the numbered sequence stays gap-free (ffmpeg
truncates at a real hole) and the rhythm continues. Up to 5 copies in a row are
tolerated; a real capture resets the streak; the 6th consecutive failure ends the
run. Frame 1 retries in place. timelapse-frames.test.ts rewritten accordingly and
switched to real tiny intervals (fake timers were fragile with the drift loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stakeholder.getMissingFrames/fixFrames does retroactively what the live loop now
does: fill holes by copying the previous frame. Adds a plan section to repair the
last year of dropped-frame holes and re-render complete videos, incl. resolving
the 540-vs-541 expected-count discrepancy and wiring a real fix command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
541 is the total file count of a complete event folder (540 frames + README.md),
not the max frame number. Documents the resulting off-by-one in
Stakeholder.getMissingFrames (iterating to 541 flags a nonexistent prefix-541.jpg):
the file-count check uses 541 but frame iteration must use 540.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the vision: each day pull last year's event repo from GitHub, repair holes
with Stakeholder.fixFrames if needed, re-render locally, post to Telegram as a
memory, then delete the download. Reuses downloadBackups/fixFrames/stitchImages/
redraft. Notes inferring the frame count from the highest frame present (robust
across eras), and the open choice of healing GitHub permanently vs ephemeral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correct the Phase 0 model: daily/event repos are PRIVATE backups, not public.
director.timelapse() now uses setupBackupRepo (private); setupPublicRepo is kept
for the future publish flow. Per the vision, repos go public on demand only after
Telegram moderation, via a publish button -> GitHub Pages.

PLAN: add the "full publishing" epic (private -> moderate -> publish button ->
Pages), higher-quality published renders, and using GitHub Actions compute for the
public render instead of the Pi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fill the empty Go skeleton with a runnable, tested camera daemon (stdlib only,
zero external deps):

- main.go: HTTP server on PORT (default 8080), graceful SIGINT/SIGTERM shutdown,
  slog logging. GET /frame?maxAgeMs=&timeoutMs= returns JPEG bytes + X-Frame-Id/
  X-Captured-At/X-Attempts headers; GET /health returns {status,backend}.
- camera/service.go: hand-rolled single-flight. Concurrent callers share ONE
  capture (one shutter, shared photons); maxAge reuses the cached last frame;
  capture retries until a non-empty image lands or timeout. Replaces the drafted
  semaphore select. captureOnce recovers from a panicking backend (stoic heron).
- camera/capturer.go: hexagonal Capturer interface with RpicamCapturer (rpicam-
  still -> temp file -> non-empty guard -> bytes, same guard as the TS fix) and
  MockCapturer (synthetic time-varying JPEG for PHILO_MODE=mock / CI).
- camera/service_test.go: force-fresh, cache reuse, concurrent coalescing (20
  callers -> 1 shutter), retry-until-success, timeout.
- Makefile: keep arm64 cross-compile; add mock/test/vet/fmt targets.

Verified via mock server (valid JPEG, force-fresh vs cache) and go test.
Not yet run on the Pi against real rpicam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ery epic

Throwback repair: push repaired frames back to the private backup repo so holes
are permanently healed (local clone still deleted). Fill every hole from the
nearest neighbour - previous frame for interior/trailing gaps, next frame copied
backwards for a leading gap (missing frame 1), incl. multiple in a row. So any run
with >=1 frame heals fully; extend Stakeholder.fixFrames (currently previous-only)
to fall back to the next frame. Live loop still retries frame 1 in place.

Add Inventory & Gallery epic: one source of truth (state, health, backup
provenance) and a public gallery generated from published entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The TS Camera no longer touches hardware: photo() POSTs to philo-optic and
writes the returned JPEG bytes to the output path. Orchestration stays in TS.

- Camera.ts: HTTP client (POST /frame?maxAgeMs=0&timeoutMs=), throws on non-ok
  status or empty body (same contract, so Timelapse copy-forward is unchanged).
  #mutex removed; the daemon serialises the device now. busy just tracks the
  in-flight request for Timelapse.stop. Config: PHILO_OPTIC_URL,
  PHILO_OPTIC_TIMEOUT_MS.
- libcamera-still.ts: extracted buildStillArgs() as the single source of truth
  for rpicam args (--nopreview + defaults, minus --output); StillCamera reuses
  it. Camera forwards buildStillArgs(this.options) so presets/ROI aren't lost.

Daemon made options-aware without duplicating arg logic:
- Capturer.Capture(ctx, args); RpicamCapturer runs the supplied args + --output
  (falls back to a default snapshot when args are empty, e.g. a bare GET).
- service.go: single-flight keyed by args (different presets don't share a
  frame) + a global device lock so only one physical shutter runs at a time.
- main.go: POST /frame reads {"args":[...]} from the body; GET still works.
- service_test.go: added different-args-capture-separately; existing tests
  updated for the new signatures.

Verified: go build/vet/test, npm build/lint/test (12/12), and end-to-end mock
(TS Camera.photo -> daemon -> valid JPEG written). Not yet run on the Pi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline /publications handler in photoStage.ts duplicated Stakeholder's
"list unpublished" logic (and used the raw pub.name as the folder instead of the
type-dated name). It now delegates to Stakeholder.checkPublications() as the single
source of truth. Drops the now-unused FileSystem import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After the hours-long sleep before golden hour, #time is stale (from scheduling -
possibly yesterday when the run was scheduled "for tomorrow"), so the session
folder would be dated wrong. Refresh the time after waking, before building the
event folder name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ring

Daemon-down is now CRITICAL and unmistakable: Camera throws
CameraUnreachableError (naming URL, cause like ECONNREFUSED/timeout, and a
systemctl hint) when philo-optic never answers; Timelapse aborts the run
immediately on it - copy-forward healing must not fabricate a timeline while
nothing is observed. A daemon-reported capture failure (503/empty frame) stays a
plain recoverable error healed by copy-forward.

StillCamera (local rpicam spawner) is removed - no local fallback by decision, a
second capture path would bypass the device lock; buildStillArgs remains as the
single source of truth for capture args.

Camera.photo gains { maxAgeMs } (default 0 = fresh frame; positive lets a preview
reuse the daemon's cached frame).

New off-Pi tests: camera.test.ts (HTTP client contract incl. unreachable vs 503),
libcamera-still.test.ts rewritten for buildStillArgs, timelapse 'down' scenario
(abort, zero copies), Go main_test.go (HTTP layer: frame GET/POST, bad JSON,
blind 503, invalid query defaults, health). TS 24/24 green, go vet + test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plan revision: when the philo-optic daemon is unreachable, Camera now logs it
loudly and falls back to spawning rpicam-still locally, so the timeline continues
even if the daemon dies mid-run. Fallback captures are serialised in Camera
(#fallbackQueue) because degraded mode has no daemon device lock. Long-term
removal of the fallback stays an open decision.

CameraUnreachableError now means BOTH paths failed (daemon down AND local capture
failed) and names both causes; Timelapse still treats it as critical and aborts
without copy-forward. A daemon-reported 503/empty frame stays the recoverable
blind-camera case.

Tests: camera.test.ts exercises the real fallback via a mocked StillCamera (incl.
serialisation of concurrent fallback captures); libcamera-still.test.ts restores
the StillCamera output-file-guard suite alongside the buildStillArgs mapping.
TS 29/29 green, lint 0, build 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EYES_GRACEFUL_DEGRADATION_PHILO_OPTIC_UNAVAILABLE_STILLCAMERA (default true) now
controls whether Camera may fall back to a local rpicam-still capture when
philo-optic is unreachable. Set to 'false' to make daemon-down always critical
(CameraUnreachableError) without attempting the fallback at all - useful while
the fallback path itself is still earning trust. Read per-call rather than cached
at module load, so it's live-toggleable and simple to test.

Documented in .env.example alongside PHILO_OPTIC_URL/PHILO_OPTIC_TIMEOUT_MS
(previously undocumented since Phase 2). Tests cover toggle on (default) and off.
TS 31/31 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mantra over the plan: the strangler path IS the tabula rasa, in an
  order that never costs a sunset (rewrite rejected as method, allowed
  as end state - guiding decisions 8+9).
- Asymmetric fallback policy decided: prod toggles the local rpicam
  fallback ON, staging/clones OFF (fallbackQueue is in-process only).
- Phase 3 reframed as clones: staging-first validates Phases 1-2 on-Pi
  (>=3 green sunsets incl. induced daemon restart before prod switch),
  hardware relief (media services -> Pholiere) as precondition,
  GitOps reconciler gets capture-window guard + rollback, Matrix C2
  unblocked via LAN-only Dendrite as the human-steering surface.
- Staging: no GitHub uploads (feature toggle); on-demand cloud-sky
  photo noted as anytime test path. Build-on-Pi vs CI still open.
- Mass-repair dry-run designated first real divide_and_conquer ticket
  (cheap failure, mechanically verifiable, real value).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant