Commit Graph
829 Commits
Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 bc41c737e5 dev/e2e: wall_clock regression check + deterministic fixture dates
`e2e.sh check` compares every indexed fixture's manifest wall clock with
the `wall_clock` reported by /image/metadata, /memories and
/photos/gps-summary; any disagreement fails.

- Fixture videos are stamped like phone containers: the America/New_York
  wall as a UTC creation_time (the label previously showed the UTC time).
- mtimes are pinned in America/New_York instead of the host's zone.
- The e2e env sets TZ=America/New_York (the #120 preflight): the Windows
  exe has no /etc/timezone, so server_zone() was otherwise None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:54:47 -04:00
Cameron CordesandClaude Opus 5.5 e2f33684c8 Dates: serve the display wall clock (wall_clock) to clients
Every payload a client renders a capture time from now carries
`wall_clock`, a naive ISO-8601 string computed by the existing
`date_view::row_display_wall`: the camera's wall clock (true instant +
camera offset; the owner's display zone without an offset), with the
per-source fallback for unconverted rows. Clients render it verbatim and
do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with
the rule kept server-side: the drain's permanent `date_utc IS NULL` floor
(#120: edited/exported files) would otherwise force every client to
carry the per-source legacy conventions forever, and insight prompts
already use the same function, so prompt and UI agree by construction.

- `/image/metadata` (+ the GPS / date / date-clear mutation responses):
  `file_wall_clock` — the row's wall, else the filename-class date, else
  the earliest fs time in the display zone (unindexed files).
- `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`.
- `get_all_with_gps` returns full rows instead of a 7-tuple that dropped
  `date_taken_source`, which unconverted rows need.
- `date_view::owner_display_zone` resolves prefs -> app default per request.

Legacy fields are untouched, so current clients see byte-identical
responses (additive; safe to deploy ahead of the client flip, per #120).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:54:46 -04:00
cameron e0c6c696f2 Merge pull request 'Tracing: migrate spans from raw OTel SDK to tracing + tracing-opentelemetry (#135)' (#148) from feature/tracing-migration into master
Reviewed-on: #148
2026-09-29 21:52:00 +00:00
Cameron CordesandClaude Opus 5.5 517d1d4b62 Docs: RUST_LOG also gates span export (#135)
The subscriber's single EnvFilter fronts both the console and the OTel
layer, so RUST_LOG decides which spans exist for export. Documented in
CLAUDE.md (supported range info/debug) and at the filter in init_tracing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:35:34 -04:00
Cameron CordesandClaude Opus 5.5 f510940819 Tracing: carry span into insight panic-isolation tasks; root the watcher boot probe (#143)
Found by the parity capture: both insight-generation handlers run the
generation inside a nested tokio::task::spawn (panic isolation) that was
not instrumented, so the background ai.insight.* trace still split off
from its 202 request. The watcher's boot-time availability probe ran
before the first tick span, leaving its count_for_library DB spans as
roots; it now opens `watcher.initial_probe`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:13:36 -04:00
Cameron CordesandClaude Opus 5.5 983102c1a5 Tracing: cargo fmt tests/otel_propagation.rs (#143)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:04:08 -04:00
Cameron CordesandClaude Opus 5.5 26115b3378 Tracing: root spans for background jobs, retire raw OTel spans (#142)
Background work has no request parent, so each job now opens a named
tracing root and carries it into its fan-out:

- watcher: `watcher.tick` (scan=quick|full) entered per tick on the
  watcher's std thread, `watcher.library` per library, and
  `watcher.playlist_cleanup` per orphaned-HLS pass. face_watch /
  clip_watch carry the ambient span into every per-candidate
  tokio::spawn and give each call its own `face_watch.detect` /
  `clip_watch.encode` span.
- thumbnails: `creating thumbnails` via #[instrument]; per-video spans on
  rayon workers name their parent explicitly.
- video actors: `playlistgenerator.generate_playlist` and
  `preview_clip.generate` are tracing roots that instrument their futures.
- reels: `reel.job` (created before the spawn, so on-demand jobs stay in
  the request trace), `reel.pregen_batch`, `reel.cache_sweep`.
- RefreshThumbnailsMessage and the dead-code GIF job move to tracing.

No raw OpenTelemetry span creation is left outside otel.rs;
`global_tracer` is removed.

tests/otel_background.rs pins the std-thread tick -> block_on -> spawn
shape and the rayon explicit-parent shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron CordesandClaude Opus 5.5 b6deca2a52 Tracing: DAO layer + handler spans on tracing, drop all Context params (#141)
trace_db_call no longer takes an OpenTelemetry Context: it opens a
db.<query_type>.<operation> span (same name via otel.name, same
db.query_type / db.operation attrs) on the caller's ambient tracing span
and runs the DAO body inside it. The cx / ctx / context parameter is gone
from every DAO trait method, impl and call site, and from the helpers and
background drains that only threaded it.

The remaining request-handler spans (files, tags, faces, image, video,
favorites, duplicates, memories) move to #[instrument]; the
Context::current_with_span / with_remote_span_context plumbing is gone, as
is extract_context_from_request (the tracing-actix-web root span covers
it). otel::set_span_attributes replaces BoxedSpan::set_attributes.

Background-job raw spans (thumbnails, video actors) are left for #142.

tests/otel_db_spans.rs pins name, attributes, status and nesting (across
an instrumented spawn); otel_traceparent now uses an #[instrument]
handler. CLAUDE.md documents the tracing conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron CordesandClaude Opus 5.5 1c508cdabd Tracing: LLM clients + streaming spans on tracing, drop cx threading (#140)
LlmClient methods no longer take an OpenTelemetry Context. llm.request is
now a tracing span (otel.kind=client, llm.provider/operation/model) that
parents on the caller's ambient span; traceparent injection reads the
span's OTel context via OpenTelemetrySpanExt.

The src/ai feature spans (handlers, insight generation, chat, persona
chat, daily summaries, TTS) move to #[instrument] / info_span!, and every
LLM-bearing tokio::spawn carries its parent span with .instrument(): the
streaming chat turn (chat_turn_stream_inner), async chat / persona turns,
background insight jobs and TTS jobs. Sub-spans in the agentic loops
(ai.chat.loop, ai.agentic.loop) instrument each LLM / tool await.

DAO calls keep their explicit Context (derived from the tracing span)
until the DAO migration (#141).

tests/otel_llm_spans.rs pins nesting + traceparent for the blocking and
streaming paths across a spawn on a multi-thread runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron Cordes 7a59afc62b Tracing: per-request root span via tracing-actix-web middleware (#139)
Add a conventional per-request OTel server span using the standard
tracing-actix-web middleware (TracingLogger) as the outermost middleware,
so the whole request lives under one root span and an incoming W3C
traceparent is extracted onto it via the global text-map propagator
(installed in #138).

extract_context_from_request now sources the operation span's parent from
that root span (tracing::Span::current().context()) instead of re-parsing
the header, so per-handler operation spans nest under the root span as
one trace tree rather than duplicating it as a sibling. Header parsing
remains as a fallback for handlers invoked without the middleware (tests).

The literal per-handler span boilerplate is intentionally NOT removed here;
it becomes #[instrument] in the LLM (#140) and DAO (#141) migrations, where
the &Context threading disappears entirely.

tests/otel_traceparent.rs pins the behavior end-to-end: an incoming
traceparent's trace_id propagates to the request, the root span is parented
on the caller's (remote) span, and the operation span nests under the root
span rather than alongside it.
2026-09-29 17:02:40 -04:00
Cameron Cordes e93f6b6d69 Otel: unify tracing subscriber across dev+release; env-gated OTLP export (#138)
Install ONE `tracing` subscriber in both build profiles via
`otel::init_tracing()` (previously release-only, so spans were a no-op in
dev). Behaviour is now environment-driven, not build-profile-driven:

- OTLP span export happens only when `OTLP_OTLS_ENDPOINT` is set
  (release / staging). Unset -> spans are still created with valid
  contexts (so outgoing `traceparent` injection keeps working) but export
  nowhere; the console `fmt` layer is what shows them.
- `tracing_subscriber::registry()` + `EnvFilter` (RUST_LOG) +
  `OpenTelemetryLayer` + console `fmt` layer, installed in both builds.
- Set the global W3C `TraceContextPropagator`: required by the
  `tracing-actix-web` request middleware (#139) and by the outgoing
  traceparent helpers, both of which read the *global* propagator.
- `tracing-log` bridge stays OFF (`default-features = false`) so the console
  fmt layer prints tracing spans only; `log` records keep their existing
  path (env_logger in debug, OTel logs in release) without double-printing.

Deps: + tracing, tracing-subscriber (env-filter/fmt/ansi), tracing-opentelemetry
0.32 (pins opentelemetry 0.31 + otel_sdk 0.31, no version bump); opentelemetry_sdk
gains the `testing` feature.

Add `tests/otel_propagation.rs`: the regression matrix from spike #133, pinning
the invariant the whole migration rests on -- an OTel context does NOT cross a
bare `tokio::spawn` on a multi-thread runtime (S2/S4 orphan onto a fresh trace)
but DOES with an explicit parent carry across the spawn (S6 `.instrument()`,
S7 `span.enter()`). This is why every spawn in the codebase must carry the parent.

clippy --all-targets clean; 690 lib + 775 integration + regression + 4 doc-tests pass.
2026-09-29 17:02:40 -04:00
cameron 8284509c90 Merge pull request 'dev/e2e: disposable server for app e2e / agent UI testing' (#145) from feature/e2e-dev-server into master
Reviewed-on: #145
2026-09-29 19:56:58 +00:00
Cameron CordesandClaude Opus 5.5 f00a309a24 dev/e2e: disposable server for app e2e / agent UI testing
`dev/e2e/e2e.sh up` runs an isolated ImageApi (port 8090, own build dir,
db, thumbnails, HLS) next to a real local server, serving a copy of a
synthetic fixture library. Every fixture image has its path, date and
scenario burned into the pixels; manifest.json records expected dates,
GPS, cameras, tags and counts. `reset` restores the baseline.

- LOCAL_BIND_URL makes the hardcoded localhost:8088 listener
  configurable (default unchanged; empty disables it) so a second
  instance can start.
- create_user bin: the server has no registration route; idempotent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:02:49 -04:00
cameron da3901c564 Merge pull request 'Prefs: owner defaults (model, backend, persona_id, reel span/length)' (#129) from feature/prefs-owner-defaults into master
Reviewed-on: #129
2026-09-29 01:50:07 +00:00
Cameron Cordes f850c535f1 Otel: thread request context through streaming chat path (#129 review)
The legacy SSE streaming path (chat_turn_stream -> run_streaming_turn ->
run_bootstrap/continuation_streaming) built its LLM spans from
Context::current(), which is always the empty root context here: the codebase
threads OTel contexts explicitly and never attaches one, there is no
context-aware tokio layer, and the turn runs on a tokio::spawn. So every
llm.request span on that path was an orphaned root span, uncorrelated with the
HTTP request.

Thread the handler's extracted request context down through
chat_turn_stream -> chat_turn_stream_inner -> run_streaming_turn ->
run_bootstrap_streaming / run_continuation_streaming, and use it for the
describe_image and agentic-loop calls. Mirrors the non-streaming path, which
already threads parent_cx -> loop_cx.

Also rename the block-scoped db 'cx' in run_streaming_turn to db_cx so it no
longer shadows the new parameter.
2026-09-28 21:41:38 -04:00
Cameron Cordes 10ed84d7b4 Otel: dedupe LLM span error/status + traceparent helpers (#130 follow-up)
Addresses the P2 refactoring follow-ups parked on issue #130:

- otel::with_traceparent(builder, &SpanContext) — one shared W3C
  traceparent injector. Replaces the 4-line inline header loop in
  llamacpp (3x) and openrouter (3x) and removes ollama's private
  inject_traceparent method (4 call sites now use the shared helper).
  Takes &SpanContext (not &dyn Span) because the Span trait is not dyn
  compatible in opentelemetry 0.31.

- otel::record_http_failure(span, status, message) and
  otel::set_http_status(span, status) — collapse the repeated
  'set http.status_code attribute + set_status(Status::error)'
  boilerplate across all three clients (~10 sites).

Net -45 lines. The remaining if-let-Some(span.as_mut()) guards (token
attributes, parse-failure status) stay inline rather than growing a
SpanHandle wrapper type for a pattern that's already readable.
2026-09-28 10:35:28 -04:00
Cameron Cordes 696d9fa8f3 Otel: llm.request client spans + traceparent on LLM calls (#130)
Add OpenTelemetry tracing to every LLM client call so an insight/chat
turn is a single trace from HTTP entry to each downstream inference.

- LlmClient trait: thread a &opentelemetry::Context through all 5
  methods (generate, chat_with_tools, chat_with_tools_stream,
  generate_embeddings, describe_image), matching the DAO convention of
  explicit context threading rather than ambient Context::current().
- otel::start_llm_request_span: single shared helper that every
  provider (openrouter / llamacpp / ollama) funnels through, so the
  span shape is identical: name llm.request, SpanKind::Client,
  llm.provider / llm.operation / llm.model.
- Per-method spans in all three clients with the spec's operation enum
  (chat | chat_stream | embeddings | describe_image | generate); stream
  spans are owned and moved into the async_stream generator so they end
  when the response drains, not when headers arrive.
- llm.tokens.input / llm.tokens.output recorded on non-stream and
  stream completion; llm.cost_usd from usage.cost (OpenRouter);
  http.status_code + error status on failure.
- W3C traceparent injected on outgoing LLM HTTP requests (llama-swap,
  Ollama, OpenRouter) gated on span_context.is_valid() so debug
  no-op-tracer builds don't send an all-zero parent.
- Turn-level spans (ai.chat.turn.execute, ai.persona_chat.turn.execute)
  now carry llm.backend + llm.model; with_entry streaming paths thread
  the turn context (turn_cx) to child describe_image / agentic-loop
  calls instead of the ambient context.

clippy clean; 690 lib + 775 integration + 4 doc tests green.
2026-09-28 01:21:46 -04:00
Cameron Cordes e84ac82f86 ADR-0002: owner defaults live in the DB row; .env keeps deployment config and secrets
Records the config boundary the #127 owner-default columns establish:
.env = boot-time deployment config + secrets (never DB), user_ai_prefs
single row = per-request owner defaults (hot-tunable, no secrets),
resolution request param -> owner default -> hard default, and the
safe-by-default privacy posture for default_backend. Adds the
Configuration glossary section to CONTEXT.md (owner default,
deployment config, hard default).
2026-09-27 22:50:28 -04:00
Cameron Cordes a699c940b8 Prefs: owner defaults (model, backend, persona_id, reel span/length)
Closes #127.

Grows user_ai_prefs with five nullable owner-default columns and makes
them the server-side fallback layer: request param -> stored pref ->
hard default, at insight generation (agentic + standard), all three
insight-chat turn variants, and reel creation.

- PUT /prefs accepts the fields with the existing three-state partial
  update semantics (TriState generalized to TriState<T> for the numeric
  reel_max_segments); validation (canonical BackendKind::parse,
  MemoriesSpan::from_str) runs before any write.
- GET /prefs returns the new fields.
- capture_prefs reads the existing row and carries the AI owner-defaults
  through unchanged (the upsert overwrites all columns), mirroring only
  the effective reel span/segments.
- MemoriesSpan gains Display + FromStr so stored spans round-trip
  losslessly.

1463/0 tests (+27), clippy clean.
2026-09-27 19:48:45 -04:00
cameron aa3c58f051 Merge pull request 'feat(prefs): user_ai_prefs surface — GET/PUT /prefs, GET /timezones (#126)' (#128) from feature/prefs-endpoints into master
Reviewed-on: #128
2026-09-27 21:38:23 +00:00
Cameron Cordes 2258a0eab5 feat(prefs): user_ai_prefs surface — GET/PUT /prefs, GET /timezones (#126)
- GET /prefs (JWT): current row or empty {voice, library, display_tz,
  updated_at} shape — snake_case, 200 with nulls when no row
- PUT /prefs (JWT): three-state partial update (omitted=unchanged,
  explicit null=clear, value=set) via hand-rolled TriState deserializer —
  serde derive conflates omitted with explicit null on Option<Option<T>>
- display_tz validated against chrono-tz before any write; invalid → 400,
  nothing written. tz_offset_minutes (frozen legacy) preserved verbatim
- GET /timezones (public): chrono_tz::TZ_VARIANTS — exactly the domain
  Tz::from_str accepts, so picker list and validation can't drift
- No migration; existing columns only. capture_prefs (POST /reels) untouched
- 14 tests: 3 GET /prefs, 7 PUT /prefs (incl. auth-gate), 2 /timezones,
  2 pure merge_patch. Full suite 1436 passed / 0 failed
2026-09-27 14:40:30 -04:00
cameron 7f348022d4 Merge pull request 'feat(dates): read paths on the true-instant contract — /memories, reels, insights (#119)' (#125) from feature/date-reads-insights into master
Reviewed-on: #125
2026-09-27 15:07:12 +00:00
Cameron Cordes 615941a511 fix(dates): assumed_server fallback uses the offset at the photo's wall, not now's
A container whose /etc/localtime is a copy rather than a symlink makes
server_zone() return None; the assumed_server rung then fell back to
Local::now()'s offset, which is only correct for captures from the
current season. Historical winter walls on a DST host skewed date_utc
by an hour. Resolve the process-local zone at the wall instead
(earliest reading for ambiguous walls).

Surfaced by test-server validation of #119.
2026-09-26 23:38:50 -04:00
Cameron Cordes af87765bbb Switch read paths to the true-instant date contract (ticket #119)
- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
2026-09-26 19:28:26 -04:00
cameron cd375f18db Merge pull request 'feat(dates): backfill drain converts mixed-convention rows to true instants (#118)' (#124) from feature/date-backfill-drain into master
Reviewed-on: #124
2026-09-26 14:42:11 +00:00
Cameron CordesandClaude Opus 5.5 fcaf8487c2 fix(dates): resume cursor so unconvertible rows can't stall the #118 drain
Review follow-up: rows that exist but can't be faithfully resolved spent
the cap and were re-collected first every tick. Carry a per-library
cursor across ticks (wrapping at end of table), share the cap parser
between the two date drains, and name the conversion tuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:36:50 -04:00
Cameron CordesandClaude Opus 5.5 98db2a48db feat(dates): backfill drain converts mixed-convention rows to true instants (#118)
Per-tick drain (backfill_utc_dates) at the existing date-drain seam,
inside the per-library online gate. Selects date_utc IS NULL AND
date_taken IS NOT NULL (new partial index idx_image_exif_utc_backfill),
re-runs resolve_dates_batch with empty priors (one exiftool subprocess
per batch, same call shape as the #117 audit), and writes only the
new-contract triple; the legacy pair stays frozen. Capped by
DATE_BACKFILL_MAX_PER_TICK; missing-file rows are paged past without
spending the cap. Legacy manual overrides convert via
manual_date_resolution rather than being re-resolved from the file.

Day-boundary invalidation deletes the day-span precomputed reels
covering a re-dated row's old/new display-zone month-day so the nightly
pre-gen regenerates them. Progress: imageserver_date_backfill_remaining
gauge + date_backfill_remaining on GET /libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:36:50 -04:00
cameron ab16bc7da6 Merge pull request 'feat(dates): pre-cutover audit utility for the filename date class (#117)' (#123) from feature/filename-audit into master
Reviewed-on: #123
2026-09-26 14:32:12 +00:00
Cameron Cordes 89fc24ceab feat(dates): print the numeric offset in the audit report
The spec-mirroring review noted the report showed the offset SOURCE
but not the offset value — the value is what the operator spot-checks.
Each proposed line now ends with offset={+N}m (or - for utc_direct
rows). Tests pin the rendered values.
2026-09-25 18:14:02 -04:00
Cameron Cordes 3aa6ce314c feat(dates): pre-cutover audit utility for the filename date class (#117)
One-shot, operator-run, strictly read-only utility that lists every
filename-class file with its stored date/source and the date/source/
offset-source the re-based resolver would assign it — the human
spot-check that gates the production backfill drain (#118).

- src/filename_audit.rs: StoredDate/AuditRow/SkippedLibrary; walk +
  PathExcluder (depth-0 guarded) + match_filename_date -> one
  resolve_dates_batch pass with EMPTY priors (mirrors the drain
  exactly); disabled and probe-stale libraries reported as skipped,
  never partially processed; output stable-sorted by (library, path).
- src/bin/filename_audit.rs: operator binary (dotenv, DATABASE_URL,
  EXCLUDED_DIRS, libraries::load_all, format_report).
- src/memories.rs: match_filename_date canonical matcher returning
  FilenameMatch { pattern, date }; extract_date_from_filename is now
  a thin wrapper (byte-for-byte behavior-preserving, verified by
  review).

4 audit tests (inversion + utc_direct + exclusions, offline/disabled
skip, stable sort, report determinism incl. no debug quotes) + 2
matcher tests. 645 lib + 718 integration pass; clippy/fmt clean.
Standards review applied: {:?}->{}, depth-0 walk guard, dead
per-file exclude check removed, unused Default derive removed,
pattern column widened, doc comment reworded.
2026-09-25 18:03:38 -04:00
cameron 3ac011abd7 Merge pull request 'feat(dates): filename date class — per-pattern conventions + filename-over-EXIF priority (#116)' (#122) from feature/filename-date-class into master
Reviewed-on: #122
2026-09-25 21:16:53 +00:00
Cameron Cordes a97bf53603 feat(dates): filename date class — per-pattern conventions + filename-over-EXIF priority (#116)
The filename date class is now first-class (ADR-0001):

- memories::extract_date_from_filename returns FilenameDate:
  Wall(NaiveDateTime) for the screenshot/dash/compact patterns,
  Utc(DateTime<Utc>) for the unix-timestamp patterns. No patterns
  added or removed; every existing pattern carries its convention.
- Waterfall inversion in both resolve_date_taken and
  resolve_dates_batch: an in-class name is the original date of
  record and resolves BEFORE EXIF / container dates are consulted
  (shared filename_class_resolved step; in-class files skip
  exiftool entirely). Wall patterns run the offset cascade over the
  kamadak prior's evidence (empty evidence -> server-zone
  assumption, so a class file always resolves); timestamp patterns
  are true instants (tz_source utc_direct, no offset).
- Legacy date_taken stays byte-identical: wall-as-UTC for wall
  patterns, epoch for timestamp patterns, via
  FilenameDate::legacy_datetime / legacy_timestamp at the seven
  consumer call sites.
- Out-of-class files keep the global EXIF-wins waterfall unchanged.
- CLAUDE.md waterfall section rewritten (step 0 = filename class;
  the EXIF-beats-filename note becomes out-of-class-only).

Tests: five new waterfall-seam tests (wall no-evidence ->
assumed_server; wall outranks EXIF with offset-tag evidence;
wall GPS evidence; timestamp utc_direct; in-class video name
resolves without exiftool) plus enum-shape assertions in every
extractor test. Code-reviewed on both axes (standards: no
violations, 3 of 4 P2 notes applied; spec: all acceptance
criteria verified, no scope creep).

716 integration + 639 lib tests pass; fmt + clippy clean.
2026-09-25 16:54:46 -04:00
Cameron Cordes 6f73a31e08 docs(tracker): correct the Gitea native-dependency API call
The wayfinding doc pointed at a .../dependencies/blocked_by subpath
that 404s, and the working call needs the blocker's owner+repo in the
JSON body (omitting them 404s with 'repository does not exist').
Verified against Gitea 1.27: POST/DELETE .../issues/<child>/dependencies
with {"index", "owner", "repo"}.
2026-09-25 16:17:17 -04:00
cameron 467abd41d5 Merge pull request 'feat(dates): true-instant date contract — container dates, fs branch, manual edit (#114, #115)' (#121) from feature/true-utc-dates into master
Reviewed-on: #121
2026-09-25 19:55:14 +00:00
Cameron Cordes 7d7d7c713c refactor(dates): condense the two date-contract migrations into one
Neither migration had shipped, so the #115 snapshot columns join the
#114 new-contract columns in 2026-09-24-000000_add_true_utc_dates.
Pure ADD COLUMN either way — population is runtime (drains), so nothing
needs re-migrating. Column order is unchanged, so schema.rs stands.
2026-09-25 15:39:23 -04:00
Cameron Cordes d2263be6ac feat(dates): container video dates, filesystem branch, manual edit (#115)
The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:

- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
  UTC-rendered creation_time fields -> true instants (utc_direct).
  Running them through the camera wall-clock cascade was the
  "videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
  the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
  the offset cascade (OffsetTime* pairing + GPS evidence pulled in
  the same exiftool pass).

The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.

Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.

ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
2026-09-25 15:26:26 -04:00
Cameron Cordes 6f020f4201 docs(utils): make the retry_with_backoff_if doctest actually compile
The example referenced an unimported DbError whose type didn't even
match the closure's anyhow::Error, and the classifier body returned
(). Rewritten as a self-contained io::Error example with an assertion.
2026-09-24 22:04:56 -04:00
Cameron Cordes 2f2107d4fa feat(dates): foundation for the true-instant date contract (#114)
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
2026-09-24 21:58:46 -04:00
Cameron Cordes ee773a34ef docs(dates): record the true-instant date contract
- CONTEXT.md: date/time vocabulary (true instant, wall clock, camera
  offset, display zone, filename date class, ...)
- ADR-0001: dates are true instants; wall clocks are a display concern
- un-ignore docs/adr/ so decisions land in git
2026-09-24 18:23:46 -04:00
cameron bf64d046d8 Merge pull request 'chore(agents): install mattpocock skills + per-repo agent config' (#112) from feature/skills-install into master
Reviewed-on: #112
2026-09-24 17:31:50 +00:00
Cameron Cordes 5b5314631b chore(agents): install mattpocock skills + per-repo agent config
- 38 skills from mattpocock/skills into .agents/skills/ (symlinked into
  .claude/skills/ for Claude Code)
- skills-lock.json for reproducible installs
- docs/agents/: issue-tracker (Gitea via tea CLI), triage-labels
  (default five-role vocabulary), domain docs (single-context)
- CLAUDE.md: add '## Agent skills' section pointing at docs/agents/
- .gitignore: un-ignore docs/agents/ under the local /docs/* rule
2026-09-24 13:24:29 -04:00
cameron 0aeeb2d1f4 Merge pull request 'Feature/agentic loop fixes' (#111) from feature/agentic-loop-fixes into master
Reviewed-on: #111
2026-09-24 15:59:58 +00:00
Cameron CordesandClaude Opus 5.5 23371ad0bc fix(watcher): refresh planner stats on full scans to stop write-lock starvation
sqlite_stat1 was last gathered by a migration while image_exif had 402
rows and an all-NULL content_hash, so the planner treated the hash index
as non-selective and scanned it for every row in the back-ref refresh's
correlated subqueries. The face_detections pass took ~20s per watcher
tick while holding the write lock, so chat/persona-chat persistence blew
past busy_timeout and returned 500s. With fresh stats the three
refreshes drop from ~22s to ~0.1s.

- library_maintenance: add refresh_planner_stats (plain ANALYZE;
  PRAGMA optimize doesn't catch this on SQLite 3.45), run on full-scan
  ticks ahead of reconcile / back-ref refresh.
- utils: retry backoff used 1_u32.pow(..), a flat ~100ms; make it the
  documented exponential 100/200/400ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:33:37 -04:00
Cameron CordesandClaude Opus 5.5 33fe7ce77c feat(openrouter): cap max_tokens per request (OPENROUTER_MAX_TOKENS)
OpenRouter reserves credit against the requested max_tokens, falling
back to the model's full output window when omitted, so expensive
models failed with credit-limit errors even for short replies. Default
the cap to 16384; OPENROUTER_MAX_TOKENS overrides it and 0 disables it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 21:32:56 -04:00
Cameron Cordes 4e3ee1973c feat(agentic-loop): retry transient knowledge-write locks + re-open tools on loop exhaustion
Transcript analysis (359 insights / ~3.3k tool results) showed 31
"database is locked" errors from knowledge-memory tools and 50/359
runs (14%) exhausting the 6-iteration budget with no final answer.
The exhaustion nudge re-invoked chat with an EMPTY tool list, so a
model that still needed information could not call anything.

- utils: add retry_with_backoff_if — same exponential backoff + jitter
  as retry_with_backoff, but retries only while the predicate classifies
  the error as transient; deterministic failures short-circuit.
- insight_generator: wrap the four knowledge write tools
  (store_fact/upsert_fact, store_entity/upsert_entity, update_fact,
  supersede_fact) plus the store_fact photo-link upsert in
  retry_with_backoff_if gated on a SQLITE_BUSY detector ("database is
  locked"/"database is busy" massage in DbError.source). WAL +
  busy_timeout absorb most contention; a parallel insight run or the
  face-watch write storm still surfaces it, and a short backoff retry
  clears it instead of surfacing a hard error to the model.
- insight_generator: exhaustion nudge now re-offers the full tool set
  instead of vec![] and explicitly says the model may call more tools;
  if it does take one, results are collected and a bounded tool-less
  turn forces the actual answer so we never store an empty insight.
  Same treatment for the read-only tool loop.
- models/knowledge_dao: derive Clone on InsertEntity, InsertEntityFact,
  InsertEntityPhotoLink and FactPatch so retried FnMut closures can
  re-issue the same write value on each attempt.
2026-09-21 19:24:28 -04:00
cameron 3a93e5cae6 Merge pull request 'Feature/open chat with persona' (#110) from feature/open-chat-with-persona into master
Reviewed-on: #110
2026-09-08 21:55:37 +00:00
Cameron CordesandClaude Opus 5 bbaa1f62a4 fix: delete a persona's conversations along with the persona
There is no FK between personas and persona_chat_conversations, and the
chat list skips conversations whose persona is gone — so deleting a persona
left its transcripts in the database, invisible and unreachable, forever.

Both deletes now run in one transaction so the two tables cannot get out of
step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:39:06 -04:00
Cameron CordesandClaude Opus 5 65cceaa67c feat: multiple persona conversations with branching and generated titles
Persona chat stored a flat Vec<ChatMessage> keyed on (user_id, persona_id),
which meant one rolling transcript per persona and no way to revisit a turn.
This moves it onto the same ChatHistoryStore tree the file chat uses and
gives conversations their own identity.

Storage
- messages_json now holds a serialized ChatHistoryStore. Reads accept the
  old flat array and upgrade it in place, so existing transcripts survive
  without a data migration. The upgrade drops the v1 seed greeting, which
  the flat renderer hid but the tree renderer would surface as a bubble the
  user has never seen.
- New migration re-keys persona_chat_conversations on an opaque
  conversation_id and adds title + created_at, so one persona can hold any
  number of separate threads. Every DAO read and write is scoped by user_id
  as well: a conversation id is a bearer token for someone's transcript and
  must never grant access on its own.
- The per-conversation lock and in-flight turn slot key on conversation_id,
  so two threads with the same persona can run turns concurrently.

Endpoints
- POST/DELETE /persona_chat/conversations — start and remove a thread,
  replacing /persona_chat/reset.
- GET /persona_chat/conversations — the chat list, with snippet and counts
  derived from each tree's active branch.
- POST /persona_chat/rewind, POST /persona_chat/switch-branch,
  GET /persona_chat/branches — rewind and fork, mirroring the file chat.
  Index 0 is rewindable here (it is the user's own first question, not a
  synthetic prompt) and re-anchors on the seed node.
- history/turn/rewind/switch-branch/branches all key on conversation_id;
  history gained branch_id and now returns real fork_info, active_leaf_id
  and viewing_branch_id instead of placeholders.

The turn body no longer carries a persona at all — it is read from the
stored conversation, so a stale client cannot swap a thread's voice midway.

Titles
After the first turn persists, the conversation is named from its opening
exchange on the same backend the turn ran on. Small models wrap titles in
quotes, prefix them with "Title:" and append explanations, so sanitize_title
strips all of that and truncates on a character boundary. Any failure falls
back to the user's opening question. Generation runs after persistence: a
failed title must not cost the turn.

Fixes found along the way
- insight_chat: both file-chat turn paths captured path.len() before
  apply_context_budget drained messages out of the middle, then sliced
  messages[path_len..] for the new tree nodes. Once truncation fired that
  dropped the user turn from the tree or panicked on an out-of-range start
  index. Now read after the budget pass as history_len.
- Persona chat had no context budget at all and hardcoded truncated: false
  in the done frame, so a rolling transcript grew unbounded.
- A cancelled turn persisted a half-finished transcript and pushed a second
  terminal frame; it now returns early like the file chat.
- The seeded system prompt was frozen at conversation creation, so editing a
  persona never reached a thread already in flight. Re-resolved per turn.
- turn_count was overwritten each write with the per-turn message delta;
  it is now the cumulative assistant-turn count on the active branch.
- is_initial is always false: the file chat reserves it for its synthetic
  "describe this photo" prompt, and marking a persona chat's first question
  with it made the opening reply impossible to regenerate.

600 lib tests pass, clippy --all-targets clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:23:10 -04:00
Cameron Cordes 883e2a0e1b fix: send persona_chat wire shapes in snake_case like file chat
The mobile client dispatches POST /persona_chat/turn with snake_case
keys (persona_id, user_message, num_ctx, ...) per the file-chat
convention, but the persona request/response structs carried camelCase
serde renames (personaId, userMessage, ...), so every dispatch 400'd
with 'missing field personaId'. Drop the renames from
PersonaChatTurnRequest, PersonaChatHistoryView, RenderedPersonaMessage,
and PersonaChatResetRequest — the persona wire shapes now match the
rest of the API (history query, 202 turn_id, SSE skip_before) — and
pin the contract with serialization round-trip tests.
2026-08-24 21:34:46 -04:00
Cameron Cordes 3b9d985025 feat: add /persona_chat endpoints for open chat with persona
- PersonaChatSession dispatches turns through the shared agent loop,
  persisting transcripts keyed on (user_id, persona_id) in the new
  persona_chat table (migration included).
- GET /persona_chat/history returns a rendered transcript (tool
  invocations folded, is_initial flag) matching the file-chat shape.
- POST /persona_chat/turn returns 202 with a turn_id; SSE replay and
  cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the
  actix-attributed handlers so both route families share the logic.
- POST /persona_chat/reset clears the persona transcript.
- Concurrent dispatches for the same (user, persona) are rejected with
  409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard
  drops with the spawned turn task, freeing the slot on completion,
  error, or abort.
2026-08-24 21:25:48 -04:00