daybook_sessions_deploy

Deploy the Daybook agent-session shipper on macOS as a periodic launchd LaunchDaemon. The role validates an existing uv binary, keeps a pinned Daybook checkout on the target, runs uv sync, installs the trufflehog Homebrew package, renders a secret environment file, and schedules daybook sessions ship. It can also install the optional Archive quote classifier launchd job that runs daybook archive classify-quotes from the same checkout and environment. A separately gated Studio system LaunchDaemon can run the draft-only daybook weeknotes reconcile command at 07:40 and 19:40 local time. Its schedule is disabled and unloaded by default.

Run the role with facts enabled. It validates the macOS target, rejects placeholder credentials, and expects real MinIO credentials to come from a private control repo such as ops-control SOPS.

What This Role Manages

  • a pinned Daybook source checkout

  • an existing uv binary at daybook_sessions_uv_bin

  • the trufflehog Homebrew package

  • /etc/daybook-sessions/sessions.env

  • /etc/daybook-sessions/ship-sessions.sh

  • /Library/LaunchDaemons/de.wersdoerfer.daybook.sessions.plist

  • optional /etc/daybook-sessions/archive-quotes.env

  • optional /etc/daybook-sessions/classify-archive-quotes.sh

  • optional /Library/LaunchDaemons/de.wersdoerfer.daybook.archive-quotes.plist

  • optional /etc/daybook-sessions/weeknotes-reconcile.env (mode 0600)

  • optional root-owned /etc/daybook-sessions/weeknotes-reconcile.env.sha256

  • optional /etc/daybook-sessions/reconcile-weeknotes.sh

  • optional /etc/daybook-sessions/weeknotes-identity-ops.py and /etc/daybook-sessions/weeknotes-identity-ops.sh content-free operator rail

  • optional /Library/LaunchDaemons/de.wersdoerfer.daybook.weeknotes-reconcile.plist

  • optional dedicated ~/.daybook/weeknotes-reconcile/ runtime (mode 0700), with state.json, .state.json.lock, and an auth-only pi-agent/

  • optional dedicated ~/.daybook/archive-quote-browser profile directory (never a user’s live Helium profile)

  • launchd stdout/stderr logs under /var/log/daybook-sessions/

  • the default shipper watermark state path at ~/.daybook/sessions-shipped.json

The role removes the legacy by-hand ~/daybook-ship.sh runner by default. It does not delete the watermark state file; keeping it lets the first timed run skip sessions that were already shipped manually.

Important Defaults

daybook_sessions_repo_url: "https://github.com/ephes/daybook.git"
daybook_sessions_repo_ref: "CHANGEME"
daybook_sessions_checkout_path: "{{ daybook_sessions_service_home }}/projects/daybook"
daybook_sessions_repo_update: true
daybook_sessions_path: "s3://agent-sessions"
daybook_sessions_schedule_interval_seconds: 1800
daybook_sessions_launchd_label: "de.wersdoerfer.daybook.sessions"
daybook_sessions_launchd_scope: "system"
daybook_sessions_state_path: "{{ daybook_sessions_service_home }}/.daybook/sessions-shipped.json"
daybook_sessions_brew_packages:
  - "trufflehog"
daybook_sessions_redact_files: []
daybook_archive_quote_classifier_enabled: false
daybook_archive_quote_classifier_state: "s3://agent-sessions/archive-quote-classifier.json"
daybook_archive_quote_classifier_env_file: "{{ daybook_sessions_config_dir }}/archive-quotes.env"
daybook_archive_quote_classifier_cmd: "CHANGEME"
daybook_archive_quote_classifier_probe_enabled: true
daybook_archive_quote_classifier_probe_prompt: "Return exactly OK."
daybook_archive_quote_classifier_probe_timeout_seconds: 120
daybook_archive_quote_classifier_throttle_interval_seconds: 60
daybook_archive_url: "CHANGEME"
daybook_archive_token: "CHANGEME"
daybook_quotes_enabled: false
daybook_quotes_unused_path: "CHANGEME"
daybook_quotes_used_path: "CHANGEME"
daybook_quote_lifecycle_state: "s3://agent-sessions/weeknotes-quote-lifecycle.json"
daybook_archive_browser_executable: "/Applications/Helium.app/Contents/MacOS/Helium"
daybook_archive_browser_user_data_dir: "{{ daybook_sessions_service_home }}/.daybook/archive-quote-browser"
daybook_archive_browser_headless: true
daybook_archive_browser_launch_timeout_seconds: 30
daybook_archive_browser_navigation_timeout_seconds: 30
daybook_archive_browser_render_wait_seconds: 1
daybook_weeknotes_reconcile_enabled: false
daybook_weeknotes_reconcile_launchd_enabled: false
daybook_weeknotes_reconcile_launchd_label: de.wersdoerfer.daybook.weeknotes-reconcile
daybook_weeknotes_reconcile_start_calendar:
  - {Hour: 7, Minute: 40}
  - {Hour: 19, Minute: 40}
daybook_weeknotes_reconcile_runtime_dir: "{{ daybook_sessions_service_home }}/.daybook/weeknotes-reconcile"
daybook_weeknotes_reconcile_state_path: "{{ daybook_weeknotes_reconcile_runtime_dir }}/state.json"
daybook_weeknotes_reconcile_pi_agent_dir: "{{ daybook_weeknotes_reconcile_runtime_dir }}/pi-agent"
daybook_weeknotes_reconcile_pi_auth_source_root: "{{ daybook_sessions_service_home }}/.config/daybook-weeknotes-reconcile"
daybook_weeknotes_reconcile_pi_auth_source: "{{ daybook_weeknotes_reconcile_pi_auth_source_root }}/auth.json"
daybook_weeknotes_reconcile_pi_auth_rotate: false
daybook_weeknotes_public_repositories_json: "CHANGEME"
daybook_weeknotes_post_identity_epoch: ""
daybook_weeknotes_identity_required_repo_ref: ""
daybook_weeknotes_identity_seed_moves: []
daybook_weeknotes_identity_seed_path: "{{ daybook_weeknotes_reconcile_runtime_dir }}/identity-migration-seed.json"
daybook_weeknotes_identity_plan_path: "{{ daybook_weeknotes_reconcile_runtime_dir }}/identity-migration-plan.json"
daybook_weeknotes_identity_attestation_path: "{{ daybook_weeknotes_reconcile_runtime_dir }}/identity-migration-attestation.json"
daybook_weeknotes_identity_activation_proof_path: "{{ daybook_sessions_config_dir }}/weeknotes-identity-activation-proof.json"

These values must be supplied by the private control repo:

  • daybook_sessions_service_user

  • daybook_sessions_repo_ref

  • daybook_sessions_aws_endpoint_url_s3

  • daybook_sessions_aws_access_key_id

  • daybook_sessions_aws_secret_access_key

  • daybook_archive_url, daybook_archive_token, and daybook_archive_quote_classifier_cmd when the optional classifier is enabled; and daybook_quotes_unused_path plus daybook_quotes_used_path when either quote lifecycle or classification is enabled. The daybook_quote_lifecycle_state default is the exact production location, s3://agent-sessions/weeknotes-quote-lifecycle.json; override it together with the Markdown paths for local or non-production lifecycles. The role probes the classifier command by default during deploy, which verifies both the executable and provider/subscription authentication before launchd is enabled.

The three quote lifecycle settings must be distinct locations on one backend. The unused and used locations must be safe absolute local .md paths or safe, exact s3://bucket/key.md object URIs; lifecycle state must be a safe absolute local .json path or exact s3://bucket/key.json URI. Mixing local and S3 locations is rejected, as is reusing either Markdown location for state. All three variables are written to sessions.env on handoff/delivery machines and to the classifier environment when classification is enabled.

The role deliberately does not stat local quote files, contact S3, or create files/object keys. Daybook reads the exact existing Markdown and JSON locations and fails closed if any are missing or malformed. For an S3 lifecycle, the configured MinIO credentials therefore need GetObject and PutObject for all three objects in addition to the existing session scope; delete remains unnecessary. The configured browser executable must be a real executable file accessible to that user. The browser profile must be an absent/empty directory or an already Daybook-marked directory directly below ~/.daybook/; the role validates its real path and rejects symlinks, unsafe marker/lock files, wrong ownership, and non-empty unmarked profiles. Never configure or copy a normal Helium/Chromium user-data directory. A fresh empty dedicated profile is sufficient for public X pages that do not require authentication.

uv sync --frozen installs the Python Playwright dependency from uv.lock with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. Daybook launches the configured Helium binary, so this role does not install Playwright’s bundled Chromium.

Use a dedicated least-privilege MinIO account scoped to the existing session objects and production lifecycle state object in agent-sessions, plus exactly the two configured Obsidian quote objects. Do not grant quote-bucket listing/delete access and do not pass the MinIO admin key to this role.

Set daybook_sessions_repo_update: false only when the private control repo has pre-staged a service-user-owned checkout on the target and daybook_sessions_repo_ref is the full 40-character commit id already present there. The role verifies the checkout and commit locally, always rejects untracked files, rejects tracked changes when force is disabled, and uses a local detached checkout without any remote operation. A missing checkout or object fails instead of cloning or fetching.

Launchd Scope

The default daybook_sessions_launchd_scope: "system" installs a LaunchDaemon under /Library/LaunchDaemons and runs as daybook_sessions_service_user. Use daybook_sessions_launchd_scope: "user" for laptops or hosts where the control repo deploys as the logged-in service user without passwordless sudo. Headed browser mode is accepted only in this GUI LaunchAgent scope and the plist is restricted to an Aqua session; system LaunchDaemons must remain headless. In user mode, override the managed paths to user-writable locations, for example:

daybook_sessions_launchd_scope: "user"
daybook_sessions_config_dir: "/Users/jochen/.config/daybook-sessions"
daybook_sessions_env_file: "/Users/jochen/.config/daybook-sessions/sessions.env"
daybook_sessions_launcher_path: "/Users/jochen/.config/daybook-sessions/ship-sessions.sh"
daybook_sessions_log_dir: "/Users/jochen/Library/Logs/daybook-sessions"
daybook_sessions_stdout_log: "/Users/jochen/Library/Logs/daybook-sessions/ship.log"
daybook_sessions_stderr_log: "/Users/jochen/Library/Logs/daybook-sessions/ship.err.log"
daybook_sessions_launchd_plist_path: "/Users/jochen/Library/LaunchAgents/de.wersdoerfer.daybook.sessions.plist"
daybook_archive_quote_classifier_launcher_path: "/Users/jochen/.config/daybook-sessions/classify-archive-quotes.sh"
daybook_archive_quote_classifier_env_file: "/Users/jochen/.config/daybook-sessions/archive-quotes.env"
daybook_archive_quote_classifier_stdout_log: "/Users/jochen/Library/Logs/daybook-sessions/archive-quotes.log"
daybook_archive_quote_classifier_stderr_log: "/Users/jochen/Library/Logs/daybook-sessions/archive-quotes.err.log"
daybook_archive_quote_classifier_launchd_plist_path: "/Users/jochen/Library/LaunchAgents/de.wersdoerfer.daybook.archive-quotes.plist"

Redaction Notes

daybook sessions ship harvests secret values from its process environment, ~/.aws/credentials, ~/.envrc, and any colon-separated DAYBOOK_REDACT_FILES. The launchd job sources the rendered environment file before running Daybook, so the injected MinIO credentials are present in the process environment and can be redacted from transcripts before upload. When quote classification is enabled, the role automatically adds archive-quotes.env to DAYBOOK_REDACT_FILES, allowing Daybook’s secret-name harvester to redact the Archive token without logging or exposing its value.

Set daybook_sessions_redact_files to host-local files that contain other API keys worth redacting, for example project .envrc files:

daybook_sessions_redact_files:
  - "/Users/jochen/projects/daybook/.envrc"
  - "/Users/jochen/projects/ops-control/.envrc"

Dedicated Weeknotes Reconciler

Set daybook_weeknotes_reconcile_enabled: true to install the dedicated reconciler files. This requires a system LaunchDaemon. Installation renders the plist but daybook_weeknotes_reconcile_launchd_enabled defaults to false; the role boots out a loaded copy and persists launchd’s disabled state. It never kickstarts or otherwise invokes reconcile during deployment. Ordinary role deployment rejects a true launchd flag. After reviewed migration and draft inspection, private control must perform a fresh idempotent apply and then include tasks_from: weeknotes_reconcile_activate in that same play to enable and bootstrap the calendar schedule without an immediate run. In Ansible check mode, read-only launchctl inspection still runs, all launchctl mutations and mutation-dependent filesystem postconditions are skipped, and placeholder validation remains fail-closed.

The calendar is fixed at 07:40 and 19:40 local time, twenty minutes after the quote classifier’s 07:20 and 19:20 runs. StartCalendarInterval lets launchd coalesce a missed occurrence after wake; there is deliberately no StartInterval catch-up loop. Daybook’s non-blocking flock makes overlap a content-free exit-1 result, and its idempotent state/fencing handles duplicate calendar delivery safely. RunAtLoad is always false. The job is background, low-I/O, draft-only, and has separate stdout/stderr logs.

The mode-0600 managed environment supplies these names:

  • DAYBOOK_PI_AGENT_DIR, DAYBOOK_PI_PUBLIC_TIMEOUT_SECONDS, and DAYBOOK_PI_FAMILY_TIMEOUT_SECONDS

  • DAYBOOK_WEEKNOTES_RECONCILE_STATE, DAYBOOK_WEEKNOTES_PATH, and DAYBOOK_SESSIONS_PATH

  • Archive GET URL (no Archive token), all three quote lifecycle locations, and the S3 endpoint, regions, and credentials

  • django-cast base URL/token and distinct public/family blog ids

  • weeknotes.home URL/token

  • the strict nonempty DAYBOOK_WEEKNOTES_PUBLIC_REPOSITORIES_JSON mapping

  • DAYBOOK_WEEKNOTES_POST_IDENTITY_EPOCH=monday-after-v1 only after the exact private plan/attestation gate has passed

All credential and public-policy defaults are CHANGEME and are rejected. Policy and credential values never enter logs, the plist, or argv; the launcher sources the owner-only file and executes exactly uv run --isolated --frozen --no-dev --no-config daybook weeknotes reconcile. It never passes a publish argument or prompt/content in argv.

The public policy is an editorial allowlist supplied by private control. Daybook validates it as a strict, nonempty alias-to-canonical-GitHub mapping from the process environment. The public collection contains no environment-specific aliases or repository URLs.

Attested identity epoch and operator rail

The epoch default is empty, and ordinary role deployment rejects every request to enable the schedule, so a code install cannot gather corrected Monday-after identities. Before any source, dependency, or configuration mutation, the role disables and unloads the exact reconcile LaunchDaemon. Requesting monday-after-v1 additionally requires a clean exact pinned Daybook checkout, the exact private control seed, matching service-user-owned mode-0600 seed/plan/attestation files, and a root-owned mode-0600 activation proof binding their plan hash to that Daybook revision. The installed helper and launcher must match the current root-owned templates. Only then is the epoch rendered or the schedule allowed to activate. Private control must perform a fresh idempotent apply and include tasks_from: weeknotes_reconcile_activate in the same play; that task binds the freshly verified plan hash to the installed epoch before it enables and bootstraps without kickstart.

The installed operator launcher sources the existing mode-0600 reconcile environment and exposes only fixed prepare, dry-run, apply, verify, and status actions. It never accepts IDs, credentials, policy, post content, or arbitrary command arguments. Preparation reads a private content-free seed, GETs only those exact posts, verifies draft/live/parent/revision/old identity and the previous_revision_id response contract, computes Daybook’s protected-field hash in memory, and publishes the mode-0600 plan through a fsynced temporary file and atomic no-replace hard link. A crash after the durable link is safely completed on retry; a partial file never occupies the final path. It never prints bodies or metadata. If a plan already exists it must match the seed and is reused without GET regeneration, preserving crash recovery after a partial migration.

Operator actions and the scheduled launcher require the exact clean service-owned checkout, an isolated frozen uv environment, and byte-identical root-owned rail files. Both root-owned launchers verify the owner-readable environment against a root-controlled SHA-256 manifest before sourcing it. Normal role deployment only installs this rail; it never invokes it. Private control must call tasks_from: weeknotes_identity_operator for one explicit action. Apply additionally requires the exact confirmation phrase APPLY monday-after-v1 draft identity migration. Dry-run is Daybook’s zero-mutation migration mode; apply is a separate command and holds the existing reconcile flock. No action publishes.

django-cast commit 80b80928 is a hard prerequisite. Its editor response adds the content-free, page-local previous_revision_id that lets Daybook prove an already-renamed post is exactly one page revision after the planned source. A later edit fails that proof; global revision IDs must never be decremented or assumed consecutive.

Dedicated pi authorization precondition

Provision a separate OpenAI subscription OAuth auth.json at the remote daybook_weeknotes_reconcile_pi_auth_source before applying the role. The source must be a service-user-owned, non-symlink, single-link mode-0600 regular file in a real service-user-owned mode-0700 dedicated source directory. The role rejects .., resolves source/runtime/state/agent paths without reading contents, rejects symlinked ancestors and every resolved source under ~/.pi, and requires exact containment under explicit owner-only roots. On first install it seeds the managed file with remote_src under no_log; later normal applications preserve that file because Pi may have refreshed its OAuth state. The role refuses every destination entry other than its owned mode-0600 auth.json. Never use or modify global pi configuration or personal auth.

To rotate the managed authorization, first disable and unload the exact reconcile unit, replace the separately staged source, and apply once with daybook_weeknotes_reconcile_pi_auth_rotate=true. Rotation fails closed while the unit is loaded. Return the flag to false immediately; it is never a normal deployment setting.

Static install, migration, epoch, and activation flow

# Install/update while forcing the new unit disabled and unloaded.
ansible-playbook ... -e daybook_weeknotes_reconcile_enabled=true \
  -e daybook_weeknotes_reconcile_launchd_enabled=false

# Through a separate private operator playbook/tasks_from invocation:
# prepare -> dry-run -> explicit apply -> verify -> idempotent apply.
# The original runtime plan is retained and reused after partial progress.

# Install the epoch while still disabled. The role verifies the exact
# plan/attestation and pinned ref before rendering it.
ansible-playbook ... -e daybook_weeknotes_reconcile_enabled=true \
  -e daybook_weeknotes_reconcile_launchd_enabled=false \
  -e daybook_weeknotes_post_identity_epoch=monday-after-v1

# Only after independent inspection, use a private activation play that runs a
# fresh apply and then includes tasks_from=weeknotes_reconcile_activate in that
# same play. The normal role rejects launchd_enabled=true.

Status remains content-free:

launchctl print system/de.wersdoerfer.daybook.weeknotes-reconcile
set -a; . /etc/daybook-sessions/weeknotes-reconcile.env; set +a
cd /Users/USER/projects/daybook
/opt/homebrew/bin/uv run --isolated --frozen --no-dev --no-config \
  daybook weeknotes reconcile-status \
  --state "$DAYBOOK_WEEKNOTES_RECONCILE_STATE"

A normal exit is 0. Lock contention or an audience failure returns 1 with Daybook’s bounded, content-free JSON report; launchd exposes the last exit status. A safe manual rerun is the launcher with no arguments or one explicit --audience. It remains draft-only and idempotent.

To roll back, first deploy with the launchd flag false and the identity epoch empty. Preserve the runtime seed, plan, attestation, root activation proof, state, lock, drafts, quotes, and comments so interrupted work remains recoverable. Then remove only the new plist, launcher, environment, and its two logs if desired. Preserve the reconcile ~/.daybook/weeknotes-reconcile/state.json, its .state.json.lock, drafts, quote assignments/lifecycle, and managed pi auth unless an operator separately rotates authorization. Reverting ops commits is safe after the unit is unloaded. This role does not configure log rotation; that matches the existing Daybook launchd jobs.

Example

- name: Deploy Daybook session shipper
  hosts: mac_session_feeders
  become: true
  roles:
    - role: local.ops_library.daybook_sessions_deploy
      vars:
        daybook_sessions_service_user: "jochen"
        daybook_sessions_brew_user: "jochen"
        daybook_sessions_repo_ref: "main"
        daybook_sessions_aws_endpoint_url_s3: "{{ daybook_sessions_secrets.aws_endpoint_url_s3 }}"
        daybook_sessions_aws_access_key_id: "{{ daybook_sessions_secrets.aws_access_key_id }}"
        daybook_sessions_aws_secret_access_key: "{{ daybook_sessions_secrets.aws_secret_access_key }}"

The classifier job runs as a background, low-I/O launchd process with a throttle interval and fixed calendar schedule. The profile lock prevents overlapping manual and scheduled browser runs; headless mode is the safe unattended default.

Verify on the target with:

launchctl print system/de.wersdoerfer.daybook.sessions
tail -f /var/log/daybook-sessions/ship.log