Files
Mike Schwarz aa126c74d5 docs: bring the as-built and reference docs to the 0.6.8 cut (#1041)
The README, reference and as-built documents are refreshed to the 0.6.8 cut.
2026-10-08 15:06:42 -07:00

11 KiB
Raw Permalink Blame History

kind, title, status, topics, domains, applies-when, last-verified-against-source, last-updated
kind title status topics domains applies-when last-verified-against-source last-updated
as-built Content Surfaces — Files, Progress, Steering, and Health active
observability
specification-and-bundles
engineering-advisor
operating-advisor
Tracing allowlisted file access, conflict-checked writes, progress indexing, steering composition, or the daemon health-summary endpoints. e8f0ab340d 2026-10-08

Content Surfaces — Files, Progress, Steering, and Health

Source snapshot: e8f0ab340db773392ec8be75b072d1c0f3068a50. This describes the source at that commit; it does not establish the version or behavior of a running daemon.

The daemon exposes filesystem content through the files, progress, and steering route families. Health summaries read existing daemon state. These are separate projections with separate configuration; configuring one root is not a universal filesystem-access policy.

Configuration and wiring

startup.ts constructs SettingsStore, decodes cfg.filesAllowlistRaw and cfg.progressScanRootsRaw, and injects FileWriteService, ProgressIndexer, and SteeringComposer. The write service exists only when the decoded file allowlist is nonempty. Its audit path is explicitly ${OPENRIG_HOME}/file-edit-audit.jsonl.

SettingsStore resolves environment overrides, then settings-file values, then defaults. files.allowlist and progress.scan_roots both default to workspace:<resolved-workspace-root>. Their environment overrides are OPENRIG_FILES_ALLOWLIST and OPENRIG_PROGRESS_SCAN_ROOTS. Thus, an unset or empty environment variable, or an empty settings-file value, does not imply an empty allowlist in normal daemon startup. A non-empty value is not validated: the decoders silently skip malformed entries (no name: prefix, or a relative path), so a malformed value can leave no configured roots. Some source comments and route hints still say otherwise or point only at the environment variable. The standalone readAllowlistFromEnv helper has different inputs and retains its legacy environment fallback.

Roots use comma-separated name:/absolute/path pairs. The decoders skip invalid pairs, canonicalize existing paths, and let the last duplicate name win. See decodeAllowlist and decodeProgressScanRoots.

Path resolution and reads

resolveAllowedPath requires a configured root, rejects .. path segments and absolute relative paths, resolves existing symlinks, and checks containment with a path-separator boundary. An empty relative path denotes the root. If realpath fails, the helper falls back to the resolved candidate; file/directory helpers then perform a stat check. These are the implemented checks, not a claim of race-free isolation against concurrent filesystem changes.

readAllowedFile is shared by the HTTP reader and the local TUI reader. It returns content, absolute and root-relative resolved paths, mtime, full-file SHA-256, size, binary classification, and truncation metadata. The returned text is capped at 1,048,576 bytes, but the full file is read and hashed. The cap limits the response, not the memory required to read the file. The TUI's local reader runs as a CLI subprocess without the daemon: it resolves files.allowlist from the CLI configuration, offers four fixed workspace targets as roots, and hides dot-prefixed entries in listings (packages/cli/src/local-reading.ts).

Writes and audit

FileWriteService.writeAtomic reads the existing target and compares both the expected mtime and content hash. A mismatch raises WriteConflictError with the current values. It then writes a temporary file in the same directory, preserves the target's ordinary permission bits, fsyncs the temporary file, and renames it over the target. This is optimistic conflict detection; it does not hold a lock over the read/compare/replace sequence.

createAtomic handles new files. It creates and fsyncs a temporary file, then uses an exclusive hard link to establish the target. If the target already exists, it returns the target_exists error instead of overwriting it. createAtomic is used by the Living Notes freeze export, and writeAtomic also backs the freeze-time MISSION_BRIEF.md spine update; both append to the same audit file.

Both operations append a JSONL audit record containing actor/provenance, paths, timestamps, hashes, and byte-count delta. An audit append failure raises audit_write_failed after the content has landed; the write is not rolled back. The service appends records without implementing rotation. The service's standalone default is under the user's .openrig directory; daemon startup overrides it with the active OpenRig home as described above.

HTTP surface

server.ts mounts these families. All of them sit behind the daemon's /api/* browser boundary (middleware/browser-boundary.ts), which accepts only known host names, and browser Origins only from the daemon's own UI or OPENRIG_ALLOWED_ORIGINS; the file allowlist is a separate check. filesRoutes implements:

Method and path Result / important behavior
GET /api/files/roots Configured roots; an empty list carries a setup hint.
GET /api/files/list?root=…&path=… Directory entries with metadata, including dotfiles.
GET /api/files/read?root=…&path=… readAllowedFile result.
GET /api/files/asset?root=…&path=… Raw asset; single byte-range support (206, invalid range 416), five-minute cache. HTML and source files default to plain text and unknown types to application/octet-stream; render=1 opts into HTML rendering. SVG is served as image/svg+xml. A render=1 HTML preview or an SVG is served with no content-security or frame headers, so it can run script in the daemon's origin (routes/files.ts:153–157).
POST /api/files/write Requires root, path, string content, expected mtime and hash. Stale reads return 409 write_conflict; an absent write service returns 503.

Writes resolve actor/provenance through resolveActorWithDeferral. Missing route dependencies return 503; unknown roots and invalid paths return 400. A failed stat returns 404 on reads, lists and assets; on a write, a missing or unreadable target returns 500 stat_failed, because POST /api/files/write only replaces existing files (domain/files/file-write-service.ts:120–128; routes/files.ts:264–265). An audit failure can return 500 even though the file write succeeded, so an error is not proof of no effect.

Progress indexing

ProgressIndexer.scan walks configured roots on each request, to a default maximum depth of 6. It skips dot-prefixed entries and named build/dependency directories, and collects regular PROGRESS.md and STEERING.md files. Unreadable directories and files are skipped. Consequently, an empty scan does not prove all files were readable.

The parser skips frontmatter, takes the first H1 as the title, turns H2–H4 headings into hierarchy rows, and reads checkbox rows as [x]/[X] = done, [~] = blocked, and [ ] = active. Checkbox depth uses two-space indentation; headings use heading depth. Per-file and aggregate counts cover checkbox rows. This is a progress-text projection, not proof readiness.

GET /api/progress/tree returns the scan. Missing indexer or empty configured roots returns 503.

Steering composition

steeringOptsFromSettings combines resolved workspace settings with OPENRIG_STEERING_WORKSPACE, OPENRIG_STEERING_PATH, OPENRIG_ROADMAP_PATH, and OPENRIG_DELIVERY_READY_DIR overrides. SteeringComposer.compose reads:

Field Source and selection
priorityStack Verbatim STEERING.md, mtime, and byte count.
roadmapRail roadmap/PROGRESS.md [ ]/[x] rows ([~] rows are not read); the first unchecked row is marked next.
laneRails Every PROGRESS.md and STEERING.md under delivery-ready, using a depth-3 ProgressIndexer (lane ID mode-N for mode-N/PROGRESS.md, else the relative path); first non-done/non-blocked checkbox is next pull. Default top 3 prefers non-done rows, then fills with done rows.

Missing/unreadable sections become unavailableSources. isReady means at least one path resolves; it does not guarantee that every read succeeds. GET /api/steering returns 503 when the composer is absent or no source path resolves. Because workspace.steering_path always has a value (default <workspace.root>/STEERING.md), the daemon's composer is normally ready, and a missing STEERING.md appears as a priorityStack read failure in unavailableSources with a 200, not a 503. Queue and health state are fetched through their own endpoints.

Health summaries

healthSummaryRoutes exposes four reads under /api/health-summary:

Suffix Source
/nodes computeNodeHealthSummary: node inventory aggregated across rigs, grouped by session/lifecycle status, with an attention-required count.
/context computeContextHealthSummary: context_usage rows grouped by urgency and sample freshness.
/version getDaemonVersion: the build-stamped version, else the daemon package version, else unknown; always 200.
/gateway The injected gateway subsystem's status(); 503 if unavailable.

/nodes and /context return 503 health_summary_unavailable when the rig repository is not wired.

The context aggregation uses 80% for critical, 60% for warning, and 300 seconds for freshness. These are constants in this module: they do not follow the health.context_pressure.* settings (defaults 95% and 99%) that the health detectors use, so the two surfaces can disagree about the same session. A missing usage value is unknown; a missing timestamp has no freshness sample. The SQL read catches errors and falls back to an empty sample set, so zero counts alone are not a database-health guarantee.