Compare commits

...
38 Commits
Author SHA1 Message Date
bvandeusen f0c915a6bc Merge pull request 'Ledger follow-ups from the shape audit (milestone 294: #2868–#2874)' (#122) from dev into main
CI & Build / Build & push image (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 57s
CI & Build / integration (push) Successful in 21s
2026-08-21 15:24:03 -04:00
bvandeusenandClaude Fable 5 6a0f8ad328 feat(backup): code_shape_uses travels (v9); consumer-map test expects uses (#2870)
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:21:13 -04:00
bvandeusenandClaude Fable 5 d4c7b0e48d feat(ledger): uses edges — consumption is its own relation, conformance keeps one snippet_id (#2870, milestone 294)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Failing after 39s
CI & Build / Build & push image (push) Skipped
A shape can follow one convention canon AND call several helper canons; the
row's single snippet_id made the 2026-08 audit pick (hash_token won, the
service-function convention lost), and hook evidence — pulled a snippet, then
wrote code naming it — was stamped as instance when it is a uses fact.

- code_shape_uses (migration 0084): shape → snippet, basis, evidence; unique
  per pair; cascades with both ends. USE_BASES: reference | hook | agent |
  audit | import. A judgment-grade basis overwrites a mechanical one, never
  the reverse.
- classify_shapes items and classify_shapes_by_rule take uses=[snippet ids]
  (targets validated like snippet_id; all-or-nothing).
- The write-path hook writes a uses edge for every pulled canon the payload
  names (the instance stamp is unchanged); the proposer writes a uses edge
  for every canon a body names (reference_canons: kind + language family +
  stoplist, same rules as the reference basis) — the mechanical form of
  "auto-confirm own-import references" deferred from #2871.
- list_shapes(uses=N) lists the consumers of a canon; get_snippet's consumer
  map gains `uses` beside instances/variants.

Operator decision on #2870 (2026-08-21): keep one snippet_id, add uses edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:18:25 -04:00
bvandeusenandClaude Fable 5 bfe5a461b4 test(ledger): derive group sizes follow the #2872 order
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:15:32 -04:00
bvandeusenandClaude Fable 5 3849c6fff3 test(ledger): derive label order follows #2872; binding-ref test imports compute_coverage
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 22s
CI & Build / Python tests (push) Canceled after 22s
CI & Build / integration (push) Canceled after 21s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python lint (push) Successful in 4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:15:09 -04:00
bvandeusenandClaude Fable 5 1a8e5787e8 feat(ledger): reason codes, case-insensitive repo filter, next action on the coverage line (#2874, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Canceled after 32s
CI & Build / Python tests (push) Canceled after 31s
CI & Build / Build & push image (push) Canceled after 0s
- code_shapes.reason_code (migration 0083): an optional code from a fixed
  catalogue (scoped-css, one-off-handler, test-helper, convention-plumbing,
  pure-helper, generated, script, typed-record) beside the prose reason, so
  the ledger can be filtered/aggregated by kind of one-off; validated in
  classify_shapes and classify_shapes_by_rule; on to_dict/to_compact.
- Snippet location lookups match repo case-insensitively in both dialects
  (location_matches / location_jsonpath via like_regex flag "i") — "Scribe"
  vs "FabledScribe" vs "fabledscribe" recorded free-form hid half the canon
  from list_snippets(repo=, path=).
- coverage line names the next action: "top canon #N ×k" (biggest proposal
  queue) and "top copy <label> ×files" (widest body-identical group).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:14:34 -04:00
bvandeusenandClaude Fable 5 d01201539b test(ledger): derive summary expectation follows #2872 — dup group before name group
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / Python tests (push) Successful in 58s
CI & Build / integration (push) Failing after 20s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Build & push image (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:11:25 -04:00
bvandeusenandClaude Fable 5 1209e1c2d9 feat(ledger): a repo binding names the branch its ledger follows — bind_repo(ref=) (#2873, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Canceled after 30s
CI & Build / Python tests (push) Canceled after 30s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / integration (push) Failing after 25s
Project 2 is bound to main, so every consolidation of the 2026-08 audit was
invisible to the ledger until the dev→main merge; the operator works on dev
(rule 1). repo_bindings.ref (migration 0082, nullable) is the branch the
coverage refresh reads; NULL keeps the forge default branch. set_binding takes
ref (name sets, "" clears, None leaves standing); bindings_for_project feeds
the refresh; bind_repo exposes ref ("-" clears). to_dict carries it.

Operator decision on #2873 (2026-08-21): per-binding ref, chosen at bind time,
default the repo default branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:10:54 -04:00
bvandeusenandClaude Fable 5 57d68c9355 feat(ledger): derive readout ranks body-identical groups first; CSS fingerprints are declarations, not selectors (#2872, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 32s
The 2026-08 audit consolidated identical bodies under different names and
files (five auth views' CSS, two Workspace formatDate()s, four modal blocks)
while the derive readout led with name groups (to_dict ×25, main ×5, load ×6)
that were convention or coincidence.

- proposal_summary ranks dup:<sha> groups above name groups, wider file spread
  first, and carries `files`; scoped rows (#2869) are in the readout, since
  that is where view-level copies live.
- extract_definitions fingerprints a CSS rule by its declarations — the row
  identity already carries the selector — so .closed-msg / .error-block /
  .success-msg with one body are one dup group. One-time effect: judged css
  rows whose stored fingerprint predates this may show a recheck on the next
  refresh (the judgment stands; re-confirm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:08:57 -04:00
bvandeusenandClaude Fable 5 1126bbe84f test(ledger): the divergence acceptance case uses TS canon — proposer v3 gates sym bases by language family (#2871)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Canceled after 4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:07:48 -04:00
bvandeusenandClaude Fable 5 1ab614bfbe feat(ledger): the scoped bucket — by-construction one-offs are stamped by the sync, not judged by a person (#2869, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Failing after 25s
CI & Build / Python tests (push) Canceled after 51s
CI & Build / Build & push image (push) Canceled after 0s
The 2026-08 audit left 77% of Scribe's ledger `exempt`, most of it a Vue
component's scoped <style> rules and <script setup> functions — one-offs by
construction (unreachable from any other file) that add nothing when judged
one by one and bury the rows a person should look at.

- coverage: Definition carries its line; scoped_definitions() names, per
  .vue file, every sym and every css rule inside <style scoped>; ArchiveShape
  carries the flag.
- sync: such rows are stamped status=scoped / classified_by=mechanical with
  the by-construction reason (history event recorded); un-stamped back to
  unclassified if a later tree makes them ordinary; a judgment overrides.
- The machine still sees them: proposer, derive grouping, divergence, hook
  evidence, canonical stamping and classify_shapes_by_rule's default all
  treat unclassified + scoped as the unjudged set (_MECHANICAL_TODO). Only
  the human todo (status=unclassified) and largest_gaps exclude them.
- accounting counts `scoped`; coverage line and the project card legend show
  it; SHAPE_STATUSES gains it (no DB CHECK on status — no migration).
- shape-accounting skill documents the bucket; plugin 0.1.37.

Operator decision on #2869 (2026-08-21): keep extracting everything, stamp
mechanically, keep `exempt` a human judgment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:06:52 -04:00
bvandeusenandClaude Fable 5 9abc4443fb feat(ledger): audit surfaces — list_shapes(compact=True) and classify_shapes_by_rule, the sweep form of a judgment (#2868, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit judged 3,427 rows in 14 hand-driven batches through a raw
MCP client because a list_shapes page overflowed the tool budget and every
row had to be sent back one by one. Now:

- list_shapes(compact=True): path · symbol · kind · status · signature, plus
  snippet_id / by / proposal / diverges_from / recheck only when set. A full
  500-row page fits. CodeShape.to_compact() is the row shape.
- classify_shapes_by_rule(project_id, path, status, pattern=, kind=,
  snippet_id=, reason=, via=, include_judged=): ONE judgment over every
  unclassified live row under a directory whose symbol matches a glob;
  judged rows are untouched unless include_judged; canonical is refused;
  same gates as the row form; one transaction; returns count + sample.
  shape_ledger.classify_shapes_where / rule_matches (pure) carry it.

Tests: compact row pinned, rule_matches directory/glob/kind semantics, the
tool mount, and an integration sweep (unclassified-only, include_judged,
gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:02:55 -04:00
bvandeusenandClaude Fable 5 3a4031d7f8 fix(ledger): proposer v3 — sym bases gated by language family, reference skips generic verbs, semantic held to the shape's own project (#2871, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit examined 407 proposals. Every cross-language hit was wrong:
the Python MCP tool-module canon (symbol `register`) was offered for each auth
view's handleSubmit (it calls authStore.register()) and for the TS auth store's
own `register`; Minstrel/Forge TS canon matched Python bodies by resemblance.
Every cross-project semantic proposal was noise.

- Canon carries the snippet's language; match_canon skips a sym canon whose
  family (py / js / css / sh / sql, by language ↔ by path extension) differs
  from the shape's. Unknown on either side = no gate.
- The reference basis ignores a stoplist of generic verbs (register, load,
  save, get, …): a bare mention is not a call site of THIS canon; the symbol
  basis still catches a second definition, and the call-site relation moves
  to `uses` edges with #2870.
- The semantic arm only reaches canon in the shape's own project and family;
  symbol/text still reach family canon elsewhere (note 2786).
- _PROPOSER_VERSION 2 → 3 so standing proposals re-examine on the next refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:00:48 -04:00
bvandeusen 141246ac2c Merge pull request 'fix(frontend): drop the three .modal-overlay copies left after the modal canon move' (#121) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 39s
2026-08-21 12:58:25 -04:00
bvandeusenandClaude Fable 5 520381e22b fix(frontend): drop the three .modal-overlay copies the comment-preceded rule regex skipped — components.css owns it (#2831)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:52:29 -04:00
bvandeusen 58c074a324 Merge pull request 'Shape audit (milestone 296): tests helpers, alembic, models, MCP, routes, services, frontend consolidation' (#120) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 40s
2026-08-21 12:48:08 -04:00
bvandeusenandClaude Fable 5 2a6c55dacb refactor(frontend): auth-shared.css, apiErrorMessage, one date helper per shape, modal canon in components.css — the frontend pass of the shape audit (#2831 #2832, milestone 296)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 16s
- assets/auth-shared.css: the five auth views carried byte-identical scoped
  copies of the page/card/brand/footer/field/input/error rules (~60 lines
  each); they now load one stylesheet the way the editors load
  editor-shared.css. .closed-msg/.error-block/.success-msg (identical bodies)
  are one .auth-note; the form rules are scoped under .auth-card so nothing
  leaks into the rest of the app.
- api/client.apiErrorMessage(e, fallback): the one place the {"error"} envelope
  is unpacked; replaces ten six-line `"body" in e` catch blocks.
- utils/dateFormat: fmtDate / fmtStamp / fmtLogStamp replace eight local
  formatDate/formatTime copies (three byte-identical pairs); the file’s old
  Calendar/Home helpers had no callers and are gone. useRelativeTime gains
  relativeTimeOrDate for the two workspace panels’ identical variant.
- components.css now owns the .modal-* shape (overlay/card/title/message/
  actions/btn/primary/danger). It was copied into four views and lived in
  editor-shared.css, which ConfirmDialog — styleless, teleported to <body> —
  silently depended on: opened from SnippetDetailView before any editor view
  had loaded, it rendered unstyled. Views keep only their own overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:41:18 -04:00
bvandeusenandClaude Fable 5 7d48eb0b1b refactor(services): one periodic-task shape, one APScheduler job shape, one token hash, one summary rule — the services pass of the shape audit (#2830, milestone 296)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
- background.start_periodic(interval, work, label=) replaces the three hand-rolled
  while-True/sleep/try loops in logging, auth and notifications.
- services/scheduler.ScheduledJob replaces the four private BackgroundScheduler
  copies in recurrence/version_pinning/trash/db_maintenance schedulers; public
  start_/stop_/reschedule_ surfaces unchanged.
- api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x.
- auth.is_registration_open reads via settings.get_admin_setting; notification
  prefs read via settings.get_setting; _fire_share_email uses _get_user_email.
- projects.get_project_summary / milestones.get_project_milestone_summary are
  now the one-id view of their batch siblings instead of a second copy of the
  queries; sharing.best_permission_by (was _deduplicate_by_permission) is the
  one rank-dedup, now also used by list_projects_for_user.
- backup: the row builders for every section both exporters carry are named
  functions, so a column added to one export cannot silently miss the other.
- iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom
  and db_maintenance._iso across services; backup keeps its explicit shape.
- trash.py hoists the sql_delete/timedelta imports it re-imported per function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:34:28 -04:00
bvandeusenandClaude Fable 5 92e38ff17b test(routes): the prior-art contract guards read the handler plus its _project_scope helper (area 5 follow-up)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:26:01 -04:00
bvandeusenandClaude Fable 5 64c641ce80 refactor(routes): one supersession seam for REST and MCP; PUT/PATCH notes share a handler; shared mask/not-found/caller helpers (#2829, milestone 296 area 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Reading the 28 route modules against each other and against the MCP tools:
- routes/notes.py carried a PUT and a PATCH handler that were the same
  function minus the supersedes contract on one of them — one handler now
  serves both verbs, so both carry it.
- The two _attach_supersession copies (REST + MCP) become
  supersession_svc.attach_relations(uid, note_id, data, hint=) — the seam
  the two surfaces must agree through; only the agent surface adds the
  one-sentence reading hint.
- Three local _uid() wrappers over g.user.id → scribe.auth.get_current_user_id
  like every other module; design_systems' private _not_found → routes.utils.
  not_found; the four "********" literals → settings_svc.SECRET_MASK with the
  read/write contract written once.
- routes/plugin.py: the project_id/repo resolution block and the
  comma-separated id parse were copied into three endpoints — _project_scope()
  and _int_list() now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:23:47 -04:00
bvandeusenandClaude Fable 5 c211e12b61 refactor(mcp): one rules_payload() for every surface that hands rules to an agent; drop the dead bearer resolver (#2828, milestone 296 area 4)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 26s
Reading the 16 tool modules against each other: the six-key applicable-rules
block (applicable_rules, applicable_rules_truncated, subscribed_rulebooks,
project_rules, suppressed_rules, suppressed_topics) was hand-built in five
places — enter_project, get_project, get_task (legacy plans), get_milestone
(three of the six) and services/planning.start_planning. rulebooks_svc.
rules_payload() is now the one place that names them; get_milestone gains the
three it lacked, so every rules-carrying payload reads the same. list_rules /
list_always_on_rules share _rule_summary. mcp/auth.resolve_bearer_to_user_id
duplicated resolve_bearer's parsing and had no product caller (only its own
tests) — removed; the tests now exercise resolve_bearer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:20:36 -04:00
bvandeusenandClaude Fable 5 b0eda32575 refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:17:42 -04:00
bvandeusenandClaude Fable 5 848ce1592e fix(tests): import make_mock_session in test_version_pinning_prune (batch-2 follow-up)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:14:44 -04:00
bvandeusenandClaude Fable 5 77bb3729a3 refactor(tests): per-model fakes, FakeMCP and session mocks come from tests/helpers (#2825, milestone 296 area 1, batch 2)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped
Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:13:17 -04:00
bvandeusen ac6f248bf9 Merge pull request 'Shape audit area 1 — tests/: one definition each for the copied fixtures and fakes (#2825)' (#119) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
2026-08-21 11:07:11 -04:00
bvandeusenandClaude Fable 5 bbee0d0db1 refactor(tests): one definition each for the copied fixtures and fakes (#2825, milestone 296 area 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 18s
The shape ledger showed the same test scaffolding defined over and over:
_bind_user x12 (byte-identical), _dispose_engine x10 in three wordings,
_no_supersession x3, _make_mock_session x7 in three subsets, a get-or-create
User helper x2 (+3 inlined), and fifteen hand-rolled MagicMock note factories
each re-explaining the same "an auto-MagicMock attribute is truthy" hazard
(note 2109).

Now: conftest.py carries _bind_user / _dispose_engine / _no_supersession as
opt-in fixtures (pytestmark = usefixtures(...) per module, so unit tests pay
nothing), and tests/helpers.py carries make_mock_session(), ensure_user() and
fake_note(**attrs) — the hazard documented once, real values on every
attribute the product reads. Call sites were rewritten by AST so titles with
dashes and commas survived; the three SimpleNamespace _note stand-ins that
only feed a single function stay local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:03:48 -04:00
bvandeusen ac1c2625af Merge pull request 'fix(ledger): proposer precision — own-definition signatures, semantic floor 0.8, ruleset version' (#118) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 35s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 18s
2026-08-21 08:27:27 -04:00
bvandeusenandClaude Fable 5 dad56a51bd fix(ledger): the proposer matches signatures against a canon's own definition only and floors semantic at 0.8; ruleset version re-examines standing proposals
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 31s
First live run: a canon whose recorded code opens with a call-site example
(confirmed()'s onTrash) made every `async function x(): Promise<void>`
resemble it at 0.86, and the write-path semantic floor paired alembic
upgrade()/downgrade() bodies with unrelated canons at 0.68-0.75. Rows now
remember (body, ruleset) so a tightened rule looks again once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 07:42:12 -04:00
bvandeusen 79f0283608 Merge pull request 'Shape ledger steps 5–7: write-path stamping, mechanical proposer, divergence readout (#2791, #2792, #2793)' (#117) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 19s
2026-08-21 07:32:23 -04:00
bvandeusenandClaude Fable 5 efc650390e fix(plugin-context): the empty write-path payload carries the divergence key too
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:19:00 -04:00
bvandeusenandClaude Fable 5 d74a244b3a feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint
judged (classified_sha) and writes a code_shape_events row; the sync writes
vanished / reappeared / drifted events and flags recheck_at when a body
moves under an instance/variant. The refresh flags diverges_from on shapes
new since the previous computation that sit where one canon dominates the
judged siblings of their directory+kind and were not proposed as that canon
(a first seed flags nothing); the write-path hint asks the same question
in-band for the shapes the hook names. list_shapes(flag=divergence|recheck),
shape_history(project_id, path, symbol) (read-only), coverage line/payload/
card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:13:39 -04:00
bvandeusenandClaude Fable 5 386b27e422 fix(ledger): the proposer prefers a same-project canon on a tie — family canon elsewhere is the fallback, not the first hit
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Python lint (push) Successful in 5s
CI & Build / integration (push) Successful in 35s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Build & push image (push) Successful in 40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:36:10 -04:00
bvandeusenandClaude Fable 5 ba0030e51d feat(ledger): mechanical proposer — every refresh proposes instances against canon and groups derive-first candidates; agents confirm in batches (#2792, milestone 294 step 6)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
Shapes now carry a content fingerprint (signature + whitespace/comment-
insensitive body_sha; migration 0080) and the proposer runs inside the
coverage refresh, the one moment bodies exist: symbol elsewhere → textual
containment → body references the canon → signature resemblance → semantic
(capped per refresh, unreached rows stay unexamined for the next). A hit is
a proposal on the row (proposed_snippet_id/basis/score), never a
classification; rows with no canon hit group by the derive-first rule
(identical body in ≥2 places, same name in ≥3 files) as proposal_basis=
derive + a group key. list_shapes(proposal=any|canon|derive|<basis>) is the
queue; confirm_shape_proposals(project_id, snippet_id|path|basis) confirms
in batches as agent instances; any classify_shapes/hook stamp retires the
proposal. Readout carries proposed + derive_groups (line, payload, card).
Plugin 0.1.35 (skill: the machine proposes, judgment classifies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:30:35 -04:00
bvandeusenandClaude Fable 5 a9e1cddba7 fix(tests): provisional ledger rows carry the empty seen-marker default, not NULL
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:23:12 -04:00
bvandeusenandClaude Fable 5 475f0857c9 feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
The prior-art hook now names the shapes being written (shapes=kind:name —
every definition in the payload, or the one enclosing an Edit found by
walking the file upward) and the server stamps them as instance rows when
the session PULLED a snippet inside PULL_WINDOW that the payload references
by symbol or that the semantic arm scored for this very payload.
classified_by=hook, evidence in reason; never overrides a judgment or a
canonical row, overridable by classify_shapes. Offered-but-unopened stamps
nothing. Pulled-and-already-seen snippets stay in the semantic query as
evidence without re-entering the deduped menu. A brand-new shape gets a
provisional row the next sync confirms or vanishes. Read-scoped keys get
the hint, never the stamp. Plugin 0.1.34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:19:45 -04:00
bvandeusen 9d5b89d785 Merge pull request 'Coverage self-seeds + refresh_pattern_coverage tool + shape-accounting skill (#2802)' (#116) from dev into main
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 16s
2026-08-20 19:05:48 -04:00
bvandeusenandClaude Fable 5 3e21978a9c fix(tests): restore the assert my seed-test insertion orphaned from its neighbor
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:24:05 -04:00
bvandeusenandClaude Fable 5 e2a084f1fb feat(ledger): coverage self-seeds — enter_project background refresh + refresh_pattern_coverage tool + shape-accounting skill (#2802, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped
The UI Refresh button must not be the only seed path (operator directive,
hit live: the P7 backfill stalled waiting for a click). Three parts:

- enter_project fire-and-forgets refresh_if_stale on the project OWNER —
  absent or day-old readouts recompute in the background (same spawn the
  webhook path uses), the enter stays fast, forge-less owners exit quietly
  (rule #115 baseline), and an in-flight guard keeps concurrent enters from
  fetching the same tarball N times.
- refresh_pattern_coverage(project_id): the synchronous agent-facing form —
  write-gated, owner-keyring resolution, and ValueError messages that name
  the fix (add a connection / bind_repo) instead of measuring nothing
  silently.
- plugin 0.1.33 ships the shape-accounting skill: the five statuses, the
  seed/todo/judge loop, and the derive-first rule, triggered by the
  coverage line or any proved code-to-canon relationship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:18:36 -04:00
160 changed files with 5977 additions and 3147 deletions
@@ -0,0 +1,54 @@
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
Revision ID: 0080
Revises: 0079
Create Date: 2026-08-21
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
(definition line + a whitespace/comment-insensitive hash of its block) so the
proposer can match on content and a later drift recheck can notice change,
without the ledger ever storing code. The proposal columns carry the
proposer's standing suggestion for an unclassified row — instance-of-#N with
a basis and score, or a derive-first group key — and `proposed_sha`
remembers the content it was judged at so a refresh re-examines only what
changed. Mechanical and recomputable: a restore that lacks them loses
nothing the next refresh does not rebuild.
"""
import sqlalchemy as sa
from alembic import op
revision = "0080"
down_revision = "0079"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
op.add_column(
"code_shapes",
sa.Column(
"proposed_snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
op.create_index(
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
)
def downgrade() -> None:
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
for col in (
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
):
op.drop_column("code_shapes", col)
@@ -0,0 +1,70 @@
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
Revision ID: 0081
Revises: 0080
Create Date: 2026-08-21
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
a judgment was made at so a later body change under an instance/variant can
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
the previous refresh, where one canon dominates its directory+kind, and not
proposed as that canon). `code_shape_events` is the what-was-used-when
record: every classification, vanish, reappearance, and drift as it
happened — history the row alone cannot keep once it moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0081"
down_revision = "0080"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"code_shapes",
sa.Column(
"diverges_from",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
op.create_table(
"code_shape_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"shape_id",
sa.Integer(),
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
op.create_index(
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
)
def downgrade() -> None:
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
op.drop_table("code_shape_events")
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
for col in ("diverges_from", "recheck_at", "classified_sha"):
op.drop_column("code_shapes", col)
+26
View File
@@ -0,0 +1,26 @@
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
Revision ID: 0082
Revises: 0081
Create Date: 2026-08-21
A repo binding used to imply the repo's default branch; the shape ledger
therefore only saw work after a merge to main, while the operator's work
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
NULL keeps today's behaviour (the forge's default branch).
"""
import sqlalchemy as sa
from alembic import op
revision = "0082"
down_revision = "0081"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("repo_bindings", "ref")
@@ -0,0 +1,26 @@
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
Revision ID: 0083
Revises: 0082
Create Date: 2026-08-21
The 2026-08 audit wrote the same free-text reason thousands of times
("scoped rule — styles one element of this view"); a judgment's WHY stays
prose, but an optional code from a fixed catalogue makes the ledger
filterable and aggregable ("how many pure helpers, how many test helpers").
"""
import sqlalchemy as sa
from alembic import op
revision = "0083"
down_revision = "0082"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("code_shapes", "reason_code")
+40
View File
@@ -0,0 +1,40 @@
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
Revision ID: 0084
Revises: 0083
Create Date: 2026-08-21
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
a canon). But a shape can also CALL several canonical helpers — a service
function that is an instance of the service-function convention and a
consumer of hash_token. The 2026-08 audit had to pick one; hook evidence
("pulled #N then wrote code referencing it") was stamped as instance when it
is a uses fact. This table holds the many-valued relation: shape → snippet,
with the basis and the evidence. Cascades with the shape and the snippet.
"""
import sqlalchemy as sa
from alembic import op
revision = "0084"
down_revision = "0083"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_uses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("basis", sa.Text(), nullable=False),
sa.Column("evidence", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
)
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
op.drop_table("code_shape_uses")
+14
View File
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
export async function apiGet<T>(path: string): Promise<T> { export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path); const res = await fetch(path);
return handleResponse<T>(res, path); return handleResponse<T>(res, path);
+115
View File
@@ -0,0 +1,115 @@
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
The five auth views used to carry byte-identical copies of these rules in
their scoped blocks (2026-08 shape audit). Loaded per view with
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
rules are scoped under .auth-card so nothing leaks into the app's other
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
stay in the view. */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
/* A centred status paragraph block: registration closed, invalid/expired
token, "check your inbox". One rule — the views used to name it
.closed-msg / .error-block / .success-msg with identical bodies. */
.auth-note {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.auth-note p {
margin: 0.5rem 0;
}
.auth-loading {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.auth-card .field {
margin-bottom: 1rem;
}
.auth-card .field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.auth-card .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.auth-card .input:focus {
outline: none;
border-color: var(--fs-accent);
}
.auth-card .input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .input-error,
.auth-card .input-error:focus {
border-color: var(--fs-error);
}
.auth-card .field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.auth-card .error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.auth-card .error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
+76
View File
@@ -221,3 +221,79 @@
is the page's main action */ is the page's main action */
font-size: var(--fs-size-body-sm); font-size: var(--fs-size-body-sm);
} }
/* ── Modal ─────────────────────────────────────────────────────────────────
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
the create-project / merge-snippet / systems dialogs, the editors' confirm
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
styles of its own, so these must be loaded with the app, not with whichever
view happens to be open. Views add only their own overrides (a wider card,
a form layout). Destructive = action-destructive per the Hybrid rule. */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-message {
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 0 0 1.25rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.modal-btn-danger {
background: var(--fs-action-destructive);
border-color: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.modal-btn-danger:hover {
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
-47
View File
@@ -316,53 +316,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--fs-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--fs-error);
color: var(--fs-text-on-action);
border-color: var(--fs-error);
}
/* ── Floating inline assist button (teleported to body) ── */ /* ── Floating inline assist button (teleported to body) ── */
.inline-assist-btn { .inline-assist-btn {
position: fixed; position: fixed;
+2 -9
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client"; import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
import DiffView from "@/components/DiffView.vue"; import DiffView from "@/components/DiffView.vue";
import type { DiffLine } from "@/composables/useAssist"; import type { DiffLine } from "@/composables/useAssist";
import { fmtStamp } from "@/utils/dateFormat";
interface NoteVersion { interface NoteVersion {
id: number; id: number;
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
return result; return result;
}); });
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
async function loadVersions() { async function loadVersions() {
loading.value = true; loading.value = true;
try { try {
@@ -212,7 +205,7 @@ onMounted(loadVersions);
v-if="v.pin_kind === 'manual' && v.pin_label" v-if="v.pin_kind === 'manual' && v.pin_label"
class="history-item-label" class="history-item-label"
>{{ v.pin_label }}</div> >{{ v.pin_label }}</div>
<div class="history-item-date">{{ formatDate(v.created_at) }}</div> <div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
</div> </div>
</div> </div>
@@ -515,36 +515,4 @@ async function confirmDelete() {
} }
.skel-row--short { width: 65%; } .skel-row--short { width: 65%; }
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--fs-overlay);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--fs-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--fs-surface-page); }
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
</style> </style>
+2 -8
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import type { TaskLog } from "@/types/task"; import type { TaskLog } from "@/types/task";
import { fmtStamp } from "@/utils/dateFormat";
const props = defineProps<{ taskId: number }>(); const props = defineProps<{ taskId: number }>();
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
const editContent = ref(""); const editContent = ref("");
const editDuration = ref(""); const editDuration = ref("");
function formatDate(iso: string): string {
const d = new Date(iso);
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return `${datePart}, ${timePart}`;
}
function formatDuration(minutes: number): string { function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`; if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60); const h = Math.floor(minutes / 60);
@@ -128,7 +122,7 @@ onMounted(loadLogs);
</template> </template>
<template v-else> <template v-else>
<div class="log-entry-meta"> <div class="log-entry-meta">
<span class="log-date">{{ formatDate(log.created_at) }}</span> <span class="log-date">{{ fmtStamp(log.created_at) }}</span>
<span v-if="log.duration_minutes" class="log-duration-badge"> <span v-if="log.duration_minutes" class="log-duration-badge">
{{ formatDuration(log.duration_minutes) }} {{ formatDuration(log.duration_minutes) }}
</span> </span>
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue"; import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next"; import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{ const props = defineProps<{
projectId: number; projectId: number;
@@ -252,20 +253,6 @@ async function confirmDelete(id: number) {
} }
} }
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
watch(noteTitle, () => { dirty.value = true; }); watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); }); watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; }); watch(noteTags, () => { dirty.value = true; });
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
> >
<div class="note-row-main"> <div class="note-row-main">
<span class="note-row-title">{{ note.title || 'Untitled' }}</span> <span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span> <span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
</div> </div>
<div v-if="note.tags?.length" class="note-row-tags"> <div v-if="note.tags?.length" class="note-row-tags">
<span <span
+3 -16
View File
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue"; import TaskLogSection from "@/components/TaskLogSection.vue";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next"; import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{ projectId: number }>(); const props = defineProps<{ projectId: number }>();
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
deleteConfirmPending.value = false; deleteConfirmPending.value = false;
} }
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
onMounted(loadAll); onMounted(loadAll);
defineExpose({ reload: loadAll }); defineExpose({ reload: loadAll });
</script> </script>
@@ -256,7 +243,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span> <span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span> <span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span> <span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span> <span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li> </li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li> <li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul> </ul>
@@ -281,7 +268,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span> <span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span> <span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span> <span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span> <span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li> </li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li> <li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul> </ul>
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
return `${days}d ago`; return `${days}d ago`;
} }
/**
* relativeTime() for the recent past, a short date once it's a week old —
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
* panels used to carry identical copies of this.
*/
export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso);
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
if (days < 7) return relativeTime(iso);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
+24 -57
View File
@@ -1,65 +1,32 @@
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
/** "9:30 AM" */
export function fmtTime(dt: string): string {
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
export function fmtDateTime(dt: string, allDay: boolean): string {
const d = new Date(dt)
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
if (allDay) return datePart
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
}
/** /**
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM" * Shared date/time formatting — one rule per display shape. Views import
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15" * these instead of carrying a local formatDate(): the 2026-08 shape audit
* found eight copies across views/components, three of them byte-identical.
* (The previous Calendar/Home helpers in this file had no callers left and
* were removed in the same pass.)
*
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
*/ */
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}` /** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
export function fmtDate(iso: string): string {
if (_isSameDay(d, now)) return `Today${timeStr}` return new Date(iso).toLocaleDateString(undefined, {
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}` year: "numeric", month: "short", day: "numeric",
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr });
} catch {
return dt
}
} }
/** /** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15" export function fmtStamp(iso: string): string {
*/ return new Date(iso).toLocaleString(undefined, {
export function fmtDayLabel(dt: string): string { month: "short", day: "numeric", year: "numeric",
try { hour: "2-digit", minute: "2-digit",
const d = new Date(dt) });
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
if (_isSameDay(d, now)) return "Today"
if (_isSameDay(d, tomorrow)) return "Tomorrow"
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
} catch {
return dt
}
} }
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */ /** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
export function fmtCompact(dt: string, allDay: boolean): string { export function fmtLogStamp(iso: string): string {
const d = new Date(dt) return new Date(iso).toLocaleString(undefined, {
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) month: "short", day: "numeric",
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) hour: "2-digit", minute: "2-digit", second: "2-digit",
});
} }
+4 -88
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { apiPost } from "@/api/client"; import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
const email = ref(""); const email = ref("");
@@ -15,12 +15,7 @@ async function handleSubmit() {
await apiPost("/api/auth/forgot-password", { email: email.value }); await apiPost("/api/auth/forgot-password", { email: email.value });
submitted.value = true; submitted.value = true;
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Something went wrong");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Something went wrong";
} else {
error.value = "Something went wrong";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -55,7 +50,7 @@ async function handleSubmit() {
</form> </form>
</template> </template>
<div v-else class="success-msg"> <div v-else class="auth-note">
<p>If an account exists with that email address, you will receive a password reset link shortly.</p> <p>If an account exists with that email address, you will receive a password reset link shortly.</p>
<p>Check your email and follow the instructions to reset your password.</p> <p>Check your email and follow the instructions to reset your password.</p>
</div> </div>
@@ -67,83 +62,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+3 -78
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -30,12 +31,7 @@ async function handleSubmit() {
const redirect = (route.query.redirect as string) || "/"; const redirect = (route.query.redirect as string) || "/";
router.push(redirect); router.push(redirect);
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Login failed");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Login failed";
} else {
error.value = "Login failed";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -112,70 +108,8 @@ function loginWithOAuth() {
</main> </main>
</template> </template>
<style src="@/assets/auth-shared.css" />
<style scoped> <style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.divider { .divider {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -190,15 +124,6 @@ function loginWithOAuth() {
flex: 1; flex: 1;
border-top: 1px solid var(--fs-border-color); border-top: 1px solid var(--fs-border-color);
} }
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
.forgot-link { .forgot-link {
text-align: right; text-align: right;
margin: -0.5rem 0 0.75rem; margin: -0.5rem 0 0.75rem;
+2 -12
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from "vue";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import PaginationBar from "@/components/PaginationBar.vue"; import PaginationBar from "@/components/PaginationBar.vue";
import { fmtLogStamp } from "@/utils/dateFormat";
const toastStore = useToastStore(); const toastStore = useToastStore();
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id; expandedId.value = expandedId.value === id ? null : id;
} }
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDetails(details: string | null): string { function formatDetails(details: string | null): string {
if (!details) return ""; if (!details) return "";
try { try {
@@ -210,7 +200,7 @@ function clearFilters() {
:class="{ 'row-expanded': expandedId === entry.id }" :class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)" @click="toggleExpand(entry.id)"
> >
<td class="cell-time">{{ formatTime(entry.created_at) }}</td> <td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td> <td>
<span class="category-badge" :class="'cat-' + entry.category"> <span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }} {{ entry.category }}
-46
View File
@@ -559,24 +559,8 @@ function overallPct(project: Project): { total: number; pct: number } {
color: var(--fs-text-tertiary); color: var(--fs-text-tertiary);
} }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card { .modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px; max-width: 480px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -618,36 +602,6 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-textarea { .modal-textarea {
resize: vertical; resize: vertical;
} }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.projects-grid { .projects-grid {
+64 -26
View File
@@ -427,6 +427,13 @@ interface CoverageGap {
unclassified: number; unclassified: number;
total: number; total: number;
} }
interface DeriveGroup {
group: string;
kind: string;
label: string;
size: number;
paths: string[];
}
interface Coverage { interface Coverage {
total: number; total: number;
accounted: number; accounted: number;
@@ -436,6 +443,21 @@ interface Coverage {
computed_at: string; computed_at: string;
repos: { repo: string; ref: string; total: number; accounted: number }[]; repos: { repo: string; ref: string; total: number; accounted: number }[];
largest_gaps: CoverageGap[]; largest_gaps: CoverageGap[];
// The mechanical proposer's standing (#2792): canon proposals awaiting an
// agent's confirm, and the biggest repeats-with-no-canon families.
proposed?: number;
derive_groups?: DeriveGroup[];
// The divergence readout (#2793): button B where button A is canon, and
// judged shapes whose bodies moved since they were judged.
divergent?: number;
divergence?: Divergence[];
recheck?: number;
}
interface Divergence {
path: string;
symbol: string;
kind: string;
canon_snippet_id: number;
} }
const coverage = ref<Coverage | null>(null); const coverage = ref<Coverage | null>(null);
@@ -732,7 +754,7 @@ async function confirmDelete() {
</div> </div>
<div v-if="coverage.counts" class="coverage-gaps"> <div v-if="coverage.counts" class="coverage-gaps">
<span <span
v-for="k in ['canonical', 'instance', 'variant', 'exempt']" v-for="k in ['canonical', 'instance', 'variant', 'exempt', 'scoped']"
:key="k" :key="k"
> >
<span v-if="coverage.counts[k]" class="coverage-gap-chip"> <span v-if="coverage.counts[k]" class="coverage-gap-chip">
@@ -746,6 +768,46 @@ async function confirmDelete() {
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span> {{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span>
</span> </span>
</div> </div>
<div
v-if="coverage.proposed || coverage.derive_groups?.length"
class="coverage-gaps"
title="The proposer matched these against canon; an agent confirms them in batches (confirm_shape_proposals)."
>
<span class="coverage-gaps-label">Proposed:</span>
<span v-if="coverage.proposed" class="coverage-gap-chip">
{{ coverage.proposed }} awaiting confirm
</span>
<span
v-for="g in coverage.derive_groups || []"
:key="g.group"
class="coverage-gap-chip"
:title="'Repeats with no canon — derive one first. ' + g.paths.join(', ')"
>
{{ g.label }} <span class="coverage-gap-count">×{{ g.size }}</span>
</span>
</div>
<div
v-if="coverage.divergent || coverage.recheck"
class="coverage-gaps"
title="Divergent: a new shape where one canon dominates its directory and that isn't proposed as that canon — button B where button A is canon. Recheck: a judged shape whose body changed since it was judged."
>
<span class="coverage-gaps-label">Divergence:</span>
<span
v-for="d in coverage.divergence || []"
:key="d.path + '::' + d.symbol"
class="coverage-gap-chip coverage-divergent"
:title="d.path + ' — canon here is snippet #' + d.canon_snippet_id"
>
{{ d.kind === 'css' ? '.' : '' }}{{ d.symbol }}
<span class="coverage-gap-count"> #{{ d.canon_snippet_id }}</span>
</span>
<span v-if="(coverage.divergent || 0) > (coverage.divergence?.length || 0)" class="coverage-gap-chip">
+{{ (coverage.divergent || 0) - (coverage.divergence?.length || 0) }} more
</span>
<span v-if="coverage.recheck" class="coverage-gap-chip">
{{ coverage.recheck }} to recheck
</span>
</div>
</template> </template>
<p v-else class="coverage-empty"> <p v-else class="coverage-empty">
Not measured yet Refresh reads the bound repo's definitions into Not measured yet Refresh reads the bound repo's definitions into
@@ -1300,6 +1362,7 @@ async function confirmDelete() {
font-size: 0.74rem; font-size: 0.74rem;
} }
.coverage-gap-count { opacity: 0.65; } .coverage-gap-count { opacity: 0.65; }
.coverage-divergent { border-color: var(--fs-warning); }
.coverage-empty { .coverage-empty {
margin: 0; margin: 0;
font-size: 0.8rem; font-size: 0.8rem;
@@ -1752,31 +1815,6 @@ async function confirmDelete() {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
z-index: 200; z-index: 200;
} }
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--fs-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--fs-surface-page); }
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
/* ── Skeleton ────────────────────────────────────────────────── */ /* ── Skeleton ────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } } @keyframes skel-shine { to { background-position: 200% center; } }
+5 -109
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client"; import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
@@ -68,12 +68,7 @@ async function handleSubmit() {
await authStore.checkAuth(); await authStore.checkAuth();
router.push("/"); router.push("/");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Registration failed");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -85,9 +80,9 @@ async function handleSubmit() {
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div> <div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
<div v-if="validating" class="loading-msg">Validating invitation...</div> <div v-if="validating" class="auth-loading">Validating invitation...</div>
<div v-else-if="!token || !valid" class="error-block"> <div v-else-if="!token || !valid" class="auth-note">
<p>This invitation link is invalid or has expired.</p> <p>This invitation link is invalid or has expired.</p>
<p class="auth-footer"> <p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link> <router-link to="/login">Back to Sign In</router-link>
@@ -157,103 +152,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+5 -104
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -39,12 +40,7 @@ async function handleSubmit() {
await authStore.register(username.value, password.value, email.value || undefined); await authStore.register(username.value, password.value, email.value || undefined);
router.push("/"); router.push("/");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Registration failed");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -56,9 +52,9 @@ async function handleSubmit() {
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div> <div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<div v-if="checking" class="loading-msg">Checking registration status...</div> <div v-if="checking" class="auth-loading">Checking registration status...</div>
<div v-else-if="!authStore.registrationOpen" class="closed-msg"> <div v-else-if="!authStore.registrationOpen" class="auth-note">
<p>Registration is currently closed.</p> <p>Registration is currently closed.</p>
<p>Contact an administrator to get an account.</p> <p>Contact an administrator to get an account.</p>
<p class="auth-footer"> <p class="auth-footer">
@@ -130,99 +126,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.closed-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.closed-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+5 -108
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { apiPost } from "@/api/client"; import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
const route = useRoute(); const route = useRoute();
@@ -35,12 +35,7 @@ async function handleSubmit() {
}); });
success.value = true; success.value = true;
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Failed to reset password");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Failed to reset password";
} else {
error.value = "Failed to reset password";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -52,7 +47,7 @@ async function handleSubmit() {
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div> <div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
<div v-if="!token" class="error-block"> <div v-if="!token" class="auth-note">
<p>Invalid reset link. Please request a new password reset.</p> <p>Invalid reset link. Please request a new password reset.</p>
<p class="auth-footer"> <p class="auth-footer">
<router-link to="/forgot-password">Request new link</router-link> <router-link to="/forgot-password">Request new link</router-link>
@@ -94,7 +89,7 @@ async function handleSubmit() {
</form> </form>
</template> </template>
<div v-else class="success-msg"> <div v-else class="auth-note">
<p>Your password has been reset successfully.</p> <p>Your password has been reset successfully.</p>
<p>You can now sign in with your new password.</p> <p>You can now sign in with your new password.</p>
</div> </div>
@@ -106,102 +101,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+9 -37
View File
@@ -3,10 +3,11 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client"; import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth"; import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue"; import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
const store = useSettingsStore(); const store = useSettingsStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -624,12 +625,7 @@ async function changeEmail() {
emailPassword.value = ""; emailPassword.value = "";
toastStore.show("Email updated successfully"); toastStore.show("Email updated successfully");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to update email"), "error");
const b = (e as { body?: { error?: string } }).body;
toastStore.show(b?.error || "Failed to update email", "error");
} else {
toastStore.show("Failed to update email", "error");
}
} finally { } finally {
changingEmail.value = false; changingEmail.value = false;
} }
@@ -663,12 +659,7 @@ async function changePassword() {
newPassword.value = ""; newPassword.value = "";
confirmNewPassword.value = ""; confirmNewPassword.value = "";
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to change password", "error");
} else {
toastStore.show("Failed to change password", "error");
}
} finally { } finally {
changingPassword.value = false; changingPassword.value = false;
} }
@@ -766,12 +757,7 @@ async function sendTestEmail() {
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() }); await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
toastStore.show("Test email sent successfully"); toastStore.show("Test email sent successfully");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send test email", "error");
} else {
toastStore.show("Failed to send test email", "error");
}
} finally { } finally {
sendingTest.value = false; sendingTest.value = false;
} }
@@ -1129,14 +1115,6 @@ function toggleLogExpand(id: number) {
expandedLogId.value = expandedLogId.value === id ? null : id; expandedLogId.value = expandedLogId.value === id ? null : id;
} }
function formatLogTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function formatLogDetails(details: string | null): string { function formatLogDetails(details: string | null): string {
if (!details) return ""; if (!details) return "";
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; } try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
@@ -1224,12 +1202,6 @@ async function deleteUser(userId: number) {
deleting.value = null; deleting.value = null;
} }
} }
function formatUserDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
</script> </script>
<template> <template>
@@ -2355,8 +2327,8 @@ function formatUserDate(iso: string): string {
<tbody> <tbody>
<tr v-for="inv in invitations" :key="inv.id"> <tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td> <td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null"> <button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }} {{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
@@ -2391,7 +2363,7 @@ function formatUserDate(iso: string): string {
{{ u.role }} {{ u.role }}
</span> </span>
</td> </td>
<td class="hide-mobile cell-date">{{ formatUserDate(u.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<template v-if="u.id === authStore.user?.id"> <template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span> <span class="you-label">You</span>
@@ -2474,7 +2446,7 @@ function formatUserDate(iso: string): string {
<tbody> <tbody>
<template v-for="entry in logs" :key="entry.id"> <template v-for="entry in logs" :key="entry.id">
<tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)"> <tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
<td class="cell-time">{{ formatLogTime(entry.created_at) }}</td> <td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td> <td>
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span> <span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
</td> </td>
-45
View File
@@ -903,23 +903,8 @@ function usageTitle(s: SnippetListItem): string {
} }
/* Merge modal */ /* Merge modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card { .modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 460px; max-width: 460px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -966,36 +951,6 @@ function usageTitle(s: SnippetListItem): string {
color: var(--fs-text-tertiary); color: var(--fs-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.snippets-grid { .snippets-grid {
+7 -24
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth"; import type { User } from "@/types/auth";
import { fmtDate } from "@/utils/dateFormat";
interface Invitation { interface Invitation {
id: number; id: number;
@@ -69,12 +70,7 @@ async function sendInvite() {
inviteEmail.value = ""; inviteEmail.value = "";
await fetchInvitations(); await fetchInvitations();
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send invitation", "error");
} else {
toastStore.show("Failed to send invitation", "error");
}
} finally { } finally {
sendingInvite.value = false; sendingInvite.value = false;
} }
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId); users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted"); toastStore.show("User deleted");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete user", "error");
} else {
toastStore.show("Failed to delete user", "error");
}
} finally { } finally {
deleting.value = null; deleting.value = null;
} }
} }
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script> </script>
<template> <template>
@@ -212,8 +195,8 @@ function formatDate(iso: string): string {
<tbody> <tbody>
<tr v-for="inv in invitations" :key="inv.id"> <tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td> <td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<button <button
class="btn-ghost btn-compact" class="btn-ghost btn-compact"
@@ -255,7 +238,7 @@ function formatDate(iso: string): string {
{{ u.role }} {{ u.role }}
</span> </span>
</td> </td>
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<template v-if="u.id === authStore.user?.id"> <template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span> <span class="you-label">You</span>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.32", "version": "0.1.37",
"author": { "name": "Bryan Van Deusen" }, "author": { "name": "Bryan Van Deusen" },
"mcpServers": { "mcpServers": {
"scribe": { "scribe": {
+56 -6
View File
@@ -14,6 +14,12 @@
# on an instance with no forge connection (decision #2707). Everything else is # on an instance with no forge connection (decision #2707). Everything else is
# the REUSE menu. The two dedup separately (see the state files below). # the REUSE menu. The two dedup separately (see the state files below).
# #
# It is also the shape ledger's write-path feed (#2791): it names the
# definitions being written (`shapes=`), and the server — only when the
# session has PULLED a snippet this code references or resembles — records
# them as instance rows, classified_by=hook. Evidence, not judgment; the
# context line says what landed so a wrong stamp is corrected in the moment.
#
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so # NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
# the write proceeds untouched and Claude sees the note beside the tool result. # the write proceeds untouched and Claude sees the note beside the tool result.
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A # Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
@@ -96,10 +102,14 @@ fi
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded # `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
# because several per type is normal Rust, not duplication. # because several per type is normal Rust, not duplication.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
local_lines="" # kind<TAB>name for each thing a piece of code DEFINES, in source order. One
if [ -n "$repo_root" ] && [ -n "$code" ]; then # program, two consumers: the local duplicate arm (every definition in the
# kind<TAB>name for each thing this payload DEFINES. # payload) and the ledger feed (#2791, below: the definitions being written,
names=$(printf '%s' "$code" | awk ' # or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
scribe_defs() {
awk '
{ {
# CSS class definition: .name { or .name, # CSS class definition: .name { or .name,
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) { if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
@@ -132,8 +142,16 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then
if (t != "") print "sym\t" t; next if (t != "") print "sym\t" t; next
} }
} }
' 2>/dev/null | sort -u | head -12) || names="" ' 2>/dev/null
}
names=""
if [ -n "$code" ]; then
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
fi
local_lines=""
if [ -n "$repo_root" ] && [ -n "$names" ]; then
while IFS=$'\t' read -r kind name; do while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue [ -n "${name:-}" ] || continue
case "$kind" in case "$kind" in
@@ -156,6 +174,38 @@ if [ -n "$local_lines" ]; then
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}" local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
fi fi
# ---------------------------------------------------------------------------
# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition
# in the bound repo, classified against recorded canon — and this hook is the
# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the
# shapes in play: every definition in the payload, or — for an Edit that
# changes the inside of a function rather than its signature — the definition
# enclosing the edit, found by walking the target file upward from the edited
# lines. The server decides whether evidence exists (the session pulled a
# snippet this code references or resembles) and stamps instance rows; with
# no pulled canon in play, nothing is recorded. Titles only still — this sends
# names, not bodies.
# ---------------------------------------------------------------------------
shapes="$names"
if [ -z "$shapes" ] && [ -f "$file_path" ] && command -v tac >/dev/null 2>&1; then
old_first=$(printf '%s' "$event" \
| jq -r '.tool_input.old_string // .tool_input.old_str // empty' 2>/dev/null \
| grep -m1 -v '^[[:space:]]*$') || old_first=""
if [ -n "$old_first" ]; then
ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln=""
if [ -n "$ln" ]; then
shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1) || shapes=""
fi
fi
fi
shapes_q=""
if [ -n "$shapes" ]; then
enc=$(printf '%s\n' "$shapes" \
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
| jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
fi
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded ${...} placeholder arriving as a literal. # Guard against an unexpanded ${...} placeholder arriving as a literal.
@@ -230,7 +280,7 @@ fi
# finding that needed no instance to produce. # finding that needed no instance to produce.
body=$(curl -fsS --max-time 5 \ body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \ -H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}" 2>/dev/null) || body="" "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body=""
context="" context=""
if [ -n "$body" ]; then if [ -n "$body" ]; then
+3
View File
@@ -34,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that.
to ask both at once. to ask both at once.
- If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its - If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its
`location` points at the reference implementation. Adapt, don't re-derive. `location` points at the reference implementation. Adapt, don't re-derive.
The pull also does the accounting: the code you then write that references
or resembles it is stamped an `instance` of that canon in the shape ledger
(classified_by=hook) — reuse from memory leaves no row.
- If auto-inject already surfaced a snippet title that looks relevant, that's - If auto-inject already surfaced a snippet title that looks relevant, that's
your cue to `get_snippet` it rather than start from scratch. your cue to `get_snippet` it rather than start from scratch.
- **Prior art offered beside a write is not noise — read it.** When Scribe notes - **Prior art offered beside a write is not noise — read it.** When Scribe notes
+113
View File
@@ -0,0 +1,113 @@
---
name: shape-accounting
description: Use when a project's shape accounting needs attention — the pattern_coverage line from enter_project shows unclassified shapes or is missing on a forge-served project, the operator asks about coverage/accounting/canon, or you just proved a code-to-canon relationship (an audit enumerated call sites, a consolidation repointed consumers, a verify pass confirmed a helper's users). Triggers on "coverage", "accounted", "unclassified", "classify shapes", "what uses this", or finishing any consolidation.
---
# Shape accounting — every shape classified against canon
The snippet library records **canon** (small); the shape ledger accounts for
**every extracted definition** in a project's bound repos (total). Each ledger
row carries a status:
- `canonical` — IS a snippet's reference (the coverage sync stamps these
mechanically; you rarely set it).
- `instance` of snippet N — conforms to recorded canon. Canon in another
project counts (a family-level button shape fully accounts for a local use).
- `variant` of snippet N — a deliberate, named departure. **Reason required**
— the why IS the record.
- `exempt` — judged genuinely one-off. **Reason required.** A recorded
judgment, not silence — it stops the next pass re-litigating it.
- `scoped` — one-off **by construction**, stamped by the coverage sync
(a Vue component's scoped `<style>` rules and its `<script setup>`
functions — unreachable from any other file). Accounted for without a
judgment; still proposed against, grouped and flagged; any judgment you
make overrides it. Not the todo.
- `unclassified` — nobody has judged it yet. **This is the todo list.**
## The loop
1. **Seed / refresh** — the ledger fills from coverage computation. Entering a
project triggers a background seed automatically; when you need it current
*now* (before a classification batch, or when the line is missing on a
forge-served project), call `refresh_pattern_coverage(project_id)` — it
returns the fresh accounting line. Takes seconds; it moves repo archives.
2. **Read the todo**`list_shapes(project_id, status="unclassified")`,
optionally scoped by `path` to the directories the coverage line names as
largest. `snippet_id=N` reads a consumer map.
3. **Judge in batches** — `classify_shapes(project_id, [{path, symbol,
status, snippet_id?, reason?}])`. All-or-nothing: a bad item applies
nothing. Rows, never prose — a consumer list in a note or verification
detail cannot be sorted, queried, or diffed.
## Rows that arrive on their own
Two feeds keep the ledger current between your batches, so most shapes never
need a hand judgment:
- **The sync** stamps a snippet's own reference location `canonical`
(`classified_by: mechanical`).
- **The write path** stamps instances as you work: when you `get_snippet` a
canon and then Write/Edit code that references or resembles it, the
definitions being written land as `instance` rows (`classified_by: hook`,
the evidence in `reason`), and the prior-art hook tells you what landed
("Shape accounting: recorded at … → instance of #N"). Offered-but-unopened
snippets stamp nothing — so *pull the canon you are instantiating*; that
pull is what turns your reuse into accounting. A hook row is evidence, not
judgment: it never overrides a classification you made, and a
`classify_shapes` call overrides it.
## The machine proposes, judgment classifies
Every coverage refresh runs the **mechanical proposer** over the unclassified
rows: same symbol as a canon elsewhere → textual containment → body
references a canon → signature resemblance → semantic (capped per refresh). A hit
is a *proposal* on the row, never a classification. Work the queue in bulk:
1. `list_shapes(project_id, proposal="canon", snippet_id=N)` or
`path="dir"` — read the page; `proposal` carries snippet_id, basis, score.
2. `confirm_shape_proposals(project_id, snippet_id=N)` (or `path=`,
`basis=`) for the ones that hold — hundreds at a time; `symbol` and
`reference` proposals are near-certain, `semantic` deserves a look.
3. `classify_shapes` the rest — variant, exempt, or instance of a different
snippet. Any judgment retires the proposal.
`list_shapes(project_id, proposal="derive")` lists the **derive-first
candidates** — the same body in ≥2 places or the same name defined in ≥3
files, with no canon at all (`proposal.group` names the family; the coverage
payload's `derive_groups` ranks the biggest). That is the consolidation
queue, not a classification queue: see below.
## The derive-first rule
N same-shaped occurrences matching **no** recorded canon is never N loose
classifications — it is a consolidation candidate: derive one reference from
the dominant form, `create_snippet` it, migrate the outliers, then classify
the rest as instances. Canon is determined from the code; consistency comes
from the derivation, not from asking permission.
## The divergence readout — button B where button A is canon
Three questions the ledger answers mechanically (#2793):
- **Divergence** — `list_shapes(project_id, flag="divergence")` (and the
coverage line's "N DIVERGENT"): a shape new since the previous refresh, in
a directory where one canon dominates the judged siblings, that the
proposer did not match to that canon. `diverges_from` names the canon.
Judge it: `instance` if it should be built from the canon (and rebuild
it), `variant` with the why if the departure is deliberate. The write-path
hook asks the same question in-band the moment such a shape is written.
- **History** — `shape_history(project_id, path, symbol?)`: the current rows
plus every `classified` / `vanished` / `reappeared` / `drifted` event with
its commit — "instance of #N from <date>, re-judged variant of #M because
R, vanished at C". Rows, not recollection.
- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged
instances/variants whose body changed since judged. The judgment stands;
re-confirm it (classify again with the same status) or re-judge.
## What this buys
Divergence becomes mechanical: when button B appears where button A is canon,
the ledger says *unintended divergence* or *justified variant with its
reason* — nobody re-derives the history. `get_snippet` shows each snippet's
`instances` and `variants`, so "what uses this?" is answered from rows before
any contract change lands on its consumers.
-14
View File
@@ -4,20 +4,6 @@ from __future__ import annotations
from scribe.services.api_keys import lookup_key from scribe.services.api_keys import lookup_key
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
Returns None if the header is missing, malformed, or the token is invalid
or revoked. The underlying lookup_key already updates last_used_at on hit.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[len("Bearer "):].strip()
if not raw_token:
return None
api_key = await lookup_key(raw_token)
return api_key.user_id if api_key else None
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None: async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
"""Resolve a Bearer token to (user_id, scope). """Resolve a Bearer token to (user_id, scope).
+1 -1
View File
@@ -112,7 +112,7 @@ _READ_ONLY_TOOLS = frozenset({
"list_repo_bindings", "list_repo_bindings",
# The shape ledger's todo query (#2789). Reads only — classify_shapes is # The shape ledger's todo query (#2789). Reads only — classify_shapes is
# the write, and it is deliberately NOT here. # the write, and it is deliberately NOT here.
"list_shapes", "list_shapes", "shape_history",
}) })
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that # Read-SHAPED tools that must NOT be reachable with a read key — a getter that
+1 -3
View File
@@ -57,9 +57,7 @@ async def get_milestone(milestone_id: int) -> dict:
return { return {
"milestone": out, "milestone": out,
"steps": [t.to_dict() for t in steps], "steps": [t.to_dict() for t in steps],
"applicable_rules": applicable["rules"], **rulebooks_svc.rules_payload(applicable),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
} }
+3 -27
View File
@@ -62,30 +62,6 @@ async def list_notes(
return {"notes": [n.to_dict() for n in rows], "total": total} return {"notes": [n.to_dict() for n in rows], "total": total}
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Add both directions of the supersession relation to a note payload.
Both, because they answer different questions and only one of them is
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
READER needs and what the note itself cannot know — a stale record handed
over without that marker gets acted on confidently, which is worse than
never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
permanently-empty lists. A field that always says nothing trains readers to
skip fields, which is the lesson `consolidated_at` cost us (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
data["superseded_note"] = (
"A later note claims to bring this up to date — see superseded_by. "
"Read this as what was true when written, and check the newer one "
"before acting on it."
)
async def get_note(note_id: int) -> dict: async def get_note(note_id: int) -> dict:
"""Fetch the full content of a single Scribe note by its ID. """Fetch the full content of a single Scribe note by its ID.
@@ -113,7 +89,7 @@ async def get_note(note_id: int) -> dict:
# snippets would leave those permanently at zero pulls and make them look # snippets would leave those permanently at zero pulls and make them look
# like dead weight next to snippets that merely had a counter (#2085). # like dead weight next to snippets that merely had a counter (#2085).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note") record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
await _attach_supersession(uid, note_id, out) await supersession_svc.attach_relations(uid, note_id, out, hint=True)
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id
) )
@@ -186,7 +162,7 @@ async def create_note(
raise ValueError(str(exc)) from exc raise ValueError(str(exc)) from exc
data = note.to_dict() data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await _attach_supersession(uid, note.id, data) await supersession_svc.attach_relations(uid, note.id, data, hint=True)
return data return data
@@ -237,7 +213,7 @@ async def update_note(
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
) )
await _attach_supersession(uid, note_id, data) await supersession_svc.attach_relations(uid, note_id, data, hint=True)
return data return data
+16 -13
View File
@@ -26,6 +26,7 @@ from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.services.background import spawn
from scribe.services.note_usage import record_surfaced from scribe.services.note_usage import record_surfaced
@@ -68,7 +69,9 @@ async def enter_project(project_id: int) -> dict:
unclassified, largest: internal/api". Unclassified IS the todo: as you unclassified, largest: internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet. canon that's missing with create_snippet. A null line on a forge-served
project usually means the ledger is seeding in the background (entering
triggers it); refresh_pattern_coverage computes it on the spot.
`systems` is the project's vocabulary of named subsystems/areas. It is `systems` is the project's vocabulary of named subsystems/areas. It is
returned here so you can TAG as you write: when creating or meaningfully returned here so you can TAG as you write: when creating or meaningfully
@@ -164,6 +167,16 @@ async def enter_project(project_id: int) -> dict:
coverage = await coverage_svc.cached_coverage( coverage = await coverage_svc.cached_coverage(
project.user_id or uid, project_id project.user_id or uid, project_id
) )
# Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
# the BACKGROUND — entering is the moment the number is wanted, and the
# UI button must not be the only path. This enter stays fast; the next
# one carries the line. Forge-less projects exit the seed quietly.
spawn(
coverage_svc.refresh_if_stale(
project.user_id or uid, project_id, cached=coverage
),
site="enter_project.coverage_seed",
)
out = { out = {
"project": project.to_dict(), "project": project.to_dict(),
@@ -179,12 +192,7 @@ async def enter_project(project_id: int) -> dict:
], ],
"design_system": design_system, "design_system": design_system,
"milestone_summary": milestone_summary, "milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"], **rulebooks_svc.rules_payload(applicable),
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"open_tasks": [ "open_tasks": [
{ {
"id": t.id, "title": t.title, "status": t.status, "id": t.id, "title": t.title, "status": t.status,
@@ -226,12 +234,7 @@ async def get_project(project_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules( applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid, project_id=project_id, user_id=uid,
) )
data["applicable_rules"] = applicable["rules"] data.update(rulebooks_svc.rules_payload(applicable))
data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data return data
+18 -3
View File
@@ -13,28 +13,43 @@ from scribe.services import projects as projects_svc
from scribe.services import repo_bindings as repo_bindings_svc from scribe.services import repo_bindings as repo_bindings_svc
async def bind_repo(repo_url: str, project_id: int) -> dict: async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
"""Bind a git repository to a Scribe project for session-start context. """Bind a git repository to a Scribe project for session-start context.
After this, any session started in that repo auto-loads the project's After this, any session started in that repo auto-loads the project's
context (the SessionStart hook sends the repo's remote; the server resolves context (the SessionStart hook sends the repo's remote; the server resolves
it here). Idempotent — re-binding the same repo updates the target project. it here). Idempotent — re-binding the same repo updates the target project.
The binding is also what the shape ledger reads (refresh_pattern_coverage):
`ref` names the branch it follows. Default (""): the repo's default branch
— which means the ledger only sees work after a merge. A dev-first project
(rule 1: dev is home) should bind with ref="dev" so classification follows
the push, not the merge. Re-binding with ref="" keeps the standing ref;
pass ref="-" to clear it back to the default branch.
Args: Args:
repo_url: the repo's git remote (e.g. the output of repo_url: the repo's git remote (e.g. the output of
`git remote get-url origin` — ssh or https form, both work). `git remote get-url origin` — ssh or https form, both work).
project_id: the Scribe project this repo represents. project_id: the Scribe project this repo represents.
ref: branch the ledger follows ("" = leave as is / default branch on
a new binding; "-" = clear to the default branch).
""" """
uid = current_user_id() uid = current_user_id()
project = await projects_svc.get_project(uid, project_id) project = await projects_svc.get_project(uid, project_id)
if project is None: if project is None:
raise ValueError(f"project {project_id} not found") raise ValueError(f"project {project_id} not found")
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id) ref_arg = None if not ref else ("" if ref.strip() == "-" else ref)
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg)
follows = binding.ref or "the default branch"
return { return {
"repo_key": binding.repo_key, "repo_key": binding.repo_key,
"project_id": binding.project_id, "project_id": binding.project_id,
"project_title": project.title, "project_title": project.title,
"message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).", "ref": binding.ref,
"message": (
f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}); "
f"the ledger follows {follows}."
),
} }
+8 -20
View File
@@ -193,6 +193,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
# ── Rule CRUD ────────────────────────────────────────────────────────── # ── Rule CRUD ──────────────────────────────────────────────────────────
def _rule_summary(r) -> dict:
"""The list-row shape for a rule: what an agent needs to APPLY it. The
full record (why, how_to_apply, timestamps) is get_rule's job."""
return {"id": r.id, "title": r.title, "statement": r.statement, "topic_id": r.topic_id}
async def list_rules( async def list_rules(
rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0, rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
) -> dict: ) -> dict:
@@ -213,16 +219,7 @@ async def list_rules(
topic_id=topic_id or None, topic_id=topic_id or None,
project_id=project_id or None, project_id=project_id or None,
) )
return { return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rows
],
"total": len(rows),
}
async def list_always_on_rules() -> dict: async def list_always_on_rules() -> dict:
@@ -235,16 +232,7 @@ async def list_always_on_rules() -> dict:
""" """
uid = current_user_id() uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid) rules = await rulebooks_svc.list_always_on_rules(uid)
return { return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rules
],
"total": len(rules),
}
async def get_rule(rule_id: int) -> dict: async def get_rule(rule_id: int) -> dict:
+231 -12
View File
@@ -9,6 +9,7 @@ refresh; these tools only ever judge what the sync has seen.
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from scribe.services import coverage as coverage_svc
from scribe.services import shape_ledger as shape_ledger_svc from scribe.services import shape_ledger as shape_ledger_svc
@@ -35,10 +36,20 @@ async def classify_shapes(
Args: Args:
project_id: The project whose ledger is being judged. project_id: The project whose ledger is being judged.
classifications: Objects of {path, symbol, status, kind?, snippet_id?, classifications: Objects of {path, symbol, status, kind?, snippet_id?,
reason?}. path+symbol name the shape exactly as list_shapes shows reason?, reason_code?}. path+symbol name the shape exactly as
it; kind ("sym"/"css") narrows when one file defines both. list_shapes shows it; kind ("sym"/"css") narrows when one file
snippet_id is required for canonical/instance/variant; reason is defines both. snippet_id is required for canonical/instance/
required for variant/exempt. variant; reason is required for variant/exempt. reason_code is
an OPTIONAL index beside the prose (one of: scoped-css,
one-off-handler, test-helper, convention-plumbing, pure-helper,
generated, script, typed-record) so the ledger can be filtered
and aggregated by kind of one-off — the prose stays the record.
uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
conformance (status + snippet_id) says what shape it is, uses
says which canonical helpers it consumes — a service function
can be an instance of the service-function convention AND use
hash_token. Consumer maps are uses edges; list_shapes(uses=N)
and get_snippet's `uses` read them.
via: Who is judging — "agent" (default), "audit" (a sweep), or via: Who is judging — "agent" (default), "audit" (a sweep), or
"import" (carrying maps recorded elsewhere). "import" (carrying maps recorded elsewhere).
@@ -61,6 +72,10 @@ async def list_shapes(
include_vanished: bool = False, include_vanished: bool = False,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
proposal: str = "",
flag: str = "",
compact: bool = False,
uses: int = 0,
) -> dict: ) -> dict:
"""Read a project's shape ledger — `status="unclassified"` IS the todo. """Read a project's shape ledger — `status="unclassified"` IS the todo.
@@ -68,28 +83,232 @@ async def list_shapes(
(fed by the coverage refresh). Filters compose: (fed by the coverage refresh). Filters compose:
Args: Args:
status: canonical | instance | variant | exempt | unclassified. status: canonical | instance | variant | exempt | scoped | unclassified.
`scoped` (#2869) is the sync's mechanical stamp on one-offs by
construction (a Vue component's scoped <style> rules and its
<script setup> functions): accounted for, not judged, still
proposed against / grouped / flagged, and overridable by any
classify_shapes judgment. The human todo is `unclassified`.
path: exact file, or a directory — matches everything beneath it path: exact file, or a directory — matches everything beneath it
(the coverage line's "largest" dirs go straight in here). (the coverage line's "largest" dirs go straight in here).
snippet_id: rows classified against this snippet — a consumer map. snippet_id: rows classified against this snippet (instance/variant
of it — conformance).
uses: rows that CALL this snippet (#2870) — the consumer map proper,
whatever shape each row is itself; edges come from judgments
(classify_shapes uses=), the write-path hook, and the proposer's
by-name reference hits.
include_vanished: include shapes no longer in the tree (history). include_vanished: include shapes no longer in the tree (history).
limit/offset: page through big ledgers (limit caps at 500). limit/offset: page through big ledgers (limit caps at 500).
compact: rows as `path · symbol · kind · status · signature` plus
snippet_id / by / proposal / diverges_from / recheck only when
set — no commits, shas or timestamps. THE form for an audit:
a full 500-row page fits the tool budget. The default rows carry
everything (shape_history-grade bookkeeping).
proposal: the proposer's queue (#2792) — "any", "canon" (rows the
machine thinks are an instance of a snippet: `proposal` carries
snippet_id, basis, score), "derive" (rows that repeat with NO
canon: `proposal.group` names the family), or one basis
(symbol/text/reference/signature/semantic).
flag: the divergence readout (#2793) — "divergence": shapes new
since the previous refresh in a directory where one canon
dominates the judged siblings and NOT proposed as that canon
(`diverges_from` names it: button B where button A is canon —
classify it: instance if it should use the canon, variant with
the why if deliberate); "recheck": judged instances/variants
whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge).
Returns {"shapes": [...], "total": N} — total counts every match, not Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Classify what you can judge with classify_shapes; a just this page. Each row's `classified_by` says who judged: agent /
repeating shape with NO recorded canon is a derive-one-first moment audit / import are judgments; `mechanical` is the canonical stamp the
(consolidate onto a reference, create_snippet it, then classify the sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
rest against it), never N loose classifications. a snippet and then wrote code referencing/resembling it, so the shape
was stamped an instance with the evidence in `reason`. A hook row is
overridable by any classify_shapes call; it never overrides yours.
THE FAST PATH through a big todo is the proposer's queue: every coverage
refresh matches unclassified shapes against canon (strongest basis
first: same symbol elsewhere → textual containment → body references
the canon → signature resemblance → semantic) and attaches a
`proposal` to each row it can speak for. Review `proposal="canon"` by
snippet or directory, then confirm_shape_proposals the ones that hold —
hundreds at a time — and classify_shapes the rest (variant/exempt, or
instance of a different snippet). `proposal="derive"` lists the
derive-first candidates: a repeating shape with NO recorded canon is
never N loose classifications — consolidate onto a reference,
create_snippet it, then classify the group against it.
""" """
uid = current_user_id() uid = current_user_id()
rows, total = await shape_ledger_svc.list_project_shapes( rows, total = await shape_ledger_svc.list_project_shapes(
uid, project_id, uid, project_id,
status=status, path=path, snippet_id=snippet_id, status=status, path=path, snippet_id=snippet_id,
include_vanished=include_vanished, limit=limit, offset=offset, include_vanished=include_vanished, limit=limit, offset=offset,
proposal=proposal, flag=flag, uses=uses,
) )
return {"shapes": [r.to_dict() for r in rows], "total": total} return {
"shapes": [r.to_compact() if compact else r.to_dict() for r in rows],
"total": total,
}
async def classify_shapes_by_rule(
project_id: int,
path: str,
status: str,
pattern: str = "",
kind: str = "",
snippet_id: int = 0,
reason: str = "",
via: str = "agent",
include_judged: bool = False,
reason_code: str = "",
uses: list[int] | None = None,
) -> dict:
"""The sweep form of classify_shapes: ONE judgment applied to every
unclassified shape under a directory whose symbol matches a glob.
For the long tail an audit judges by family, not by row — "every scoped
rule under frontend/src/views is exempt: styles one element of its view",
"every `*_scheduler.py` symbol is an instance of ScheduledJob" — where
listing 900 rows and sending them back is the whole cost. The row form
stays the precise tool; reach for it when each row gets its own reason.
Args:
project_id: The project whose ledger is being judged.
path: A file, or a directory and everything beneath it. Required —
a sweep names what it judges.
status: instance | variant | exempt | unclassified (canonical is the
sync's stamp, not a sweep's).
pattern: Shell glob on the symbol (`*_rows`, `_*`, `modal-*`, `*`);
"" = every symbol under path.
kind: "sym" or "css" to narrow; "" = both.
snippet_id: Required for instance/variant — the canon judged against.
reason: Required for variant/exempt — the why, recorded on every row.
via: "agent" (default) | "audit" | "import".
reason_code: Optional catalogue code beside the reason (see
classify_shapes) — a sweep is exactly where one applies.
uses: Optional snippet ids every matched shape CALLS (#2870) — e.g.
"every *_scheduler.py symbol uses ScheduledJob".
include_judged: By default only unjudged rows are touched —
`unclassified` and the sync's mechanical `scoped` stamp — a
sweep never silently overwrites a judgment. True re-judges every
matching live row (use to re-confirm after a recheck, or to
revise a family you judged earlier).
One transaction: applies whole or not at all. Returns
{"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted)
so you can see what the rule reached; N = 0 means the rule matched
nothing live and unclassified — widen the pattern or refresh coverage.
"""
uid = current_user_id()
try:
return await shape_ledger_svc.classify_shapes_where(
uid, project_id, path=path, status=status, pattern=pattern,
kind=kind, snippet_id=snippet_id or None, reason=reason or None,
via=via, include_judged=include_judged,
reason_code=reason_code or None, uses=uses or None,
)
except ValueError as exc:
return {"error": str(exc)}
async def shape_history(
project_id: int, path: str, symbol: str = "", limit: int = 200
) -> dict:
"""What was used here, when, and why — a shape's (or a directory's)
history from the ledger (#2793).
`shapes` are the current rows at `path` (a file, or a directory and
everything beneath it; `symbol` narrows to one definition) with
first/last-seen commits, vanished_at, and the standing judgment;
`events` are the state changes, oldest first: `classified` (status,
snippet_id, who, why — one per judgment, so a shape that was an instance
of #N and later a variant of #M shows both), `vanished`, `reappeared`,
`drifted` (the body moved under a judgment; see list_shapes flag=
"recheck"). Each event carries the commit the tree was read at.
Read it as a timeline: "instance of #N from <first classified at>,
re-judged variant of #M at <at> because <reason>, vanished at <commit>".
Read-only; requires read access to the project.
"""
uid = current_user_id()
return await shape_ledger_svc.shape_history(
uid, project_id, path, symbol=symbol, limit=limit
)
async def confirm_shape_proposals(
project_id: int,
snippet_id: int = 0,
path: str = "",
basis: str = "",
min_score: float = 0.0,
) -> dict:
"""Confirm the proposer's canon proposals you have reviewed, in batch.
The machine proposes, judgment classifies (#2792): each matching row —
live, unclassified, carrying a `proposal` with a snippet_id — becomes
`instance` of that snippet, classified_by="agent", reason naming the
basis and score. Narrow to what you actually looked at: at least one of
snippet_id (confirm one canon's whole queue after reading its
`list_shapes(proposal="canon", ...)` page), path (a directory you
audited), or basis (e.g. "symbol" and "reference" are near-certain;
"semantic" deserves a look first) is required — a bare confirm-all is
not a judgment. min_score trims a basis's tail.
Proposals you do NOT confirm are judged with classify_shapes (variant,
exempt, or instance of a different snippet) — any judgment retires the
proposal. Requires write access. Returns {"confirmed": N}.
"""
uid = current_user_id()
try:
return await shape_ledger_svc.confirm_proposals(
uid, project_id, snippet_id=snippet_id, path=path, basis=basis,
min_score=min_score,
)
except ValueError as exc:
return {"error": str(exc)}
async def refresh_pattern_coverage(project_id: int) -> dict:
"""Seed or refresh the project's shape ledger NOW, and return the readout.
The synchronous form of the arrival-moment background seed (#2802):
downloads the bound repos through the OWNER's forge connections, upserts
every extracted shape into the ledger (new shapes arrive `unclassified`),
re-stamps snippet reference locations as canonical, and recomputes the
accounting. Reach for it when the ledger must be current before you act —
a classification batch about to run, a coverage question asked directly —
rather than waiting on the background seed an enter_project triggers.
Takes seconds, not milliseconds (it moves repo archives). Requires write
access to the project. Errors name the fix: no forge connection → the
owner adds one (Settings → Integrations → Git Forges); no served repo →
bind_repo on a host a connection serves.
The refresh is also when the mechanical proposer runs (#2792): with the
repo bodies in hand it matches every changed unclassified shape against
canon and records proposals (see list_shapes proposal=), then regroups
the derive-first candidates. Semantic matching is capped per refresh, so
a large ledger's queue grows across refreshes rather than in one.
Returns the accounting payload — total, accounted, counts by status,
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon
families), `proposer` (what this refresh examined) — plus
`pattern_coverage`, the same one-line summary enter_project carries.
"""
uid = current_user_id()
coverage = await coverage_svc.refresh_for_caller(uid, project_id)
return {
"pattern_coverage": coverage_svc.coverage_line(coverage),
**coverage,
}
def register(mcp) -> None: def register(mcp) -> None:
for fn in (classify_shapes, list_shapes): for fn in (
classify_shapes, classify_shapes_by_rule, list_shapes,
refresh_pattern_coverage, confirm_shape_proposals, shape_history,
):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+4
View File
@@ -245,6 +245,10 @@ async def get_snippet(snippet_id: int) -> dict:
data["instances"] = consumers["instances"] data["instances"] = consumers["instances"]
if consumers["variants"]: if consumers["variants"]:
data["variants"] = consumers["variants"] data["variants"] = consumers["variants"]
if consumers.get("uses"):
# The call sites (#2870): shapes that use this snippet, whatever
# shape they are themselves.
data["uses"] = consumers["uses"]
return data return data
+1 -1
View File
@@ -134,7 +134,7 @@ async def attach_systems(
tagged record shows its areas (the touching-a-System reflex needs the tagged record shows its areas (the touching-a-System reflex needs the
affiliation visible on read, not just settable on write), an untagged affiliation visible on read, not just settable on write), an untagged
project record carries the question instead. Neither field is ever project record carries the question instead. Neither field is ever
attached empty (same reasoning as notes._attach_supersession / #2483 — a attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a
field that always says nothing trains readers to skip fields). The hint field that always says nothing trains readers to skip fields). The hint
goes only to the record's owner: tagging someone else's record in someone goes only to the record's owner: tagging someone else's record in someone
else's project is not the caller's call to make. Fail-open — decoration else's project is not the caller's call to make. Fail-open — decoration
+1 -6
View File
@@ -97,12 +97,7 @@ async def get_task(task_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules( applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid, project_id=note.project_id, user_id=uid,
) )
data["applicable_rules"] = applicable["rules"] data.update(rulebooks_svc.rules_payload(applicable))
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
data.update(await access_svc.describe_provenance(uid, note)) data.update(await access_svc.describe_provenance(uid, note))
# Same reasoning as get_note's record_pulled, and this is the tool where it # Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is # matters MOST: auto-inject ranks kind-blind over a corpus that is
+1 -1
View File
@@ -44,6 +44,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
) )
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
from scribe.models.code_shape import CodeShape # noqa: E402, F401 from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401 from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401 from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -36,7 +36,7 @@ class ApiKey(Base, CreatedAtMixin):
"name": self.name, "name": self.name,
"key_prefix": self.key_prefix, "key_prefix": self.key_prefix,
"scope": self.scope, "scope": self.scope,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, "last_used_at": iso(self.last_used_at),
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, "revoked_at": iso(self.revoked_at),
} }
+5 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import iso
class AppLog(Base): class AppLog(Base):
@@ -20,6 +21,9 @@ class AppLog(Base):
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True) duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True) ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
details: Mapped[str | None] = mapped_column(Text, nullable=True) details: Mapped[str | None] = mapped_column(Text, nullable=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -44,5 +48,5 @@ class AppLog(Base):
"duration_ms": self.duration_ms, "duration_ms": self.duration_ms,
"ip_address": self.ip_address, "ip_address": self.ip_address,
"details": self.details, "details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
} }
+11 -1
View File
@@ -1,9 +1,19 @@
from datetime import datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import DateTime, Text from sqlalchemy import DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
def iso(value: datetime | date | None) -> str | None:
"""ISO-8601 for a payload, None for an unset column.
Every model's to_dict serialises timestamps through this one helper so a
row read before flush (created_at still None) and a nullable column both
come out as null instead of raising on `.isoformat()`.
"""
return value.isoformat() if value else None
class SoftDeleteMixin: class SoftDeleteMixin:
"""Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id """Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id
groups rows soft-deleted in one operation so a cascade restores as a unit.""" groups rows soft-deleted in one operation so a cascade restores as a unit."""
+232 -8
View File
@@ -1,8 +1,9 @@
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
DateTime, DateTime,
Float,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
@@ -12,13 +13,39 @@ from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
# The classification vocabulary (note 2786). `unclassified` is the default and # The classification vocabulary (note 2786). `unclassified` is the default and
# THE todo state; every other status is a judgment, stamped with who made it. # THE todo state; every other status is a judgment, stamped with who made it
SHAPE_STATUSES = ("canonical", "instance", "variant", "exempt", "unclassified") # except `scoped` (#2869): the coverage sync's mechanical stamp on shapes that
# are one-offs BY CONSTRUCTION (a Vue component's scoped <style> rules and its
# <script setup> functions — unreachable from any other file). Scoped rows are
# accounted for without a human judging them, so `exempt` keeps meaning "a
# person looked"; the proposer, derive grouping and divergence still see them,
# and any judgment (instance/variant/exempt) overrides the stamp.
SHAPE_STATUSES = ("canonical", "instance", "variant", "exempt", "scoped", "unclassified")
SHAPE_CLASSIFIERS = ("agent", "audit", "hook", "mechanical", "import") SHAPE_CLASSIFIERS = ("agent", "audit", "hook", "mechanical", "import")
# The reason catalogue (#2874): an OPTIONAL code beside the prose reason on
# variant/exempt rows, so the ledger can be filtered and aggregated by kind
# of one-off. The prose remains the record; the code is the index.
REASON_CODES = (
"scoped-css", # a scoped rule styling one element (pre-#2869 rows)
"one-off-handler", # a view/component handler or loader, one per surface
"test-helper", # a test module's stub, driver or fixture data
"convention-plumbing", # registration, wiring, app factory — one of each
"pure-helper", # a sync module-private helper with no session
"generated", # generated source (theme.css, protos, bundles)
"script", # a standalone dev/CI script
"typed-record", # a NamedTuple / dataclass / error class — one each
)
# How the mechanical proposer (#2792) arrived at a proposal, strongest first.
# `derive` is the odd one out: not "this is an instance of #N" but "this
# shape repeats with NO canon — derive one first" (note 2786's derive-first
# rule), so it carries a group key instead of a snippet.
PROPOSAL_BASES = ("symbol", "text", "reference", "signature", "semantic", "derive")
class CodeShape(Base, TimestampMixin): class CodeShape(Base, TimestampMixin):
"""One extracted code shape and its classification against canon (#2787). """One extracted code shape and its classification against canon (#2787).
@@ -42,6 +69,29 @@ class CodeShape(Base, TimestampMixin):
snippet_id is SET NULL on snippet deletion: the classification's target snippet_id is SET NULL on snippet deletion: the classification's target
is gone but the judgment happened; the sync pass (step 2) re-files such is gone but the judgment happened; the sync pass (step 2) re-files such
rows as unclassified so they rejoin the todo instead of dangling. rows as unclassified so they rejoin the todo instead of dangling.
`signature` / `body_sha` (#2792) are the shape's content fingerprint —
its definition line and a whitespace/comment-insensitive hash of its
block — refreshed by every sync. They are what the mechanical proposer
matches on and what a later drift recheck compares against; the ledger
still never stores code bodies.
`classified_sha` remembers the fingerprint a judgment was made at;
when a later sync sees the body change under an instance/variant, the
row is flagged `recheck_at` (the judgment stands, it just asks to be
confirmed again) and a `drifted` event is written. `diverges_from`
(#2793) is the button-B flag: a shape new since the previous refresh, in
a directory+kind where one canon dominates the judged siblings, that the
proposer did not match to that canon — "button B appeared where button
A is canon: divergence or variant? classify it." Both clear on judgment.
The proposal columns hold the proposer's standing suggestion for an
UNCLASSIFIED row: `proposed_snippet_id` + `proposal_basis` + score for
"looks like an instance of #N", or `proposal_basis="derive"` +
`proposal_group` for "repeats with no canon". `proposed_sha` is the
body_sha the row was last examined at, so a refresh re-examines only
what changed. A judgment clears the proposal — the machine proposes,
judgment classifies.
""" """
__tablename__ = "code_shapes" __tablename__ = "code_shapes"
@@ -52,6 +102,8 @@ class CodeShape(Base, TimestampMixin):
), ),
Index("ix_code_shapes_project_status", "project_id", "status"), Index("ix_code_shapes_project_status", "project_id", "status"),
Index("ix_code_shapes_snippet", "snippet_id"), Index("ix_code_shapes_snippet", "snippet_id"),
Index("ix_code_shapes_proposed", "project_id", "proposed_snippet_id"),
Index("ix_code_shapes_diverges", "project_id", "diverges_from"),
) )
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -67,6 +119,7 @@ class CodeShape(Base, TimestampMixin):
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
) )
reason: Mapped[str | None] = mapped_column(Text, nullable=True) reason: Mapped[str | None] = mapped_column(Text, nullable=True)
reason_code: Mapped[str | None] = mapped_column(Text, nullable=True) # REASON_CODES (#2874)
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True) classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
classified_at: Mapped[datetime | None] = mapped_column( classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
@@ -76,6 +129,38 @@ class CodeShape(Base, TimestampMixin):
vanished_at: Mapped[datetime | None] = mapped_column( vanished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
signature: Mapped[str] = mapped_column(Text, default="")
body_sha: Mapped[str] = mapped_column(Text, default="")
proposed_snippet_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
proposal_basis: Mapped[str | None] = mapped_column(Text, nullable=True)
proposal_score: Mapped[float | None] = mapped_column(Float, nullable=True)
proposal_group: Mapped[str | None] = mapped_column(Text, nullable=True)
proposed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
proposed_sha: Mapped[str] = mapped_column(Text, default="")
classified_sha: Mapped[str] = mapped_column(Text, default="")
recheck_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
diverges_from: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
@property
def proposal(self) -> dict | None:
"""The standing proposal as one object, or None when the proposer
has nothing to say about this row."""
if self.proposed_snippet_id is None and not self.proposal_group:
return None
out: dict = {"basis": self.proposal_basis, "score": self.proposal_score}
if self.proposed_snippet_id is not None:
out["snippet_id"] = self.proposed_snippet_id
if self.proposal_group:
out["group"] = self.proposal_group
return out
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -88,11 +173,150 @@ class CodeShape(Base, TimestampMixin):
"status": self.status, "status": self.status,
"snippet_id": self.snippet_id, "snippet_id": self.snippet_id,
"reason": self.reason, "reason": self.reason,
"reason_code": self.reason_code,
"classified_by": self.classified_by, "classified_by": self.classified_by,
"classified_at": self.classified_at.isoformat() if self.classified_at else None, "classified_at": iso(self.classified_at),
"first_seen_commit": self.first_seen_commit, "first_seen_commit": self.first_seen_commit,
"last_seen_commit": self.last_seen_commit, "last_seen_commit": self.last_seen_commit,
"vanished_at": self.vanished_at.isoformat() if self.vanished_at else None, "vanished_at": iso(self.vanished_at),
"created_at": self.created_at.isoformat(), "signature": self.signature,
"updated_at": self.updated_at.isoformat(), "body_sha": self.body_sha,
"proposal": self.proposal,
"classified_sha": self.classified_sha,
"recheck_at": iso(self.recheck_at),
"diverges_from": self.diverges_from,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
def to_compact(self) -> dict:
"""The row as an audit reads it (#2868): identity, standing, the
definition line and the proposer's word — none of the bookkeeping
(commits, shas, timestamps). A 500-row page of these fits the tool
budget; a page of to_dict() does not."""
out = {
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"status": self.status,
"signature": self.signature,
}
if self.snippet_id is not None:
out["snippet_id"] = self.snippet_id
if self.classified_by:
out["by"] = self.classified_by
if self.reason_code:
out["reason_code"] = self.reason_code
proposal = self.proposal
if proposal:
out["proposal"] = proposal
if self.diverges_from is not None:
out["diverges_from"] = self.diverges_from
if self.recheck_at is not None:
out["recheck"] = True
return out
# How a uses edge was established (#2870): who/what said "this shape calls
# that canon". `reference` is the proposer's mechanical by-name hit on the
# body (language-gated, #2871); `hook` is write-path evidence (pulled the
# snippet, then wrote code naming its symbol); agent/audit/import are
# judgments carried on classify_shapes(..., uses=[...]).
USE_BASES = ("reference", "hook", "agent", "audit", "import")
class CodeShapeUse(Base):
"""One consumption edge: shape → canonical snippet it calls/uses (#2870).
Conformance (CodeShape.status/snippet_id) answers "what shape is this";
this table answers "what does it use" — many per shape. A service function
that is an instance of the service-function convention AND a consumer of
hash_token has one snippet_id and one uses edge. Cascades with both ends:
a use of a deleted snippet is no longer a fact worth keeping.
"""
__tablename__ = "code_shape_uses"
__table_args__ = (
UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
Index("ix_code_shape_uses_snippet", "snippet_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
shape_id: Mapped[int] = mapped_column(
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
)
snippet_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
basis: Mapped[str] = mapped_column(Text, nullable=False)
evidence: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
def to_dict(self) -> dict:
return {
"id": self.id,
"shape_id": self.shape_id,
"snippet_id": self.snippet_id,
"basis": self.basis,
"evidence": self.evidence,
"created_at": iso(self.created_at),
}
# What a shape's history records (#2793). Not "appeared" — first_seen and
# created_at already say that on the row; history is for what CHANGED:
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
class CodeShapeEvent(Base):
"""One state change in a shape's life — the what-was-used-when record.
"We used #N here from <date>, #M replaced it at commit C, reason R" is a
question the ledger row alone cannot answer once it has moved on; this
table keeps each judgment (status, snippet, who, why, at which commit)
and each presence change (vanished / reappeared / drifted) as it
happened. Denormalised path/symbol/kind so a directory's history reads
without joining; `snippet_id` is deliberately FK-free — history outlives
the snippet it names, which is the point.
"""
__tablename__ = "code_shape_events"
__table_args__ = (
Index("ix_code_shape_events_shape", "shape_id", "at"),
Index("ix_code_shape_events_project_path", "project_id", "path"),
)
id: Mapped[int] = mapped_column(primary_key=True)
shape_id: Mapped[int] = mapped_column(
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[int] = mapped_column(Integer, nullable=False)
path: Mapped[str] = mapped_column(Text, nullable=False)
symbol: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False)
event: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
snippet_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
commit: Mapped[str] = mapped_column(Text, default="")
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"shape_id": self.shape_id,
"project_id": self.project_id,
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"event": self.event,
"status": self.status,
"snippet_id": self.snippet_id,
"classified_by": self.classified_by,
"reason": self.reason,
"commit": self.commit,
"at": iso(self.at),
} }
+5 -5
View File
@@ -20,7 +20,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin): class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
@@ -56,8 +56,8 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
"description": self.description or "", "description": self.description or "",
"guidance": self.guidance or "", "guidance": self.guidance or "",
"parent_id": self.parent_id, "parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
@@ -153,6 +153,6 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"rationale": self.rationale, "rationale": self.rationale,
"supersedes": self.supersedes or [], "supersedes": self.supersedes or [],
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class ForgeConnection(Base, TimestampMixin): class ForgeConnection(Base, TimestampMixin):
@@ -40,6 +40,6 @@ class ForgeConnection(Base, TimestampMixin):
"kind": self.kind, "kind": self.kind,
"base_url": self.base_url, "base_url": self.base_url,
"host": self.host, "host": self.host,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin from scribe.models.base import CreatedAtMixin, TimestampMixin, iso
class Group(Base, TimestampMixin): class Group(Base, TimestampMixin):
@@ -27,8 +27,8 @@ class Group(Base, TimestampMixin):
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"created_by": self.created_by, "created_by": self.created_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -53,5 +53,5 @@ class GroupMembership(Base, CreatedAtMixin):
"group_id": self.group_id, "group_id": self.group_id,
"user_id": self.user_id, "user_id": self.user_id,
"role": self.role, "role": self.role,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class InvitationToken(Base): class InvitationToken(Base, CreatedAtMixin):
__tablename__ = "invitation_tokens" __tablename__ = "invitation_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +16,6 @@ class InvitationToken(Base):
invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_invitation_tokens_token_hash", "token_hash"), Index("ix_invitation_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Milestone(Base, TimestampMixin, SoftDeleteMixin): class Milestone(Base, TimestampMixin, SoftDeleteMixin):
@@ -30,6 +30,6 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
"body": self.body, "body": self.body,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+7 -11
View File
@@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
@@ -105,18 +105,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"milestone_id": self.milestone_id, "milestone_id": self.milestone_id,
"status": self.status, "status": self.status,
"priority": self.priority, "priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None, "due_date": iso(self.due_date),
"started_at": self.started_at.isoformat() if self.started_at else None, "started_at": iso(self.started_at),
"completed_at": self.completed_at.isoformat() if self.completed_at else None, "completed_at": iso(self.completed_at),
"recurrence_rule": self.recurrence_rule, "recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": ( "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"is_task": self.is_task, "is_task": self.is_task,
"note_type": self.note_type or "note", "note_type": self.note_type or "note",
"task_kind": self.task_kind, "task_kind": self.task_kind,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class NoteDraft(Base, TimestampMixin): class NoteDraft(Base, TimestampMixin):
@@ -25,6 +25,6 @@ class NoteDraft(Base, TimestampMixin):
"original_body": self.original_body, "original_body": self.original_body,
"instruction": self.instruction, "instruction": self.instruction,
"scope": self.scope, "scope": self.scope,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class NoteSupersession(Base, CreatedAtMixin): class NoteSupersession(Base, CreatedAtMixin):
@@ -66,5 +66,5 @@ class NoteSupersession(Base, CreatedAtMixin):
"id": self.id, "id": self.id,
"superseder_id": self.superseder_id, "superseder_id": self.superseder_id,
"superseded_id": self.superseded_id, "superseded_id": self.superseded_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
} }
+4 -8
View File
@@ -1,15 +1,14 @@
from datetime import datetime, timezone from sqlalchemy import Index, Integer, Text
from sqlalchemy import DateTime, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced" SURFACED = "surfaced"
PULLED = "pulled" PULLED = "pulled"
class NoteUsageEvent(Base): class NoteUsageEvent(Base, CreatedAtMixin):
"""One row per time a note was SURFACED to the agent, or PULLED in full. """One row per time a note was SURFACED to the agent, or PULLED in full.
Answers the question RetrievalLog cannot: not "what did the ranker return Answers the question RetrievalLog cannot: not "what did the ranker return
@@ -44,9 +43,6 @@ class NoteUsageEvent(Base):
__tablename__ = "note_usage_events" __tablename__ = "note_usage_events"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
note_id: Mapped[int] = mapped_column(Integer, nullable=False) note_id: Mapped[int] = mapped_column(Integer, nullable=False)
# 'surfaced' | 'pulled' # 'surfaced' | 'pulled'
@@ -81,7 +77,7 @@ class NoteUsageEvent(Base):
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"user_id": self.user_id, "user_id": self.user_id,
"note_id": self.note_id, "note_id": self.note_id,
"event": self.event, "event": self.event,
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class NoteVersion(Base, CreatedAtMixin): class NoteVersion(Base, CreatedAtMixin):
@@ -26,7 +26,7 @@ class NoteVersion(Base, CreatedAtMixin):
"tags": self.tags or [], "tags": self.tags or [],
"pin_kind": self.pin_kind, "pin_kind": self.pin_kind,
"pin_label": self.pin_label, "pin_label": self.pin_label,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
if include_body: if include_body:
d["body"] = self.body d["body"] = self.body
+3 -3
View File
@@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class Notification(Base, CreatedAtMixin): class Notification(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class Notification(Base, CreatedAtMixin):
"user_id": self.user_id, "user_id": self.user_id,
"type": self.type, "type": self.type,
"payload": self.payload, "payload": self.payload,
"read_at": self.read_at.isoformat() if self.read_at else None, "read_at": iso(self.read_at),
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class PasswordResetToken(Base): class PasswordResetToken(Base, CreatedAtMixin):
__tablename__ = "password_reset_tokens" __tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,9 +15,6 @@ class PasswordResetToken(Base):
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_password_reset_tokens_token_hash", "token_hash"), Index("ix_password_reset_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ import enum
from sqlalchemy import BigInteger, ForeignKey, Integer, Text from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
@@ -48,6 +48,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"design_system_id": self.design_system_id, "design_system_id": self.design_system_id,
"forge_connection_id": self.forge_connection_id, "forge_connection_id": self.forge_connection_id,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+8 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class RepoBinding(Base, TimestampMixin): class RepoBinding(Base, TimestampMixin):
@@ -28,6 +28,10 @@ class RepoBinding(Base, TimestampMixin):
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
) )
repo_key: Mapped[str] = mapped_column(Text, nullable=False) repo_key: Mapped[str] = mapped_column(Text, nullable=False)
# The branch the coverage refresh reads for this binding (#2873); NULL =
# the forge's default branch. Chosen at bind time so a dev-first project
# can have its ledger follow dev instead of waiting for the merge.
ref: Mapped[str | None] = mapped_column(Text, nullable=True)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin):
"user_id": self.user_id, "user_id": self.user_id,
"project_id": self.project_id, "project_id": self.project_id,
"repo_key": self.repo_key, "repo_key": self.repo_key,
"created_at": self.created_at.isoformat(), "ref": self.ref,
"updated_at": self.updated_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
} }
+5 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import iso
class RetrievalLog(Base): class RetrievalLog(Base):
@@ -23,6 +24,9 @@ class RetrievalLog(Base):
__tablename__ = "retrieval_logs" __tablename__ = "retrieval_logs"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -54,7 +58,7 @@ class RetrievalLog(Base):
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"user_id": self.user_id, "user_id": self.user_id,
"source": self.source, "source": self.source,
"query": self.query, "query": self.query,
+10 -34
View File
@@ -4,10 +4,10 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index,
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Rulebook(Base, SoftDeleteMixin): class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebooks" __tablename__ = "rulebooks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -19,14 +19,6 @@ class Rulebook(Base, SoftDeleteMixin):
always_on: Mapped[bool] = mapped_column( always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false" Boolean, default=False, nullable=False, server_default="false"
) )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -35,12 +27,12 @@ class Rulebook(Base, SoftDeleteMixin):
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"always_on": self.always_on, "always_on": self.always_on,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
class RulebookTopic(Base, SoftDeleteMixin): class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebook_topics" __tablename__ = "rulebook_topics"
# Partial unique: a title is unique among LIVE topics in a rulebook, so a # Partial unique: a title is unique among LIVE topics in a rulebook, so a
# trashed topic doesn't block recreating/restoring the same title. # trashed topic doesn't block recreating/restoring the same title.
@@ -58,14 +50,6 @@ class RulebookTopic(Base, SoftDeleteMixin):
title: Mapped[str] = mapped_column(Text) title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -74,12 +58,12 @@ class RulebookTopic(Base, SoftDeleteMixin):
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
class Rule(Base, SoftDeleteMixin): class Rule(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rules" __tablename__ = "rules"
# Partial unique: title unique among LIVE rules in a topic (soft-deleted # Partial unique: title unique among LIVE rules in a topic (soft-deleted
# rules don't block recreating/restoring the same title). # rules don't block recreating/restoring the same title).
@@ -109,14 +93,6 @@ class Rule(Base, SoftDeleteMixin):
why: Mapped[str | None] = mapped_column(Text, nullable=True) why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -128,8 +104,8 @@ class Rule(Base, SoftDeleteMixin):
"why": self.why or "", "why": self.why or "",
"how_to_apply": self.how_to_apply or "", "how_to_apply": self.how_to_apply or "",
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
+5 -5
View File
@@ -2,7 +2,7 @@ from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class ProjectShare(Base, TimestampMixin): class ProjectShare(Base, TimestampMixin):
@@ -37,8 +37,8 @@ class ProjectShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id, "shared_with_group_id": self.shared_with_group_id,
"permission": self.permission, "permission": self.permission,
"invited_by": self.invited_by, "invited_by": self.invited_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -74,6 +74,6 @@ class NoteShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id, "shared_with_group_id": self.shared_with_group_id,
"permission": self.permission, "permission": self.permission,
"invited_by": self.invited_by, "invited_by": self.invited_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin, SoftDeleteMixin from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
class System(Base, TimestampMixin, SoftDeleteMixin): class System(Base, TimestampMixin, SoftDeleteMixin):
@@ -44,8 +44,8 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class TaskLog(Base, TimestampMixin): class TaskLog(Base, TimestampMixin):
@@ -21,6 +21,6 @@ class TaskLog(Base, TimestampMixin):
"user_id": self.user_id, "user_id": self.user_id,
"content": self.content, "content": self.content,
"duration_minutes": self.duration_minutes, "duration_minutes": self.duration_minutes,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class User(Base, CreatedAtMixin): class User(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class User(Base, CreatedAtMixin):
"username": self.username, "username": self.username,
"email": self.email, "email": self.email,
"role": self.role, "role": self.role,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"has_password": self.password_hash is not None, "has_password": self.password_hash is not None,
} }
+5 -7
View File
@@ -22,6 +22,7 @@ from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_conf
from scribe.services.logging import get_logs, get_log_stats, log_audit from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email from scribe.services.notifications import send_invitation_email
from scribe.services.settings import ( from scribe.services.settings import (
SECRET_MASK,
get_admin_setting, get_admin_setting,
set_admin_setting, set_admin_setting,
set_setting, set_setting,
@@ -116,7 +117,7 @@ async def get_smtp():
config = await get_smtp_config() config = await get_smtp_config()
# Mask password # Mask password
if config.get("smtp_password"): if config.get("smtp_password"):
config["smtp_password"] = "********" config["smtp_password"] = SECRET_MASK
return jsonify(config) return jsonify(config)
@@ -130,7 +131,7 @@ async def update_smtp():
for key in SMTP_SETTING_KEYS: for key in SMTP_SETTING_KEYS:
if key in data: if key in data:
# Skip password if it's the mask placeholder # Skip password if it's the mask placeholder
if key == "smtp_password" and data[key] == "********": if key == "smtp_password" and data[key] == SECRET_MASK:
continue continue
settings_to_save[key] = str(data[key]) settings_to_save[key] = str(data[key])
@@ -157,9 +158,6 @@ async def test_smtp():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
_TOKEN_MASK = "********"
# The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git # The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git
# forges); what stays admin is the webhook secret, because the push endpoint # forges); what stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users. # is one URL per instance and authenticates deliveries, not users.
@@ -178,7 +176,7 @@ async def get_forge_webhook_settings():
return jsonify({ return jsonify({
# Secrets never leave the server — the smtp_password convention: # Secrets never leave the server — the smtp_password convention:
# masked when set, empty when not. # masked when set, empty when not.
"webhook_secret": _TOKEN_MASK if webhook_secret else "", "webhook_secret": SECRET_MASK if webhook_secret else "",
}) })
@@ -192,7 +190,7 @@ async def update_forge_webhook_settings():
webhook_secret = data.get("webhook_secret") webhook_secret = data.get("webhook_secret")
# The mask coming back means "unchanged" — the form round-trips what GET # The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration. # showed it, and storing the mask would silently break the integration.
if webhook_secret is not None and webhook_secret != _TOKEN_MASK: if webhook_secret is not None and webhook_secret != SECRET_MASK:
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret)) await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
# The secret is deliberately absent from the audit detail. # The secret is deliberately absent from the audit detail.
await log_audit( await log_audit(
+28 -33
View File
@@ -15,9 +15,10 @@ one and returns None for the other:
those two IS the intent: distinguishing them would confirm the existence of those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see. records the caller may not see.
""" """
from quart import Blueprint, g, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import design_systems as ds_svc from scribe.services import design_systems as ds_svc
from scribe.services.design_starter_roles import ( from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX, DEFAULT_TOKEN_PREFIX,
@@ -28,12 +29,6 @@ from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api") design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ────────────────────────────────────────────────────── # ── Design systems ──────────────────────────────────────────────────────
@@ -43,7 +38,7 @@ def _not_found(what: str = "design system"):
async def list_design_systems(): async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an """The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error.""" install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid()) rows = await ds_svc.list_design_systems(get_current_user_id())
return jsonify({"design_systems": [s.to_dict() for s in rows]}) return jsonify({"design_systems": [s.to_dict() for s in rows]})
@@ -55,7 +50,7 @@ async def create_design_system():
if not title: if not title:
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system( system = await ds_svc.create_design_system(
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
description=data.get("description") or None, description=data.get("description") or None,
guidance=data.get("guidance") or None, guidance=data.get("guidance") or None,
@@ -84,9 +79,9 @@ async def list_starter_role_groups():
@design_systems_bp.get("/design-systems/<int:design_system_id>") @design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required @login_required
async def get_design_system(design_system_id: int): async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id) system = await ds_svc.get_design_system(get_current_user_id(), design_system_id)
if system is None: if system is None:
return _not_found() return not_found("Design system")
return jsonify(system.to_dict()) return jsonify(system.to_dict())
@@ -102,19 +97,19 @@ async def update_design_system(design_system_id: int):
if "parent_id" in data: if "parent_id" in data:
fields["parent_id"] = data["parent_id"] fields["parent_id"] = data["parent_id"]
try: try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields) system = await ds_svc.update_design_system(get_current_user_id(), design_system_id, **fields)
except DesignSystemCycle as exc: except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
if system is None: if system is None:
return _not_found() return not_found("Design system")
return jsonify(system.to_dict()) return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>") @design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required @login_required
async def delete_design_system(design_system_id: int): async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id): if not await ds_svc.delete_design_system(get_current_user_id(), design_system_id):
return _not_found() return not_found("Design system")
return "", 204 return "", 204
@@ -127,9 +122,9 @@ async def resolve_design_system(design_system_id: int):
this returns what it ends up being. Both are real questions and answering this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation. only one would make the other a client-side computation.
""" """
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id) resolved = await ds_svc.resolve_design_system(get_current_user_id(), design_system_id)
if resolved is None: if resolved is None:
return _not_found() return not_found("Design system")
return jsonify({ return jsonify({
"design_system_id": design_system_id, "design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved], "tokens": [t.to_dict() for t in resolved],
@@ -149,9 +144,9 @@ async def get_design_system_stylesheet(design_system_id: int):
`:root`, so the generator takes it as a parameter. `:root`, so the generator takes it as a parameter.
""" """
root = (request.args.get("root") or ":root").strip() or ":root" root = (request.args.get("root") or ":root").strip() or ":root"
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root) result = await ds_svc.stylesheet_for_system(get_current_user_id(), design_system_id, root)
if result is None: if result is None:
return _not_found() return not_found("Design system")
if request.args.get("format") == "css": if request.args.get("format") == "css":
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"} return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
return jsonify(result) return jsonify(result)
@@ -168,10 +163,10 @@ async def check_snippets_against_system(design_system_id: int):
""" """
project_id = request.args.get("project_id", type=int) or 0 project_id = request.args.get("project_id", type=int) or 0
result = await ds_svc.check_snippets_against_system( result = await ds_svc.check_snippets_against_system(
_uid(), design_system_id, project_id get_current_user_id(), design_system_id, project_id
) )
if result is None: if result is None:
return _not_found() return not_found("Design system")
return jsonify(result) return jsonify(result)
@@ -181,9 +176,9 @@ async def check_snippets_against_system(design_system_id: int):
@login_required @login_required
async def list_design_tokens(design_system_id: int): async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set.""" """This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None: if await ds_svc.get_design_system(get_current_user_id(), design_system_id) is None:
return _not_found() return not_found("Design system")
rows = await ds_svc.list_tokens(_uid(), design_system_id) rows = await ds_svc.list_tokens(get_current_user_id(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]}) return jsonify({"tokens": [t.to_dict() for t in rows]})
@@ -195,7 +190,7 @@ async def create_design_token(design_system_id: int):
if not name: if not name:
return jsonify({"error": "name is required"}), 400 return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token( token = await ds_svc.create_token(
user_id=_uid(), user_id=get_current_user_id(),
design_system_id=design_system_id, design_system_id=design_system_id,
name=name, name=name,
value_by_mode=data.get("value_by_mode"), value_by_mode=data.get("value_by_mode"),
@@ -206,7 +201,7 @@ async def create_design_token(design_system_id: int):
order_index=data.get("order_index") or 0, order_index=data.get("order_index") or 0,
) )
if token is None: if token is None:
return _not_found() return not_found("Design system")
return jsonify(token.to_dict()), 201 return jsonify(token.to_dict()), 201
@@ -221,17 +216,17 @@ async def update_design_token(token_id: int):
"supersedes", "order_index", "supersedes", "order_index",
) )
} }
token = await ds_svc.update_token(_uid(), token_id, **fields) token = await ds_svc.update_token(get_current_user_id(), token_id, **fields)
if token is None: if token is None:
return _not_found("design token") return not_found("Design token")
return jsonify(token.to_dict()) return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>") @design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required @login_required
async def delete_design_token(token_id: int): async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id): if not await ds_svc.delete_token(get_current_user_id(), token_id):
return _not_found("design token") return not_found("Design token")
return "", 204 return "", 204
@@ -247,9 +242,9 @@ async def set_project_design_system(project_id: int):
""" """
data = await request.get_json() or {} data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system( ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id") get_current_user_id(), project_id, data.get("design_system_id")
) )
if not ok: if not ok:
return _not_found("project or design system") return not_found("Project or design system")
return jsonify({"project_id": project_id, return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")}) "design_system_id": data.get("design_system_id")})
+8 -54
View File
@@ -26,22 +26,6 @@ from scribe.services import dedup as dedup_svc
from scribe.services import supersession as supersession_svc from scribe.services import supersession as supersession_svc
from scribe.services.note_usage import record_pulled from scribe.services.note_usage import record_pulled
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Both directions of the supersession relation on a note payload.
Mirrors the MCP helper of the same name — the two surfaces must agree about
what a note's payload says, or the web UI and the agent would disagree about
whether a record is current.
Omitted when empty: a field that always says nothing trains readers to skip
fields, which is what `consolidated_at` cost (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
from scribe.services.note_versions import list_versions, get_version from scribe.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,7 +126,7 @@ async def create_note_route():
# may not write the target. The note itself was created. # may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403 return jsonify({"error": str(exc), "note": note.to_dict()}), 403
out = note.to_dict() out = note.to_dict()
await _attach_supersession(uid, note.id, out) await supersession_svc.attach_relations(uid, note.id, out)
return jsonify(out), 201 return jsonify(out), 201
@@ -241,13 +225,17 @@ async def get_note_route(note_id: int):
# injected line useful?" is answered by agent pulls alone, and a human # injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on. # clicking a link would inflate exactly the number #1038 and #2085 gate on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note") record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await _attach_supersession(uid, note_id, data) await supersession_svc.attach_relations(uid, note_id, data)
return jsonify(data) return jsonify(data)
@notes_bp.route("/<int:note_id>", methods=["PUT"]) @notes_bp.route("/<int:note_id>", methods=["PUT", "PATCH"])
@login_required @login_required
async def update_note_route(note_id: int): async def update_note_route(note_id: int):
"""Partial update — only the keys present in the payload change. PUT and
PATCH are the same handler on purpose: the form sends the field set it
edited, and the two verbs used to be two near-identical copies of this
function that drifted (one carried the supersedes contract, one did not)."""
uid = get_current_user_id() uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared # Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service. # editor's save isn't rejected by the owner-scoped update service.
@@ -290,44 +278,10 @@ async def update_note_route(note_id: int):
except PermissionError as exc: except PermissionError as exc:
return jsonify({"error": str(exc)}), 403 return jsonify({"error": str(exc)}), 403
out = note.to_dict() out = note.to_dict()
await _attach_supersession(uid, note_id, out) await supersession_svc.attach_relations(uid, note_id, out)
return jsonify(out) return jsonify(out)
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
@login_required
async def patch_note_route(note_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json()
fields = {}
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "due_date" in data:
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(owner_uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"]) @notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required @login_required
+70 -49
View File
@@ -24,6 +24,35 @@ plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
_MARKETPLACE_KEY = "plugin_marketplace_url" _MARKETPLACE_KEY = "plugin_marketplace_url"
def _int_list(raw: str | None) -> list[int]:
"""A comma-separated id list from the query string; non-ints dropped."""
return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()]
async def _project_scope() -> tuple[int, str, str]:
"""(project_id, repo, unbound_repo) from the request's `project_id` /
`repo` query args — the one resolution every plugin endpoint shares.
An explicit project_id wins; otherwise the repo remote is resolved
through the caller's bindings, and a remote nobody bound comes back as
`unbound_repo` (normalised) so /context can say "bind this repo".
"""
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
unbound_repo = ""
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
return project_id, repo, unbound_repo
@plugin_bp.get("/context") @plugin_bp.get("/context")
@login_required @login_required
async def session_context(): async def session_context():
@@ -37,20 +66,7 @@ async def session_context():
project_id (optional int) — explicit override, mainly for manual/ad-hoc project_id (optional int) — explicit override, mainly for manual/ad-hoc
curl testing; takes precedence over `repo` when set. curl testing; takes precedence over `repo` when set.
""" """
try: project_id, _repo, unbound_repo = await _project_scope()
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
unbound_repo = ""
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
result = await plugin_ctx_svc.build_session_context( result = await plugin_ctx_svc.build_session_context(
g.user.id, project_id, unbound_repo=unbound_repo g.user.id, project_id, unbound_repo=unbound_repo
) )
@@ -77,22 +93,8 @@ async def autoinject_retrieve():
session; skipped so each note injects at most once. session; skipped so each note injects at most once.
""" """
q = (request.args.get("q") or "").strip() q = (request.args.get("q") or "").strip()
try: project_id, _repo, _unbound = await _project_scope()
project_id = int(request.args.get("project_id", 0) or 0) exclude_ids = _int_list(request.args.get("exclude_ids"))
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
exclude_ids = [
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
if p.strip().isdigit()
]
result = await plugin_ctx_svc.build_autoinject_hint( result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
) )
@@ -127,36 +129,55 @@ async def write_path_prior_art():
surfaced. A separate channel on purpose: a reuse surfaced. A separate channel on purpose: a reuse
hint shown early must not suppress the record-sync hint shown early must not suppress the record-sync
nudge when the recorded file is edited later. nudge when the recorded file is edited later.
shapes (opt) — comma-separated `kind:name` definitions the hook
found in (or enclosing) the payload, kind being
css|sym. The shape ledger's write-path feed
(#2791): when the session recently PULLED a
snippet this payload references or resembles,
these land as instance rows (classified_by=hook).
Honoured only for a caller allowed to write — a
read-scoped key still gets the hint, and never
changes accounting on a GET.
""" """
path = (request.args.get("path") or "").strip() path = (request.args.get("path") or "").strip()
code = request.args.get("code") or "" code = request.args.get("code") or ""
try: project_id, repo, _unbound = await _project_scope()
project_id = int(request.args.get("project_id", 0) or 0) exclude_ids = _int_list(request.args.get("exclude_ids"))
except (TypeError, ValueError): exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
project_id = 0 shapes = _parse_shapes(request.args.get("shapes") or "")
api_key = getattr(g, "api_key", None)
repo = (request.args.get("repo") or "").strip() may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
exclude_ids = [
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
if p.strip().isdigit()
]
exclude_sync_ids = [
int(p) for p in (request.args.get("exclude_sync_ids") or "").split(",")
if p.strip().isdigit()
]
result = await plugin_ctx_svc.build_write_path_hint( result = await plugin_ctx_svc.build_write_path_hint(
g.user.id, path, code=code, project_id=project_id, g.user.id, path, code=code, project_id=project_id,
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids, exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
stamp_shapes=shapes if may_stamp else None,
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
) )
return jsonify(result) return jsonify(result)
# The hook names at most a dozen definitions per write; anything past that is
# a generated file, not a shape being instantiated.
_SHAPES_CAP = 12
def _parse_shapes(raw: str) -> list[tuple[str, str]]:
"""`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")].
Unknown kinds and empty names are dropped, duplicates collapse, and the
list is capped — the hook's own cap, re-applied so the contract holds
for any caller."""
out: list[tuple[str, str]] = []
for part in raw.split(","):
kind, _sep, name = part.strip().partition(":")
kind, name = kind.strip(), name.strip()
if kind in ("css", "sym") and name and (kind, name) not in out:
out.append((kind, name))
if len(out) >= _SHAPES_CAP:
break
return out
@plugin_bp.get("/processes") @plugin_bp.get("/processes")
@login_required @login_required
async def process_manifest(): async def process_manifest():
+25 -28
View File
@@ -1,29 +1,26 @@
"""Rulebook / topic REST endpoints. """Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the Wraps services/rulebooks.py. Standard Scribe auth: get_current_user_id() is the
authenticated owner; the service enforces ownership scoping. authenticated owner; the service enforces ownership scoping.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required from scribe.auth import get_current_user_id, login_required
import scribe.services.rulebooks as rulebooks_svc import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete from scribe.services.trash import delete as trash_delete
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api") rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
# ── Rulebooks ─────────────────────────────────────────────────────────── # ── Rulebooks ───────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks") @rulebooks_bp.get("/rulebooks")
@login_required @login_required
async def list_rulebooks(): async def list_rulebooks():
rows = await rulebooks_svc.list_rulebooks(_uid()) rows = await rulebooks_svc.list_rulebooks(get_current_user_id())
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]}) return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
@@ -35,7 +32,7 @@ async def create_rulebook():
if not title: if not title:
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
rb = await rulebooks_svc.create_rulebook( rb = await rulebooks_svc.create_rulebook(
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
description=data.get("description", ""), description=data.get("description", ""),
) )
@@ -45,7 +42,7 @@ async def create_rulebook():
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>") @rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
@login_required @login_required
async def get_rulebook(rulebook_id: int): async def get_rulebook(rulebook_id: int):
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid()) rb = await rulebooks_svc.get_rulebook(rulebook_id, get_current_user_id())
if rb is None: if rb is None:
return jsonify({"error": "rulebook not found"}), 404 return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict()) return jsonify(rb.to_dict())
@@ -56,7 +53,7 @@ async def get_rulebook(rulebook_id: int):
async def update_rulebook(rulebook_id: int): async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {} data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")} fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields) rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
if rb is None: if rb is None:
return jsonify({"error": "rulebook not found"}), 404 return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict()) return jsonify(rb.to_dict())
@@ -65,7 +62,7 @@ async def update_rulebook(rulebook_id: int):
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>") @rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
@login_required @login_required
async def delete_rulebook(rulebook_id: int): async def delete_rulebook(rulebook_id: int):
await trash_delete(_uid(), "rulebook", rulebook_id) await trash_delete(get_current_user_id(), "rulebook", rulebook_id)
return "", 204 return "", 204
@@ -75,7 +72,7 @@ async def delete_rulebook(rulebook_id: int):
@login_required @login_required
async def list_topics(rulebook_id: int): async def list_topics(rulebook_id: int):
try: try:
rows = await rulebooks_svc.list_topics(rulebook_id, _uid()) rows = await rulebooks_svc.list_topics(rulebook_id, get_current_user_id())
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
return jsonify({"topics": [t.to_dict() for t in rows]}) return jsonify({"topics": [t.to_dict() for t in rows]})
@@ -91,7 +88,7 @@ async def create_topic(rulebook_id: int):
try: try:
topic = await rulebooks_svc.create_topic( topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id, rulebook_id=rulebook_id,
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
description=data.get("description", ""), description=data.get("description", ""),
order_index=data.get("order_index", 0), order_index=data.get("order_index", 0),
@@ -109,7 +106,7 @@ async def update_topic(topic_id: int):
k: v for k, v in data.items() k: v for k, v in data.items()
if k in ("title", "description", "order_index") if k in ("title", "description", "order_index")
} }
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields) topic = await rulebooks_svc.update_topic(topic_id, get_current_user_id(), **fields)
if topic is None: if topic is None:
return jsonify({"error": "topic not found"}), 404 return jsonify({"error": "topic not found"}), 404
return jsonify(topic.to_dict()) return jsonify(topic.to_dict())
@@ -118,7 +115,7 @@ async def update_topic(topic_id: int):
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>") @rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required @login_required
async def delete_topic(topic_id: int): async def delete_topic(topic_id: int):
if await trash_delete(_uid(), "topic", topic_id) is None: if await trash_delete(get_current_user_id(), "topic", topic_id) is None:
return jsonify({"error": "topic not found"}), 404 return jsonify({"error": "topic not found"}), 404
return "", 204 return "", 204
@@ -140,7 +137,7 @@ async def list_rules():
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400 return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
rows = await rulebooks_svc.list_rules( rows = await rulebooks_svc.list_rules(
user_id=_uid(), user_id=get_current_user_id(),
rulebook_id=rulebook_id, rulebook_id=rulebook_id,
topic_id=topic_id, topic_id=topic_id,
project_id=project_id, project_id=project_id,
@@ -159,7 +156,7 @@ async def create_rule(topic_id: int):
try: try:
rule = await rulebooks_svc.create_rule( rule = await rulebooks_svc.create_rule(
topic_id=topic_id, topic_id=topic_id,
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
statement=statement, statement=statement,
why=data.get("why", ""), why=data.get("why", ""),
@@ -174,7 +171,7 @@ async def create_rule(topic_id: int):
@rulebooks_bp.get("/rules/<int:rule_id>") @rulebooks_bp.get("/rules/<int:rule_id>")
@login_required @login_required
async def get_rule(rule_id: int): async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, _uid()) rule = await rulebooks_svc.get_rule(rule_id, get_current_user_id())
if rule is None: if rule is None:
return jsonify({"error": "rule not found"}), 404 return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict()) return jsonify(rule.to_dict())
@@ -188,7 +185,7 @@ async def update_rule(rule_id: int):
k: v for k, v in data.items() k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index") if k in ("title", "statement", "why", "how_to_apply", "order_index")
} }
rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields) rule = await rulebooks_svc.update_rule(rule_id, get_current_user_id(), **fields)
if rule is None: if rule is None:
return jsonify({"error": "rule not found"}), 404 return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict()) return jsonify(rule.to_dict())
@@ -197,7 +194,7 @@ async def update_rule(rule_id: int):
@rulebooks_bp.delete("/rules/<int:rule_id>") @rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required @login_required
async def delete_rule(rule_id: int): async def delete_rule(rule_id: int):
if await trash_delete(_uid(), "rule", rule_id) is None: if await trash_delete(get_current_user_id(), "rule", rule_id) is None:
return jsonify({"error": "rule not found"}), 404 return jsonify({"error": "rule not found"}), 404
return "", 204 return "", 204
@@ -213,7 +210,7 @@ async def subscribe_project(project_id: int):
return jsonify({"error": "rulebook_id is required"}), 400 return jsonify({"error": "rulebook_id is required"}), 400
try: try:
await rulebooks_svc.subscribe_project( await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(), project_id=project_id, rulebook_id=int(rulebook_id), user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -227,7 +224,7 @@ async def subscribe_project(project_id: int):
async def unsubscribe_project(project_id: int, rulebook_id: int): async def unsubscribe_project(project_id: int, rulebook_id: int):
try: try:
await rulebooks_svc.unsubscribe_project( await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(), project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -238,7 +235,7 @@ async def unsubscribe_project(project_id: int, rulebook_id: int):
@login_required @login_required
async def get_project_rules(project_id: int): async def get_project_rules(project_id: int):
result = await rulebooks_svc.get_applicable_rules( result = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=_uid(), project_id=project_id, user_id=get_current_user_id(),
) )
return jsonify(result) return jsonify(result)
@@ -248,7 +245,7 @@ async def get_project_rules(project_id: int):
async def suppress_project_rule(project_id: int, rule_id: int): async def suppress_project_rule(project_id: int, rule_id: int):
try: try:
await rulebooks_svc.suppress_rule_for_project( await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(), project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -260,7 +257,7 @@ async def suppress_project_rule(project_id: int, rule_id: int):
async def unsuppress_project_rule(project_id: int, rule_id: int): async def unsuppress_project_rule(project_id: int, rule_id: int):
try: try:
await rulebooks_svc.unsuppress_rule_for_project( await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(), project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -272,7 +269,7 @@ async def unsuppress_project_rule(project_id: int, rule_id: int):
async def suppress_project_topic(project_id: int, topic_id: int): async def suppress_project_topic(project_id: int, topic_id: int):
try: try:
await rulebooks_svc.suppress_topic_for_project( await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(), project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -284,7 +281,7 @@ async def suppress_project_topic(project_id: int, topic_id: int):
async def unsuppress_project_topic(project_id: int, topic_id: int): async def unsuppress_project_topic(project_id: int, topic_id: int):
try: try:
await rulebooks_svc.unsuppress_topic_for_project( await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(), project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -303,7 +300,7 @@ async def create_project_rule(project_id: int):
try: try:
rule = await rulebooks_svc.create_project_rule( rule = await rulebooks_svc.create_project_rule(
project_id=project_id, project_id=project_id,
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
statement=statement, statement=statement,
why=data.get("why", ""), why=data.get("why", ""),
+6 -5
View File
@@ -9,7 +9,9 @@ from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from scribe.config import Config from scribe.config import Config
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch from scribe.services.settings import (
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,12 +24,11 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# rows live on the admin's own user_id, so the plain GET returned them raw. # rows live on the admin's own user_id, so the plain GET returned them raw.
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.) # (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"}) _SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
_SECRET_MASK = "********"
def _masked(settings: dict) -> dict: def _masked(settings: dict) -> dict:
return { return {
k: (_SECRET_MASK if k in _SECRET_KEYS and v else v) k: (SECRET_MASK if k in _SECRET_KEYS and v else v)
for k, v in settings.items() for k, v in settings.items()
} }
@@ -53,7 +54,7 @@ async def update_settings_route():
str_v = str(v) str_v = str(v)
# A masked secret round-tripping through a client is "unchanged", not # A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential. # a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == _SECRET_MASK: if k in _SECRET_KEYS and str_v == SECRET_MASK:
continue continue
if not str_v: if not str_v:
await delete_setting(uid, k) await delete_setting(uid, k)
@@ -127,7 +128,7 @@ async def update_forge_connection_route(connection_id: int):
token = str(data.get("token", "")) token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the # The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection. # list showed, and storing the mask would silently break the connection.
if token == _SECRET_MASK: if token == SECRET_MASK:
token = "" token = ""
try: try:
row = await update_connection( row = await update_connection(
+5 -8
View File
@@ -1,33 +1,30 @@
"""Trash REST API — list / restore / purge soft-deleted content by batch.""" """Trash REST API — list / restore / purge soft-deleted content by batch."""
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify from quart import Blueprint, jsonify
from scribe.auth import login_required from scribe.auth import get_current_user_id, login_required
import scribe.services.trash as trash_svc import scribe.services.trash as trash_svc
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash") trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
def _uid() -> int:
return g.user.id
@trash_bp.get("") @trash_bp.get("")
@login_required @login_required
async def list_trash(): async def list_trash():
return jsonify({"batches": await trash_svc.list_trash(_uid())}) return jsonify({"batches": await trash_svc.list_trash(get_current_user_id())})
@trash_bp.post("/<batch_id>/restore") @trash_bp.post("/<batch_id>/restore")
@login_required @login_required
async def restore_batch(batch_id: str): async def restore_batch(batch_id: str):
n = await trash_svc.restore(_uid(), batch_id) n = await trash_svc.restore(get_current_user_id(), batch_id)
return jsonify({"restored": n}) return jsonify({"restored": n})
@trash_bp.delete("/<batch_id>") @trash_bp.delete("/<batch_id>")
@login_required @login_required
async def purge_batch(batch_id: str): async def purge_batch(batch_id: str):
n = await trash_svc.purge(_uid(), batch_id) n = await trash_svc.purge(get_current_user_id(), batch_id)
return jsonify({"purged": n}) return jsonify({"purged": n})
+7 -4
View File
@@ -13,8 +13,11 @@ def generate_key() -> str:
return "fmcp_" + secrets.token_urlsafe(32) return "fmcp_" + secrets.token_urlsafe(32)
def _hash_key(key: str) -> str: def hash_token(raw: str) -> str:
return hashlib.sha256(key.encode()).hexdigest() """The ONE fingerprint for every bearer secret stored by hash — API keys,
password-reset tokens, invitation tokens. Stored rows hold this, never
the raw value; a lookup hashes the presented token and compares."""
return hashlib.sha256(raw.encode()).hexdigest()
def _key_prefix(key: str) -> str: def _key_prefix(key: str) -> str:
@@ -32,7 +35,7 @@ async def create_api_key(
key = ApiKey( key = ApiKey(
user_id=user_id, user_id=user_id,
name=name, name=name,
key_hash=_hash_key(full_key), key_hash=hash_token(full_key),
key_prefix=_key_prefix(full_key), key_prefix=_key_prefix(full_key),
scope=scope, scope=scope,
) )
@@ -70,7 +73,7 @@ async def revoke_api_key(user_id: int, key_id: int) -> bool:
async def lookup_key(raw_key: str) -> ApiKey | None: async def lookup_key(raw_key: str) -> ApiKey | None:
"""Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" """Look up a non-revoked ApiKey by raw token value. Updates last_used_at."""
key_hash = _hash_key(raw_key) key_hash = hash_token(raw_key)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(ApiKey).where( select(ApiKey).where(
+16 -28
View File
@@ -1,4 +1,3 @@
import hashlib
import logging import logging
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -12,6 +11,8 @@ from scribe.models.invitation import InvitationToken
from scribe.models.password_reset import PasswordResetToken from scribe.models.password_reset import PasswordResetToken
from scribe.models.setting import Setting from scribe.models.setting import Setting
from scribe.models.user import User from scribe.models.user import User
from scribe.services.api_keys import hash_token
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,16 +143,7 @@ async def is_registration_open() -> bool:
user_count = await get_user_count() user_count = await get_user_count()
if user_count == 0: if user_count == 0:
return True return True
return await get_admin_setting("registration_open", "false") == "true"
async with async_session() as session:
# Find the admin user's registration_open setting
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "registration_open")
)
setting = result.scalar_one_or_none()
return setting.value == "true" if setting else False
async def list_users() -> list[User]: async def list_users() -> list[User]:
@@ -211,7 +203,7 @@ async def get_user_by_email(email: str) -> User | None:
async def create_password_reset_token(user_id: int) -> str: async def create_password_reset_token(user_id: int) -> str:
"""Generate a password reset token. Returns the raw token (for the email link).""" """Generate a password reset token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32) raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=1) expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
async with async_session() as session: async with async_session() as session:
@@ -239,7 +231,7 @@ async def create_password_reset_token(user_id: int) -> str:
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None: async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
"""Validate a reset token and update the user's password. Returns user_id on success.""" """Validate a reset token and update the user's password. Returns user_id on success."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -270,7 +262,7 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> int |
async def create_invitation(email: str, invited_by: int) -> str: async def create_invitation(email: str, invited_by: int) -> str:
"""Generate an invitation token. Returns the raw token (for the email link).""" """Generate an invitation token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32) raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(days=7) expires_at = datetime.now(timezone.utc) + timedelta(days=7)
async with async_session() as session: async with async_session() as session:
@@ -299,7 +291,7 @@ async def create_invitation(email: str, invited_by: int) -> str:
async def validate_invitation_token(raw_token: str) -> InvitationToken | None: async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
"""Look up by hash, check not used/expired. Returns the token record with email.""" """Look up by hash, check not used/expired. Returns the token record with email."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -319,7 +311,7 @@ async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None: async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None:
"""Validate token, create user with the invitation's email, mark token used.""" """Validate token, create user with the invitation's email, mark token used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -398,20 +390,16 @@ async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
return removed return removed
async def _auth_token_retention_loop() -> None: async def _auth_token_retention_tick() -> None:
import asyncio removed = await purge_expired_auth_tokens()
while True: if removed:
await asyncio.sleep(86400) # daily logger.info("Auth token retention: deleted %d expired token(s)", removed)
try:
removed = await purge_expired_auth_tokens()
if removed:
logger.info("Auth token retention: deleted %d expired token(s)", removed)
except Exception:
logger.exception("Error in auth token retention cleanup")
def start_auth_token_retention_loop() -> None: def start_auth_token_retention_loop() -> None:
global _auth_retention_task global _auth_retention_task
import asyncio
if _auth_retention_task is None or _auth_retention_task.done(): if _auth_retention_task is None or _auth_retention_task.done():
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop()) from scribe.services.background import start_periodic
_auth_retention_task = start_periodic(
86400, _auth_token_retention_tick, label="auth_token_retention", # daily
)
+20
View File
@@ -45,6 +45,26 @@ def spawn(coro: Coroutine, *, site: str) -> None:
task.add_done_callback(_done) task.add_done_callback(_done)
def start_periodic(interval_s: float, work, *, label: str) -> asyncio.Task:
"""A forever loop that sleeps ``interval_s`` then awaits ``work()``, logging
(never raising) when a tick fails — the one shape the hourly/daily
retention sweeps share (log retention, notification sweep, auth-token
purge). Sleeps FIRST so startup isn't a sweep; holds a strong reference
like spawn() so the loop cannot be garbage-collected mid-flight."""
async def _loop() -> None:
while True:
await asyncio.sleep(interval_s)
try:
await work()
except Exception:
logger.exception("periodic task %s failed", label)
task = asyncio.get_running_loop().create_task(_loop(), name=f"periodic-{label}")
_pending.add(task)
task.add_done_callback(_pending.discard)
return task
async def drain() -> None: async def drain() -> None:
"""Await everything in flight — for tests that need the writes landed.""" """Await everything in flight — for tests that need the writes landed."""
while _pending: while _pending:
+247 -278
View File
@@ -11,7 +11,7 @@ from scribe.models.note_supersession import NoteSupersession
from scribe.models.note_version import NoteVersion from scribe.models.note_version import NoteVersion
from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.note_usage import NoteUsageEvent from scribe.models.note_usage import NoteUsageEvent
from scribe.models.code_shape import CodeShape from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding from scribe.models.repo_binding import RepoBinding
from scribe.models.rulebook import ( from scribe.models.rulebook import (
@@ -40,8 +40,13 @@ logger = logging.getLogger(__name__)
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications # v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
# are judgment data worth carrying; a restore keeps a judgment only when its # are judgment data worth carrying; a restore keeps a judgment only when its
# snippet target survives the id re-mapping, else the row rejoins the todo. # snippet target survives the id re-mapping, else the row rejoins the todo.
# v8 (2026-08) added code_shape_events — the ledger's history (#2793): what
# was used where, when, and why is not recomputable, so it travels.
# v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870):
# judgment-grade edges (agent/audit/import) are operator records; mechanical
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
# Bump when the serialized schema changes. # Bump when the serialized schema changes.
BACKUP_VERSION = 7 BACKUP_VERSION = 9
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is # below, these two lists must together account for the entire schema — which is
@@ -59,8 +64,8 @@ _BACKED_UP = [
# v5 (2026-08): the five-year gap this list was written to stop. # v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens", "systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions", "note_usage_events", "repo_bindings", "note_supersessions",
# v7 (2026-08): the shape ledger (#2787). # v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
"code_shapes", "code_shapes", "code_shape_events", "code_shape_uses",
] ]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is # Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -176,6 +181,14 @@ def _code_shape_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows] return [r.to_dict() for r in rows]
def _code_shape_event_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows]
def _code_shape_use_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows]
def _repo_binding_rows(rows) -> list[dict]: def _repo_binding_rows(rows) -> list[dict]:
return [ return [
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key} {"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
@@ -183,6 +196,142 @@ def _repo_binding_rows(rows) -> list[dict]:
] ]
# Row builders for the sections both exporters carry. Pure, like the join-table
# helpers above; the full and per-user exports used to restate every one of
# these comprehensions side by side, and a column added to one and not the
# other is a backup that silently drops it (#2293's shape, one layer down).
def _user_rows(rows) -> list[dict]:
return [
{
"id": u.id, "username": u.username, "email": u.email,
"password_hash": u.password_hash, "oauth_sub": u.oauth_sub,
"role": u.role, "session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in rows
]
def _project_rows(rows) -> list[dict]:
return [
{
"id": p.id, "user_id": p.user_id, "title": p.title,
"description": p.description, "goal": p.goal, "status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in rows
]
def _milestone_rows(rows) -> list[dict]:
return [
{
"id": m.id, "user_id": m.user_id, "project_id": m.project_id,
"title": m.title, "description": m.description, "status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in rows
]
def _note_rows(rows) -> list[dict]:
return [
{
"id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body,
"tags": n.tags or [], "parent_id": n.parent_id,
"project_id": n.project_id, "milestone_id": n.milestone_id,
"status": n.status, "priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in rows
]
def _task_log_rows(rows) -> list[dict]:
return [
{
"id": tl.id, "user_id": tl.user_id, "task_id": tl.task_id,
"content": tl.content, "duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in rows
]
def _note_draft_rows(rows) -> list[dict]:
return [
{
"id": nd.id, "user_id": nd.user_id, "note_id": nd.note_id,
"proposed_body": nd.proposed_body, "original_body": nd.original_body,
"instruction": nd.instruction, "scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in rows
]
def _note_version_rows(rows) -> list[dict]:
return [
{
"id": nv.id, "user_id": nv.user_id, "note_id": nv.note_id,
"title": nv.title, "body": nv.body, "tags": nv.tags or [],
"pin_kind": nv.pin_kind, "pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in rows
]
def _setting_rows(rows) -> list[dict]:
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
def _rulebook_rows(rows) -> list[dict]:
return [
{
"id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title,
"description": rb.description, "always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rows
]
def _topic_rows(rows) -> list[dict]:
return [
{
"id": t.id, "rulebook_id": t.rulebook_id, "title": t.title,
"description": t.description, "order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in rows
]
def _rule_rows(rows) -> list[dict]:
return [
{
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
"title": r.title, "statement": r.statement, "why": r.why,
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rows
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Export # Export
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -216,6 +365,12 @@ async def export_full_backup() -> dict:
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all() usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all() repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
code_shapes = (await session.execute(select(CodeShape))).scalars().all() code_shapes = (await session.execute(select(CodeShape))).scalars().all()
code_shape_events = (await session.execute(
select(CodeShapeEvent).order_by(CodeShapeEvent.at, CodeShapeEvent.id)
)).scalars().all()
code_shape_uses = (await session.execute(
select(CodeShapeUse).order_by(CodeShapeUse.shape_id, CodeShapeUse.snippet_id)
)).scalars().all()
rulebooks = (await session.execute(select(Rulebook))).scalars().all() rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all() topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all() rules = (await session.execute(select(Rule))).scalars().all()
@@ -238,148 +393,17 @@ async def export_full_backup() -> dict:
"Store it securely and restrict access." "Store it securely and restrict access."
), ),
"_not_included": _NOT_INCLUDED, "_not_included": _NOT_INCLUDED,
"users": [ "users": _user_rows(users),
{ "projects": _project_rows(projects),
"id": u.id, "milestones": _milestone_rows(milestones),
"username": u.username, "notes": _note_rows(notes),
"email": u.email, "task_logs": _task_log_rows(task_logs),
"password_hash": u.password_hash, "note_drafts": _note_draft_rows(note_drafts),
"oauth_sub": u.oauth_sub, "note_versions": _note_version_rows(note_versions),
"role": u.role, "settings": _setting_rows(settings),
"session_version": u.session_version, "rulebooks": _rulebook_rows(rulebooks),
"created_at": u.created_at.isoformat(), "rulebook_topics": _topic_rows(topics),
} "rules": _rule_rows(rules),
for u in users
],
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"rulebooks": [
{
"id": rb.id,
"owner_user_id": rb.owner_user_id,
"title": rb.title,
"description": rb.description,
"always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rulebooks
],
"rulebook_topics": [
{
"id": t.id,
"rulebook_id": t.rulebook_id,
"title": t.title,
"description": t.description,
"order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in topics
],
"rules": [
{
"id": r.id,
"topic_id": r.topic_id,
"project_id": r.project_id,
"title": r.title,
"statement": r.statement,
"why": r.why,
"how_to_apply": r.how_to_apply,
"order_index": r.order_index,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rules
],
"rulebook_subscriptions": _subscription_rows(subscriptions), "rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions), "rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions),
@@ -391,6 +415,8 @@ async def export_full_backup() -> dict:
"repo_bindings": _repo_binding_rows(repo_bindings), "repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions), "note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes), "code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
} }
@@ -463,6 +489,15 @@ async def export_user_backup(user_id: int) -> dict:
code_shapes = (await session.execute( code_shapes = (await session.execute(
select(CodeShape).where(CodeShape.project_id.in_(project_ids)) select(CodeShape).where(CodeShape.project_id.in_(project_ids))
)).scalars().all() if project_ids else [] )).scalars().all() if project_ids else []
code_shape_events = (await session.execute(
select(CodeShapeEvent).where(CodeShapeEvent.project_id.in_(project_ids))
.order_by(CodeShapeEvent.at, CodeShapeEvent.id)
)).scalars().all() if project_ids else []
code_shape_uses = (await session.execute(
select(CodeShapeUse).join(CodeShape, CodeShape.id == CodeShapeUse.shape_id)
.where(CodeShape.project_id.in_(project_ids))
.order_by(CodeShapeUse.shape_id, CodeShapeUse.snippet_id)
)).scalars().all() if project_ids else []
rulebooks = (await session.execute( rulebooks = (await session.execute(
select(Rulebook).where(Rulebook.owner_user_id == user_id) select(Rulebook).where(Rulebook.owner_user_id == user_id)
)).scalars().all() )).scalars().all()
@@ -512,135 +547,16 @@ async def export_user_backup(user_id: int) -> dict:
"role": user.role, "role": user.role,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
} if user else None, } if user else None,
"projects": [ "projects": _project_rows(projects),
{ "milestones": _milestone_rows(milestones),
"id": p.id, "notes": _note_rows(notes),
"user_id": p.user_id, "task_logs": _task_log_rows(task_logs),
"title": p.title, "note_drafts": _note_draft_rows(note_drafts),
"description": p.description, "note_versions": _note_version_rows(note_versions),
"goal": p.goal, "settings": _setting_rows(settings),
"status": p.status, "rulebooks": _rulebook_rows(rulebooks),
"color": p.color, "rulebook_topics": _topic_rows(topics),
"created_at": p.created_at.isoformat(), "rules": _rule_rows(rules),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"rulebooks": [
{
"id": rb.id,
"owner_user_id": rb.owner_user_id,
"title": rb.title,
"description": rb.description,
"always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rulebooks
],
"rulebook_topics": [
{
"id": t.id,
"rulebook_id": t.rulebook_id,
"title": t.title,
"description": t.description,
"order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in topics
],
"rules": [
{
"id": r.id,
"topic_id": r.topic_id,
"project_id": r.project_id,
"title": r.title,
"statement": r.statement,
"why": r.why,
"how_to_apply": r.how_to_apply,
"order_index": r.order_index,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rules
],
"rulebook_subscriptions": _subscription_rows(subscriptions), "rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions), "rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions),
@@ -652,6 +568,8 @@ async def export_user_backup(user_id: int) -> dict:
"repo_bindings": _repo_binding_rows(repo_bindings), "repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions), "note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes), "code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
} }
@@ -755,7 +673,8 @@ async def _restore_v2(data: dict) -> dict:
"topic_suppressions": 0, "topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0, "systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0, "design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
"note_supersessions": 0, "code_shapes": 0, "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
"code_shape_uses": 0,
} }
async with async_session() as session: async with async_session() as session:
@@ -1137,6 +1056,7 @@ async def _restore_v2(data: dict) -> dict:
# survive the re-mapping (canonical/instance/variant with a gone # survive the re-mapping (canonical/instance/variant with a gone
# snippet) is downgraded to unclassified so it rejoins the todo # snippet) is downgraded to unclassified so it rejoins the todo
# honestly instead of dangling; exempt needs no target and keeps. # honestly instead of dangling; exempt needs no target and keeps.
shape_id_map: dict[int, int] = {}
for cs_data in data.get("code_shapes", []): for cs_data in data.get("code_shapes", []):
mapped_pid = project_id_map.get(cs_data.get("project_id", 0)) mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
if mapped_pid is None: if mapped_pid is None:
@@ -1149,7 +1069,7 @@ async def _restore_v2(data: dict) -> dict:
status = "unclassified" status = "unclassified"
classified_by = None classified_by = None
classified_at = None classified_at = None
session.add(CodeShape( shape = CodeShape(
project_id=mapped_pid, project_id=mapped_pid,
repo_key=cs_data.get("repo_key", ""), repo_key=cs_data.get("repo_key", ""),
path=cs_data.get("path", ""), path=cs_data.get("path", ""),
@@ -1163,11 +1083,60 @@ async def _restore_v2(data: dict) -> dict:
first_seen_commit=cs_data.get("first_seen_commit", ""), first_seen_commit=cs_data.get("first_seen_commit", ""),
last_seen_commit=cs_data.get("last_seen_commit", ""), last_seen_commit=cs_data.get("last_seen_commit", ""),
vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None, vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None,
# Fingerprints restore; proposals (#2792) deliberately do not —
# they are mechanical, and the next refresh recomputes them
# against the restored snippet ids.
signature=cs_data.get("signature", ""),
body_sha=cs_data.get("body_sha", ""),
classified_sha=cs_data.get("classified_sha", ""),
created_at=_dt(cs_data.get("created_at")), created_at=_dt(cs_data.get("created_at")),
updated_at=_dt(cs_data.get("updated_at")), updated_at=_dt(cs_data.get("updated_at")),
)) )
session.add(shape)
await session.flush()
if cs_data.get("id"):
shape_id_map[int(cs_data["id"])] = shape.id
stats["code_shapes"] += 1 stats["code_shapes"] += 1
# v8: the ledger's history rides its shapes. snippet_id is kept as
# the history's own claim (FK-free by design) but re-mapped when the
# snippet survived, so a restored timeline points at restored records.
for ev in data.get("code_shape_events", []):
new_shape_id = shape_id_map.get(ev.get("shape_id") or 0)
mapped_pid = project_id_map.get(ev.get("project_id", 0))
if new_shape_id is None or mapped_pid is None:
continue
old_sid = ev.get("snippet_id")
session.add(CodeShapeEvent(
shape_id=new_shape_id,
project_id=mapped_pid,
path=ev.get("path", ""),
symbol=ev.get("symbol", ""),
kind=ev.get("kind", "sym"),
event=ev.get("event", "classified"),
status=ev.get("status"),
snippet_id=note_id_map.get(old_sid, old_sid) if old_sid else None,
classified_by=ev.get("classified_by"),
reason=ev.get("reason"),
commit=ev.get("commit", ""),
at=_dt(ev.get("at")),
))
stats["code_shape_events"] += 1
# v9: consumption edges (#2870) ride their shape AND their snippet —
# both ends must have survived, or the edge is no longer a fact.
for use in data.get("code_shape_uses", []):
new_shape_id = shape_id_map.get(use.get("shape_id") or 0)
new_sid = note_id_map.get(use.get("snippet_id") or 0)
if new_shape_id is None or new_sid is None:
continue
session.add(CodeShapeUse(
shape_id=new_shape_id, snippet_id=new_sid,
basis=use.get("basis", "import"), evidence=use.get("evidence"),
created_at=_dt(use.get("created_at")),
))
stats["code_shape_uses"] += 1
await session.commit() await session.commit()
logger.info("Restored v2/v3 backup: %s", stats) logger.info("Restored v2/v3 backup: %s", stats)
+338 -34
View File
@@ -25,16 +25,18 @@ only ever reads the cache.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import io import io
import json import json
import logging import logging
import posixpath import posixpath
import re import re
import tarfile import tarfile
from datetime import datetime, timezone from typing import NamedTuple
from datetime import datetime, timedelta, timezone
from scribe.services.forge import ForgeSelector, get_forges from scribe.services.forge import ForgeSelector, get_forges
from scribe.services.repo_bindings import keys_for_project from scribe.services.repo_bindings import bindings_for_project
from scribe.services.settings import get_setting, set_setting from scribe.services.settings import get_setting, set_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -91,6 +93,148 @@ _ARROW_RE = re.compile(
) )
class Definition(NamedTuple):
"""One extracted definition with its content fingerprint (#2792).
`signature` is the definition line itself; `body_sha` hashes the block
whitespace- and comment-insensitively; `body` is the block's text, held
only for the duration of a refresh (the proposer matches on it) and
never stored.
"""
kind: str
name: str
signature: str
body_sha: str
body: str
line: int = -1 # 0-based line the definition starts on (#2869)
def _definition_on(raw: str) -> tuple[str, str] | None:
"""The (kind, name) this one line defines, or None. First match wins —
the same order the hook's awk program tries."""
m = _CSS_RE.match(raw)
if m:
return ("css", m.group(1))
line = _MODIFIERS_RE.sub("", raw.lstrip())
if m := _GO_METHOD_RE.match(line):
return ("sym", m.group(1))
if m := _KEYWORD_RE.match(line):
name = m.group(1)
if name.startswith("__") and name.endswith("__"):
return None
return ("sym", name)
if m := _ARROW_RE.match(line):
return ("sym", m.group(1))
return None
# A definition's block runs from its line until the next non-blank line at
# its own indentation or shallower that is not a closer — so a Python def ends
# at the next top-level statement, a braces block keeps its `}`, a CSS rule
# keeps its `}`. Capped so a generated monolith can't make one shape's
# fingerprint cover the file.
_BLOCK_CAP = 120
_CLOSERS = ("}", ")", "]", "end", "};", "});", ");", "})", "]);")
# Lines that don't change what a shape IS: comments and decorators. Dropped
# from the fingerprint so touching a comment above the next function doesn't
# read as this one's body changing.
_NOISE_PREFIXES = ("#", "//", "/*", "*", "*/", "@", "<!--", "-->")
_SIGNATURE_CAP = 300
def _indent(line: str) -> int:
return len(line) - len(line.lstrip())
def _block_sha(lines: list[str]) -> str:
kept = [
" ".join(ln.split())
for ln in lines
if ln.strip() and not ln.lstrip().startswith(_NOISE_PREFIXES)
]
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
def extract_definitions(text: str) -> list[Definition]:
"""Every definition this text makes, with signature + fingerprint.
Duplicate (kind, name) within one text collapse to the first — the
ledger's identity is per file, so a second definition of the same name
(an overload, a re-declaration) is the same shape to it.
"""
lines = text.splitlines()
starts: list[tuple[int, str, str]] = []
for i, raw in enumerate(lines):
hit = _definition_on(raw)
if hit:
starts.append((i, hit[0], hit[1]))
seen: set[tuple[str, str]] = set()
out: list[Definition] = []
for i, kind, name in starts:
if (kind, name) in seen:
continue
seen.add((kind, name))
base = _indent(lines[i])
end = min(len(lines), i + _BLOCK_CAP)
for j in range(i + 1, min(len(lines), i + _BLOCK_CAP)):
ln = lines[j]
if not ln.strip():
continue
if _indent(ln) <= base and ln.strip() not in _CLOSERS:
end = j
break
block = lines[i:end]
# A CSS rule's fingerprint is its DECLARATIONS, not its selector
# (#2872): the row's identity already carries the selector, and the
# question the fingerprint answers for derive grouping is "is this the
# same rule under another name?" — .closed-msg / .error-block /
# .success-msg with identical bodies are one dup group, not three
# lonely rows. Sym blocks keep their signature line in the hash.
hashed = block[1:] if kind == "css" and len(block) > 1 else block
out.append(Definition(
kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(hashed),
"\n".join(block), i,
))
return out
# --- by-construction scope (#2869) -------------------------------------------
#
# A Vue single-file component's `<style scoped>` rules and its `<script setup>`
# functions cannot be reached from any other file: they are one-offs by
# construction, not by judgment. The sync stamps them `scoped` (mechanical) so
# the human todo holds only shapes a person should look at, while the bodies
# stay in play for the proposer, derive grouping and divergence — the five
# auth views' identical rules were found exactly there. Unscoped `<style>` in
# a .vue and every non-.vue file stay ordinary.
_STYLE_OPEN_RE = re.compile(r"^\s*<style\b[^>]*\bscoped\b", re.IGNORECASE)
_STYLE_CLOSE_RE = re.compile(r"^\s*</style\s*>", re.IGNORECASE)
def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tuple[str, str]]:
"""The (kind, name) pairs among ``defs`` that are one-offs by
construction in this file: every sym in a .vue, and every css rule
that starts inside a `<style scoped>` block. Empty for other files."""
if not (path or "").lower().endswith(".vue"):
return set()
ranges: list[tuple[int, int]] = []
open_at: int | None = None
for i, ln in enumerate(text.splitlines()):
if open_at is None and _STYLE_OPEN_RE.match(ln):
open_at = i
elif open_at is not None and _STYLE_CLOSE_RE.match(ln):
ranges.append((open_at, i))
open_at = None
out: set[tuple[str, str]] = set()
for d in defs:
if d.kind == "sym":
out.add((d.kind, d.name))
elif any(a <= d.line <= b for a, b in ranges):
out.add((d.kind, d.name))
return out
def extract_shapes(text: str) -> list[tuple[str, str]]: def extract_shapes(text: str) -> list[tuple[str, str]]:
"""Every (kind, name) this text DEFINES — kind is "css" or "sym". """Every (kind, name) this text DEFINES — kind is "css" or "sym".
@@ -98,29 +242,7 @@ def extract_shapes(text: str) -> list[tuple[str, str]]:
line, dunders are skipped (every class defines __init__ — guaranteed line, dunders are skipped (every class defines __init__ — guaranteed
noise), duplicates within one text count once. noise), duplicates within one text count once.
""" """
seen: set[tuple[str, str]] = set() return [(d.kind, d.name) for d in extract_definitions(text)]
out: list[tuple[str, str]] = []
for raw in text.splitlines():
m = _CSS_RE.match(raw)
if m:
shape = ("css", m.group(1))
else:
line = _MODIFIERS_RE.sub("", raw.lstrip())
if m := _GO_METHOD_RE.match(line):
shape = ("sym", m.group(1))
elif m := _KEYWORD_RE.match(line):
name = m.group(1)
if name.startswith("__") and name.endswith("__"):
continue
shape = ("sym", name)
elif m := _ARROW_RE.match(line):
shape = ("sym", m.group(1))
else:
continue
if shape not in seen:
seen.add(shape)
out.append(shape)
return out
def scannable(path: str) -> bool: def scannable(path: str) -> bool:
@@ -131,14 +253,34 @@ def scannable(path: str) -> bool:
return not path.lower().endswith(_SKIP_SUFFIXES) return not path.lower().endswith(_SKIP_SUFFIXES)
class ArchiveShape(NamedTuple):
"""A definition located in a repo archive — what the sync upserts and
the proposer matches. The leading (path, kind, name) triple is the
ledger identity; the rest is the fingerprint and the transient body."""
path: str
kind: str
name: str
signature: str
body_sha: str
body: str
scoped: bool = False # one-off by construction (#2869)
def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]: def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
"""(path, kind, name) for every definition in a repo tarball. """(path, kind, name) for every definition in a repo tarball — the
identity view of definitions_from_archive."""
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
"""Every definition in a repo tarball, with its fingerprint and body.
Forge archives wrap content in a single top-level directory (repo-ref/); Forge archives wrap content in a single top-level directory (repo-ref/);
that component is stripped so paths match recorded snippet locations, that component is stripped so paths match recorded snippet locations,
which are repo-relative. Non-UTF-8 files are binaries and skipped. which are repo-relative. Non-UTF-8 files are binaries and skipped.
""" """
shapes: list[tuple[str, str, str]] = [] shapes: list[ArchiveShape] = []
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar: with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar: for member in tar:
if not member.isfile() or "/" not in member.name: if not member.isfile() or "/" not in member.name:
@@ -153,7 +295,15 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
text = handle.read().decode("utf-8") text = handle.read().decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
continue continue
shapes.extend((path, kind, name) for kind, name in extract_shapes(text)) defs = extract_definitions(text)
scoped = scoped_definitions(path, text, defs)
shapes.extend(
ArchiveShape(
path, d.kind, d.name, d.signature, d.body_sha, d.body,
(d.kind, d.name) in scoped,
)
for d in defs
)
return shapes return shapes
@@ -253,13 +403,20 @@ async def compute_coverage(
served: list[tuple[str, str]] = [] served: list[tuple[str, str]] = []
recorded = await _recorded_locations(user_id, project_id) recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id): # The proposer's canon catalog, read once per refresh and shared across
# the project's repos (#2792).
canons = None
proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0}
for binding in await bindings_for_project(user_id, project_id):
key = binding.repo_key
hit = selector.resolve(key) hit = selector.resolve(key)
if hit is None: if hit is None:
continue # bound to a host no connection serves continue # bound to a host no connection serves
forge, api_repo = hit forge, api_repo = hit
ref = await forge.default_branch(api_repo) # The binding's own ref when it names one (#2873: a dev-first project
shapes = shapes_from_archive(await forge.archive(api_repo, ref)) # has its ledger follow dev), else the forge's default branch.
ref = binding.ref or await forge.default_branch(api_repo)
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
# The head commit is provenance sugar on the ledger rows; failing to # The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well # learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when. # enough and the row timestamps carry the when.
@@ -268,19 +425,49 @@ async def compute_coverage(
except ForgeError: except ForgeError:
marker = ref marker = ref
await shape_ledger.sync_repo_shapes( await shape_ledger.sync_repo_shapes(
project_id, key, shapes, seen_marker=marker project_id, key, definitions, seen_marker=marker
) )
served.append((key, ref)) served.append((key, ref))
# Propose while the bodies are in hand — the one moment they exist.
# Canonical marking below only touches rows the proposer leaves
# alone (a canon's own location never gets a proposal), so the order
# is immaterial; the proposer must not be able to fail the refresh.
try:
if canons is None:
canons = await shape_ledger.canon_catalog(user_id)
stats = await shape_ledger.propose_for_repo(
user_id, project_id, key, definitions, canons=canons
)
for k in proposer_stats:
proposer_stats[k] += stats.get(k, 0)
except Exception:
logger.warning("shape proposer failed for %s", key, exc_info=True)
if not served: if not served:
return None return None
await shape_ledger.mark_canonicals(project_id, recorded) await shape_ledger.mark_canonicals(project_id, recorded)
try:
await shape_ledger.apply_derive_groups(project_id)
except Exception:
logger.warning("derive-first grouping failed", exc_info=True)
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
# where a canon dominates. The previous computation's stamp is the cache;
# a first seed has none, so it flags nothing (everything is new then).
try:
previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}")
since = None
if previous:
stamp = (json.loads(previous) or {}).get("computed_at")
since = datetime.fromisoformat(stamp) if stamp else None
await shape_ledger.flag_divergence(project_id, since=since)
except Exception:
logger.warning("divergence pass failed", exc_info=True)
# Project-wide readout, deliberately wider than this walk: a second bound # Project-wide readout, deliberately wider than this walk: a second bound
# repo that was unreachable today still has live rows, and they count. # repo that was unreachable today still has live rows, and they count.
rows = await shape_ledger.live_rows(project_id) rows = await shape_ledger.live_rows(project_id)
counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0, counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0,
"unclassified": 0} "scoped": 0, "unclassified": 0}
for row in rows: for row in rows:
counts[row.status] = counts.get(row.status, 0) + 1 counts[row.status] = counts.get(row.status, 0) + 1
by_repo: dict[str, dict[str, int]] = {} by_repo: dict[str, dict[str, int]] = {}
@@ -290,11 +477,24 @@ async def compute_coverage(
agg["accounted"] += row.status != "unclassified" agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified") unclassified = counts.pop("unclassified")
proposals = shape_ledger.proposal_summary(rows)
divergence = shape_ledger.divergence_summary(rows)
return { return {
"total": len(rows), "total": len(rows),
"accounted": len(rows) - unclassified, "accounted": len(rows) - unclassified,
"unclassified": unclassified, "unclassified": unclassified,
"counts": counts, "counts": counts,
# The proposer's standing (#2792): canon proposals awaiting a
# confirm, the largest derive-first groups, and what this refresh did.
"proposed": proposals["proposed"],
"derive_groups": proposals["derive_groups"],
"top_canon": proposals.get("top_canon"),
"proposer": proposer_stats,
# The divergence readout (#2793): button B where button A is canon,
# and judged shapes whose bodies moved since they were judged.
"divergent": divergence["divergent"],
"divergence": divergence["divergence"],
"recheck": divergence["recheck"],
# Honesty flag, not decoration: every surface that shows the number # Honesty flag, not decoration: every surface that shows the number
# is expected to carry it through. # is expected to carry it through.
"estimate": True, "estimate": True,
@@ -323,6 +523,90 @@ async def refresh_coverage(
return coverage return coverage
# The arrival-moment self-seed (#2802). A ledger that has never been computed
# used to wait for someone to find the UI Refresh button; entering the project
# is the moment the number is wanted, so entering is the moment it seeds.
_SEED_MAX_AGE = timedelta(hours=24)
# Projects with a refresh already running — concurrent enters must not fetch
# the same tarball N times. In-process on purpose: the cost being bounded is
# per-process forge traffic, and a rare double-fetch across workers is
# harmless (the upsert is idempotent).
_inflight_seed: set[int] = set()
async def refresh_if_stale(
user_id: int, project_id: int, cached: dict | None = None
) -> None:
"""Background-refresh a project's ledger when its readout is absent or
older than a day. Fire-and-forget material (background.spawn): every exit
is quiet, every failure a WARNING — a seed must never surface as an
enter_project error. ``user_id`` is the project OWNER (whose keyring and
cache this is); pass ``cached`` when the caller already read it.
"""
try:
if cached is None:
cached = await cached_coverage(user_id, project_id)
if cached:
try:
computed = datetime.fromisoformat(cached.get("computed_at") or "")
if datetime.now(timezone.utc) - computed < _SEED_MAX_AGE:
return
except ValueError:
pass # an unreadable stamp reads as stale
if project_id in _inflight_seed:
return
selector = await get_forges(user_id, project_id)
if not selector.configured:
return # rule #115: forge-less stays exactly as it was
_inflight_seed.add(project_id)
try:
await refresh_coverage(user_id, project_id, selector=selector)
finally:
_inflight_seed.discard(project_id)
except Exception:
logger.warning(
"background coverage seed failed for project %s", project_id,
exc_info=True,
)
async def refresh_for_caller(caller_id: int, project_id: int) -> dict:
"""The explicit agent-facing refresh (#2802) — synchronous, named errors.
Write-gated: recomputing spends the owner's forge API budget and rewrites
ledger rows' seen-markers, so a read share doesn't grant it. Resolution
runs on the OWNER's keyring like every other forge read. Raises
ValueError with a fixable message instead of silently measuring nothing —
the caller is an agent mid-task, and "None" would strand it exactly the
way the button-only path did.
"""
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import access
if not await access.can_write_project(caller_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
async with async_session() as session:
project = await session.get(Project, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
owner_id = project.user_id or caller_id
selector = await get_forges(owner_id, project_id)
if not selector.configured:
raise ValueError(
"No forge connection serves this project — the owner adds one "
"under Settings → Integrations → Git Forges"
)
coverage = await refresh_coverage(owner_id, project_id, selector=selector)
if coverage is None:
raise ValueError(
"No bound repo is served by the owner's forge connections — "
"bind_repo the project's repo on a host a connection serves"
)
return coverage
async def cached_coverage(user_id: int, project_id: int) -> dict | None: async def cached_coverage(user_id: int, project_id: int) -> dict | None:
"""The last computed summary, or None — never computes.""" """The last computed summary, or None — never computes."""
raw = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}", "") raw = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}", "")
@@ -341,7 +625,7 @@ def coverage_line(coverage: dict) -> str:
counts = coverage.get("counts") or {} counts = coverage.get("counts") or {}
breakdown = " · ".join( breakdown = " · ".join(
f"{counts[k]} {k}" f"{counts[k]} {k}"
for k in ("canonical", "instance", "variant", "exempt") for k in ("canonical", "instance", "variant", "exempt", "scoped")
if counts.get(k) if counts.get(k)
) )
line = ( line = (
@@ -354,7 +638,27 @@ def coverage_line(coverage: dict) -> str:
unclassified = coverage.get("unclassified", 0) unclassified = coverage.get("unclassified", 0)
if unclassified: if unclassified:
line += f"; {unclassified} unclassified" line += f"; {unclassified} unclassified"
standing = []
if coverage.get("proposed"):
standing.append(f"{coverage['proposed']} proposed")
n_groups = len(coverage.get("derive_groups") or [])
if n_groups:
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
if coverage.get("divergent"):
standing.append(f"{coverage['divergent']} DIVERGENT")
# The next action, on the line (#2874): the canon with the biggest
# queue to confirm, and the widest body-identical copy to consolidate.
top = coverage.get("top_canon") or {}
if top.get("snippet_id"):
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
first = (coverage.get("derive_groups") or [{}])[0]
if first.get("label") and first.get("files"):
standing.append(f"top copy {first['label']} ×{first['files']} files")
if standing:
line += f" ({', '.join(standing)})"
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []] gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps: if gaps:
line += ", largest: " + ", ".join(gaps) line += ", largest: " + ", ".join(gaps)
if coverage.get("recheck"):
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
return line return line
+2 -1
View File
@@ -15,6 +15,7 @@ from scribe.models import async_session
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.base import iso
from scribe.services import milestones as milestones_svc from scribe.services import milestones as milestones_svc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -173,7 +174,7 @@ async def _recently_completed(user_id: int) -> list[dict]:
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT) .order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
)).all() )).all()
return [{"id": n.id, "title": n.title, "project_title": ptitle, return [{"id": n.id, "title": n.title, "project_title": ptitle,
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows] "completed_at": iso(n.completed_at)} for n, ptitle in rows]
async def _week_stats(user_id: int) -> dict: async def _week_stats(user_id: int) -> dict:
+3 -6
View File
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
from sqlalchemy import text from sqlalchemy import text
from scribe.models import async_session, engine from scribe.models import async_session, engine
from scribe.models.base import iso
from scribe.services.settings import get_admin_setting, set_admin_setting from scribe.services.settings import get_admin_setting, set_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -128,10 +129,6 @@ _HEALTH_SQL = text("""
""") """)
def _iso(value) -> str | None:
return value.isoformat() if value is not None else None
async def get_table_health() -> dict: async def get_table_health() -> dict:
"""Per-table health from Postgres statistics + the total database size. """Per-table health from Postgres statistics + the total database size.
@@ -156,8 +153,8 @@ async def get_table_health() -> dict:
"dead_pct": float(r["dead_pct"] or 0), "dead_pct": float(r["dead_pct"] or 0),
"total_bytes": int(r["total_bytes"] or 0), "total_bytes": int(r["total_bytes"] or 0),
"mod_since_analyze": int(r["mod_since_analyze"] or 0), "mod_since_analyze": int(r["mod_since_analyze"] or 0),
"last_vacuum": _iso(r["last_vacuum"]), "last_vacuum": iso(r["last_vacuum"]),
"last_analyze": _iso(r["last_analyze"]), "last_analyze": iso(r["last_analyze"]),
} }
for r in rows for r in rows
] ]
+18 -46
View File
@@ -1,9 +1,8 @@
"""Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE). """Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE).
Mirrors trash_scheduler.py: a single global BackgroundScheduler job bridges One ScheduledJob (services/scheduler.py). Scheduled for 04:00 UTC by default
into the asyncio loop to run the async maintenance. Scheduled for 04:00 UTC by — after the 03:30 trash purge — so it collects the dead tuples that night's
default — after the 03:30 trash purge — so it collects the dead tuples that delete sweeps leave behind.
night's delete sweeps leave behind.
Two things are operator-tunable from the admin Settings card: Two things are operator-tunable from the admin Settings card:
- db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling - db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling
@@ -16,9 +15,9 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from scribe.services.scheduler import ScheduledJob
from scribe.services.settings import get_admin_setting from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,9 +25,6 @@ logger = logging.getLogger(__name__)
_JOB_ID = "db_maintenance_vacuum" _JOB_ID = "db_maintenance_vacuum"
_DEFAULT_HOUR = 4 _DEFAULT_HOUR = 4
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
async def get_maintenance_hour() -> int: async def get_maintenance_hour() -> int:
"""The configured run-hour (UTC, 023), clamped; default 04:00.""" """The configured run-hour (UTC, 023), clamped; default 04:00."""
@@ -45,23 +41,20 @@ async def is_maintenance_enabled() -> bool:
return (await get_admin_setting("db_maintenance_enabled", "true")) != "false" return (await get_admin_setting("db_maintenance_enabled", "true")) != "false"
def _run_maintenance_threadsafe() -> None: async def _run_maintenance() -> None:
"""APScheduler invokes this from a worker thread; bridge into the loop.""" if not await is_maintenance_enabled():
if _loop is None: logger.debug("db maintenance: disabled, skipping scheduled run")
logger.warning("db maintenance scheduler: no loop registered")
return return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
async def _runner():
try:
if not await is_maintenance_enabled():
logger.debug("db maintenance: disabled, skipping scheduled run")
return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
except Exception:
logger.exception("db maintenance run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop) _JOB = ScheduledJob(_JOB_ID, _run_maintenance, label="DB maintenance")
def _trigger(hour: int) -> CronTrigger:
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
return CronTrigger(hour=hour, minute=0, timezone="UTC")
def start_db_maintenance_scheduler( def start_db_maintenance_scheduler(
@@ -72,36 +65,15 @@ def start_db_maintenance_scheduler(
in rather than read here so we never block the event loop at startup. The in rather than read here so we never block the event loop at startup. The
job's enabled-gate is re-checked at every fire, so only the hour is needed job's enabled-gate is re-checked at every fire, so only the hour is needed
up front.""" up front."""
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler = BackgroundScheduler() _JOB.start(loop, _trigger(hour), describe=f"daily {hour:02d}:00 UTC")
_scheduler.add_job(
_run_maintenance_threadsafe,
trigger=CronTrigger(hour=hour, minute=0, timezone="UTC"),
id=_JOB_ID,
replace_existing=True,
)
_scheduler.start()
logger.info("DB maintenance scheduler started (daily %02d:00 UTC)", hour)
def reschedule_db_maintenance(hour: int) -> None: def reschedule_db_maintenance(hour: int) -> None:
"""Move the live job to a new UTC hour (called when the admin changes it).""" """Move the live job to a new UTC hour (called when the admin changes it)."""
if _scheduler is None:
return
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler.reschedule_job( _JOB.reschedule(_trigger(hour), describe=f"{hour:02d}:00 UTC")
_JOB_ID, trigger=CronTrigger(hour=hour, minute=0, timezone="UTC")
)
logger.info("DB maintenance scheduler rescheduled to %02d:00 UTC", hour)
def stop_db_maintenance_scheduler() -> None: def stop_db_maintenance_scheduler() -> None:
global _scheduler _JOB.stop()
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("DB maintenance scheduler stopped")
+3 -2
View File
@@ -32,6 +32,7 @@ from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding from scribe.models.embedding import NoteEmbedding
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.rulebook import Rule from scribe.models.rulebook import Rule
from scribe.models.base import iso
from scribe.services import embeddings as embeddings_svc from scribe.services import embeddings as embeddings_svc
# Imported rather than redeclared: no service imports this module (the create # Imported rather than redeclared: no service imports this module (the create
# gate is called from the routes/tools layer), so there is no cycle to dodge, # gate is called from the routes/tools layer), so there is no cycle to dodge,
@@ -592,8 +593,8 @@ async def find_duplicate_records(
titles[int(i)] = t titles[int(i)] = t
records[int(i)] = d or {} records[int(i)] = d or {}
meta[int(i)] = { meta[int(i)] = {
"created_at": created.isoformat() if created else None, "created_at": iso(created),
"updated_at": updated.isoformat() if updated else None, "updated_at": iso(updated),
"task_kind": task_kind, "task_kind": task_kind,
} }
except Exception: except Exception:
+14 -3
View File
@@ -16,12 +16,14 @@ endorsed, so a one-off direct share has to be searched for rather than arriving
in your ambient lists. in your ambient lists.
""" """
import json import json
import re
import logging import logging
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.base import iso
from scribe.services.access import browsable_notes_clause, readable_notes_clause from scribe.services.access import browsable_notes_clause, readable_notes_clause
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -75,6 +77,10 @@ def location_matches(data: dict | None, parts: dict[str, str]) -> bool:
if all( if all(
_path_matches((loc.get(key) or "").strip(), want) _path_matches((loc.get(key) or "").strip(), want)
if key == "path" if key == "path"
# Repo names are recorded free-form ("Scribe" / "FabledScribe" /
# "fabledscribe") — case is never the distinguishing thing (#2874).
else (loc.get(key) or "").strip().lower() == want.lower()
if key == "repo"
else (loc.get(key) or "").strip() == want else (loc.get(key) or "").strip() == want
for key, want in parts.items() for key, want in parts.items()
): ):
@@ -98,6 +104,11 @@ def location_jsonpath(parts: dict[str, str]) -> str:
if key == "path": if key == "path":
prefix = json.dumps(want.rstrip("/") + "/") prefix = json.dumps(want.rstrip("/") + "/")
filters.append(f"(@.path == {literal} || @.path starts with {prefix})") filters.append(f"(@.path == {literal} || @.path starts with {prefix})")
elif key == "repo":
# Case-insensitive, anchored, regex-escaped (#2874) — mirrors the
# Python dialect's .lower() compare.
pattern = json.dumps("^" + re.escape(want) + "$")
filters.append(f'(@.repo like_regex {pattern} flag "i")')
else: else:
filters.append(f"@.{key} == {literal}") filters.append(f"@.{key} == {literal}")
return f"$.locations[*] ? ({' && '.join(filters)})" return f"$.locations[*] ? ({' && '.join(filters)})"
@@ -211,8 +222,8 @@ def _note_to_item(note: Note) -> dict:
# These lists now include records shared with the caller, so the client # These lists now include records shared with the caller, so the client
# needs the owner to tell "mine" from "someone else's" in a mixed list. # needs the owner to tell "mine" from "someone else's" in a mixed list.
"user_id": note.user_id, "user_id": note.user_id,
"created_at": note.created_at.isoformat(), "created_at": iso(note.created_at),
"updated_at": note.updated_at.isoformat(), "updated_at": iso(note.updated_at),
} }
# Drift verdict (#2086), when one has been recorded. Included here rather # Drift verdict (#2086), when one has been recorded. Included here rather
# than decorated on by the snippet layer because `current` is derivable from # than decorated on by the snippet layer because `current` is derivable from
@@ -249,7 +260,7 @@ def _note_to_item(note: Note) -> dict:
item["task_kind"] = note.task_kind item["task_kind"] = note.task_kind
item["status"] = note.status item["status"] = note.status
item["priority"] = note.priority item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None item["due_date"] = iso(note.due_date)
return item return item
+6 -10
View File
@@ -194,18 +194,14 @@ async def delete_old_logs(retention_days: int) -> int:
return result.rowcount return result.rowcount
async def _retention_loop() -> None: async def _retention_tick() -> None:
while True: deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
await asyncio.sleep(3600) # hourly if deleted:
try: logger.info("Log retention: deleted %d old log entries", deleted)
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
if deleted:
logger.info("Log retention: deleted %d old log entries", deleted)
except Exception:
logger.exception("Error in log retention cleanup")
def start_log_retention_loop() -> None: def start_log_retention_loop() -> None:
global _retention_task global _retention_task
if _retention_task is None or _retention_task.done(): if _retention_task is None or _retention_task.done():
_retention_task = asyncio.create_task(_retention_loop()) from scribe.services.background import start_periodic
_retention_task = start_periodic(3600, _retention_tick, label="log_retention") # hourly
+3 -9
View File
@@ -235,12 +235,6 @@ async def get_project_milestone_summaries(
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]: async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats.""" """Ordered milestones with progress — the one-project view of
milestones = await list_milestones(user_id, project_id) get_project_milestone_summaries (two queries, not N+1)."""
result = [] return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
+3 -2
View File
@@ -36,6 +36,7 @@ from sqlalchemy import case, func, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.models.base import iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -232,13 +233,13 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
slot["ambient_count"] = int(n) slot["ambient_count"] = int(n)
elif event == SURFACED: elif event == SURFACED:
slot["surfaced_count"] = int(n) slot["surfaced_count"] = int(n)
slot["last_surfaced_at"] = last_at.isoformat() if last_at else None slot["last_surfaced_at"] = iso(last_at)
elif event == PULLED: elif event == PULLED:
# Pulls are pulls regardless of what surfaced the record — the # Pulls are pulls regardless of what surfaced the record — the
# question a pull answers ("did anyone ever open this?") doesn't # question a pull answers ("did anyone ever open this?") doesn't
# depend on how it was found. # depend on how it was found.
slot["pull_count"] = slot["pull_count"] + int(n) slot["pull_count"] = slot["pull_count"] + int(n)
latest = last_at.isoformat() if last_at else None latest = iso(last_at)
if latest and (slot["last_pulled_at"] or "") < latest: if latest and (slot["last_pulled_at"] or "") < latest:
slot["last_pulled_at"] = latest slot["last_pulled_at"] = latest
return out return out
+24 -44
View File
@@ -3,17 +3,20 @@
import asyncio import asyncio
import json import json
import logging import logging
from datetime import date, datetime, time, timezone from datetime import date, datetime, time, timedelta, timezone
from sqlalchemy import func, select, text from sqlalchemy import delete as sa_delete, func, select, text
from sqlalchemy import update as sa_update
from scribe.models import async_session from scribe.models import async_session
from scribe.models.app_log import AppLog from scribe.models.app_log import AppLog
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.setting import Setting from scribe.models.notification import Notification
from scribe.models.user import User from scribe.models.user import User
from scribe.models.base import iso
from scribe.services.email import _email_html, is_smtp_configured, send_email from scribe.services.email import _email_html, is_smtp_configured, send_email
from scribe.services.logging import log_audit from scribe.services.logging import log_audit
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,13 +32,7 @@ SECURITY_EVENT_LABELS = {
async def _get_user_notification_pref(user_id: int, key: str) -> bool: async def _get_user_notification_pref(user_id: int, key: str) -> bool:
"""Check if a user has a notification preference enabled (default True).""" """Check if a user has a notification preference enabled (default True)."""
async with async_session() as session: return await get_setting(user_id, key, "true") != "false"
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
# Default to enabled
return setting.value != "false" if setting else True
async def _get_user_email(user_id: int) -> str | None: async def _get_user_email(user_id: int) -> str | None:
@@ -222,7 +219,7 @@ async def check_due_tasks() -> None:
for task in user_tasks: for task in user_tasks:
overdue = task.due_date < today if task.due_date else False overdue = task.due_date < today if task.due_date else False
date_color = "#ef4444" if overdue else "#374151" date_color = "#ef4444" if overdue else "#374151"
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else "" date_label = f'<span style="color: {date_color};">{iso(task.due_date)}</span>' if task.due_date else ""
overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else "" overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else ""
task_rows += ( task_rows += (
f'<tr>' f'<tr>'
@@ -261,13 +258,10 @@ _NOTIFICATION_RETENTION_DAYS = 30
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int: async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
"""Delete already-read in-app notifications older than retention_days.""" """Delete already-read in-app notifications older than retention_days."""
from datetime import timedelta
from sqlalchemy import delete
from scribe.models.notification import Notification
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
delete(Notification).where( sa_delete(Notification).where(
Notification.read_at.isnot(None), Notification.read_at.isnot(None),
Notification.read_at < cutoff, Notification.read_at < cutoff,
) )
@@ -276,25 +270,21 @@ async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETEN
return result.rowcount or 0 return result.rowcount or 0
async def _notification_loop() -> None: async def _notification_tick() -> None:
while True: try:
await asyncio.sleep(3600) # hourly await check_due_tasks()
try: except Exception:
await check_due_tasks() logger.exception("Error in notification loop")
except Exception: removed = await purge_old_read_notifications()
logger.exception("Error in notification loop") if removed:
try: logger.info("Notification retention: deleted %d read notification(s)", removed)
removed = await purge_old_read_notifications()
if removed:
logger.info("Notification retention: deleted %d read notification(s)", removed)
except Exception:
logger.exception("Error in notification retention cleanup")
def start_notification_loop() -> None: def start_notification_loop() -> None:
global _notification_task global _notification_task
if _notification_task is None or _notification_task.done(): if _notification_task is None or _notification_task.done():
_notification_task = asyncio.create_task(_notification_loop()) from scribe.services.background import start_periodic
_notification_task = start_periodic(3600, _notification_tick, label="notifications") # hourly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -303,7 +293,6 @@ def start_notification_loop() -> None:
async def create_in_app_notification(user_id: int, notif_type: str, payload: dict): async def create_in_app_notification(user_id: int, notif_type: str, payload: dict):
"""Create an in-app Notification record.""" """Create an in-app Notification record."""
from scribe.models.notification import Notification
async with async_session() as session: async with async_session() as session:
n = Notification(user_id=user_id, type=notif_type, payload=payload) n = Notification(user_id=user_id, type=notif_type, payload=payload)
session.add(n) session.add(n)
@@ -316,11 +305,10 @@ async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
try: try:
if not await is_smtp_configured(): if not await is_smtp_configured():
return return
async with async_session() as session: email = await _get_user_email(user_id)
user = await session.get(User, user_id) if email:
if user and user.email:
html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>") html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>")
await send_email(user.email, subject, html) await send_email(email, subject, html)
except Exception: except Exception:
logger.exception("Share email notification failed for user %d", user_id) logger.exception("Share email notification failed for user %d", user_id)
@@ -427,7 +415,6 @@ async def notify_group_added(
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]: async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
from scribe.models.notification import Notification
async with async_session() as session: async with async_session() as session:
q = select(Notification).where(Notification.user_id == user_id) q = select(Notification).where(Notification.user_id == user_id)
if unread_only: if unread_only:
@@ -438,7 +425,6 @@ async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> l
async def unread_notification_count(user_id: int) -> int: async def unread_notification_count(user_id: int) -> int:
from scribe.models.notification import Notification
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(func.count()).where( select(func.count()).where(
@@ -450,8 +436,6 @@ async def unread_notification_count(user_id: int) -> int:
async def mark_notification_read(user_id: int, notification_id: int) -> bool: async def mark_notification_read(user_id: int, notification_id: int) -> bool:
from scribe.models.notification import Notification
from datetime import timezone as tz
async with async_session() as session: async with async_session() as session:
n = (await session.execute( n = (await session.execute(
select(Notification).where( select(Notification).where(
@@ -461,21 +445,17 @@ async def mark_notification_read(user_id: int, notification_id: int) -> bool:
)).scalar_one_or_none() )).scalar_one_or_none()
if not n: if not n:
return False return False
from datetime import datetime n.read_at = datetime.now(timezone.utc)
n.read_at = datetime.now(tz.utc)
await session.commit() await session.commit()
return True return True
async def mark_all_notifications_read(user_id: int) -> int: async def mark_all_notifications_read(user_id: int) -> int:
from scribe.models.notification import Notification
from datetime import datetime, timezone as tz
from sqlalchemy import update as sa_update
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
sa_update(Notification) sa_update(Notification)
.where(Notification.user_id == user_id, Notification.read_at.is_(None)) .where(Notification.user_id == user_id, Notification.read_at.is_(None))
.values(read_at=datetime.now(tz.utc)) .values(read_at=datetime.now(timezone.utc))
.returning(Notification.id) .returning(Notification.id)
) )
await session.commit() await session.commit()
+1 -6
View File
@@ -60,12 +60,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
return { return {
"milestone": milestone.to_dict(), "milestone": milestone.to_dict(),
"applicable_rules": applicable["rules"], **rulebooks_svc.rules_payload(applicable),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"project_goal": getattr(project, "goal", "") or "", "project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count, "open_task_count": open_count,
} }
+102 -4
View File
@@ -27,6 +27,7 @@ from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc from scribe.services import snippets as snippets_svc
from scribe.services.access import label_shared_items, owner_names_for from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import semantic_search_notes from scribe.services.embeddings import semantic_search_notes
@@ -703,6 +704,8 @@ async def build_write_path_hint(
project_id: int = 0, project_id: int = 0,
exclude_ids: list[int] | None = None, exclude_ids: list[int] | None = None,
exclude_sync_ids: list[int] | None = None, exclude_sync_ids: list[int] | None = None,
stamp_shapes: list[tuple[str, str]] | None = None,
repo_key: str = "",
) -> dict: ) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit. """Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -749,9 +752,20 @@ async def build_write_path_hint(
un-scored surfacing now has its own home: every arm emits note_usage_events, un-scored surfacing now has its own home: every arm emits note_usage_events,
tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so
each claim's pull-through rate is measurable on its own. each claim's pull-through rate is measurable on its own.
``stamp_shapes`` turns the same request into the ledger's write-path feed
(#2791): the (kind, name) definitions the hook saw in — or enclosing —
the payload. When the session has PULLED a snippet recently and this
payload references or resembles it, those shapes land as `instance` rows
(classified_by=hook, see shape_ledger.stamp_write_path_instances) and
the result's ``stamped`` lists them. The route passes it only for a
caller allowed to write — a read-scoped key gets the hint, never the
stamp. ``repo_key`` (the hook's remote, normalised) homes a provisional
row for a shape the ledger has not synced yet.
""" """
cfg = await get_writepath_config(user_id) cfg = await get_writepath_config(user_id)
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg} empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
"stamped": [], "divergence": []}
path = (path or "").strip() path = (path or "").strip()
if not cfg["enabled"] or not path: if not cfg["enabled"] or not path:
return empty return empty
@@ -800,6 +814,15 @@ async def build_write_path_hint(
seen.add(nid) seen.add(nid)
placed.append(("nearby", item)) placed.append(("nearby", item))
# The stamping feed's "actually pulled it" half (#2791). Read once, before
# the semantic arm, because the arm's query doubles as the resemblance
# test: a pulled snippet this session already saw (so it sits in `seen`)
# must still be SCORED for this payload — it just isn't re-listed.
pulled: dict = {}
if stamp_shapes:
pulled = await shape_ledger_svc.recent_pulls(user_id)
resembles: dict[int, float] = {}
# --- arm 2: by meaning --- # --- arm 2: by meaning ---
scored: list[tuple[str, dict]] = [] scored: list[tuple[str, dict]] = []
remaining = top_k - len(synced) - len(placed) remaining = top_k - len(synced) - len(placed)
@@ -821,12 +844,15 @@ async def build_write_path_hint(
query = concept_query(query) or query query = concept_query(query) or query
if remaining > 0 and query: if remaining > 0 and query:
t0 = time.perf_counter() t0 = time.perf_counter()
# Pulled-and-seen ids stay in the query (as evidence) but never in
# the menu — the dedup contract holds, the resemblance still lands.
pulled_seen = seen & set(pulled)
hits = await semantic_search_notes( hits = await semantic_search_notes(
user_id, query, user_id, query,
limit=remaining, limit=remaining + len(pulled_seen),
threshold=cfg["threshold"], threshold=cfg["threshold"],
project_id=scope_project, project_id=scope_project,
exclude_ids=seen, exclude_ids=seen - pulled_seen,
# Snippets AND recorded experience (#2246). This arm was # Snippets AND recorded experience (#2246). This arm was
# snippets-only, which is auto-inject's mistake inverted: an issue # snippets-only, which is auto-inject's mistake inverted: an issue
# saying "we tried this and it deadlocked", or a dev-log recording # saying "we tried this and it deadlocked", or a dev-log recording
@@ -845,6 +871,11 @@ async def build_write_path_hint(
# the browse scope and never surfaces a one-to-one direct share. # the browse scope and never surfaces a one-to-one direct share.
scope="browse", scope="browse",
) )
resembles = {
int(note.id): float(score) for score, note in hits
if int(note.id) in pulled
}
hits = [(s, n) for s, n in hits if int(n.id) not in seen][:remaining]
record_retrieval( record_retrieval(
user_id=user_id, source="write_path", query=query, user_id=user_id, source="write_path", query=query,
threshold=cfg["threshold"], limit=remaining, threshold=cfg["threshold"], limit=remaining,
@@ -879,7 +910,32 @@ async def build_write_path_hint(
)) ))
menu = (placed + scored)[:max(0, top_k - len(synced))] menu = (placed + scored)[:max(0, top_k - len(synced))]
if not synced and not menu:
# The stamp runs whether or not anything is rendered — after dedup, the
# common case is a silent hint and a pulled canon being instantiated.
stamped: list[dict] = []
if stamp_shapes and pulled:
try:
stamped = await shape_ledger_svc.stamp_write_path_instances(
user_id, project_id, path=path, shapes=stamp_shapes,
code=code or "", pulled=pulled, resembles=resembles,
repo_key=repo_key,
)
except Exception:
logger.warning("Write-path ledger stamping failed", exc_info=True)
# The in-band button-B check (#2793): the hook named the shapes being
# written; if this directory+kind is canon-dense and a named shape isn't
# (about to be) an instance of that canon, say so NOW — at the write,
# not at the next audit.
divergence: list[dict] = []
if stamp_shapes and project_id:
try:
divergence = await shape_ledger_svc.write_time_divergence(
project_id, path, stamp_shapes, stamped
)
except Exception:
logger.warning("write-time divergence check failed", exc_info=True)
if not synced and not menu and not stamped and not divergence:
return empty return empty
owners = await owner_names_for({ owners = await owner_names_for({
@@ -943,6 +999,11 @@ async def build_write_path_hint(
note_ids.append(int(item["id"])) note_ids.append(int(item["id"]))
lines.append(_prior_art_line(item, marker, owner, foreign_lang)) lines.append(_prior_art_line(item, marker, owner, foreign_lang))
if stamped:
lines.append(_stamp_line(path, stamped))
if divergence:
lines.append(_divergence_line(path, divergence))
# Split by arm, which is the whole reason this table exists. The place arm # Split by arm, which is the whole reason this table exists. The place arm
# carries no score and so has no home in retrieval_logs; before #2085 a # carries no score and so has no home in retrieval_logs; before #2085 a
# snippet surfaced BY PLACE left no trace anywhere, making the arm that # snippet surfaced BY PLACE left no trace anywhere, making the arm that
@@ -964,9 +1025,46 @@ async def build_write_path_hint(
"note_ids": note_ids, "note_ids": note_ids,
"sync_note_ids": sync_note_ids, "sync_note_ids": sync_note_ids,
"config": cfg, "config": cfg,
"stamped": stamped,
"divergence": divergence,
} }
def _divergence_line(path: str, divergence: list[dict]) -> str:
"""Button B where button A is canon — named at the write (#2793)."""
parts = [
f"`{('.' if d['kind'] == 'css' else '') + d['symbol']}` → #{d['canon_snippet_id']} "
f"({d['instances']} of {d['judged']} judged siblings are its instances)"
for d in divergence
]
return (
f"> Divergence check at `{path}`: a canon dominates this directory — "
f"{'; '.join(parts)}. If this is a new instance, pull that snippet "
"and build from it; if it is a deliberate departure, "
"`classify_shapes(..., status=\"variant\", reason=…)` records the why; "
"otherwise it reads as unintended divergence."
)
def _stamp_line(path: str, stamped: list[dict]) -> str:
"""One line saying what the ledger just recorded, so the session can
correct a wrong stamp in the moment rather than an audit finding it."""
by_snippet: dict[int, list[str]] = {}
for row in stamped:
label = f".{row['symbol']}" if row["kind"] == "css" else row["symbol"]
by_snippet.setdefault(int(row["snippet_id"]), []).append(f"`{label}`")
parts = [
f"{', '.join(names)} → instance of #{sid}"
for sid, names in by_snippet.items()
]
return (
f"> Shape accounting: recorded at `{path}` — {'; '.join(parts)} "
"(classified_by=hook: you pulled that snippet this session and this "
"code references/resembles it). Not an instance? `classify_shapes` "
"overrides a hook stamp."
)
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]: async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
"""Map topic_id -> title for the given ids (live topics only).""" """Map topic_id -> title for the given ids (live topics only)."""
if not topic_ids: if not topic_ids:
+11 -57
View File
@@ -208,54 +208,9 @@ async def get_project_summaries(
async def get_project_summary(user_id: int, project_id: int) -> dict: async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity.""" """Return task counts by status, note count, and last activity — the
async with async_session() as session: one-project view of get_project_summaries (one rule, not two copies)."""
# Task counts by status return (await get_project_summaries(user_id, [project_id]))[project_id]
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
# Initialise all three lifecycle keys to 0 so consumers can sum them
# safely without `?? 0` guards. Frontend interface declares all three
# as required; rendering `undefined + N` yields NaN.
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
Note.deleted_at.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from scribe.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -279,8 +234,6 @@ async def list_projects_for_user(user_id: int, status: str | None = None) -> lis
"""Owned projects + shared projects, each dict has 'permission' field.""" """Owned projects + shared projects, each dict has 'permission' field."""
from scribe.models.group import GroupMembership from scribe.models.group import GroupMembership
from scribe.models.share import ProjectShare from scribe.models.share import ProjectShare
from scribe.services.access import PERMISSION_RANK
owned = await list_projects(user_id, status) owned = await list_projects(user_id, status)
owned_ids = {p.id for p in owned} owned_ids = {p.id for p in owned}
@@ -307,13 +260,14 @@ async def list_projects_for_user(user_id: int, status: str | None = None) -> lis
) )
)).scalars().all() )).scalars().all()
seen: dict[int, str] = {} from scribe.services.sharing import best_permission_by
for share in list(shared_direct) + list(shared_group): seen = {
if share.project_id in owned_ids: pid: perm
continue for pid, perm in best_permission_by(
prev = seen.get(share.project_id) list(shared_direct) + list(shared_group), "project_id"
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]: ).items()
seen[share.project_id] = share.permission if pid not in owned_ids
}
for pid, perm in seen.items(): for pid, perm in seen.items():
if status: if status:
+7 -32
View File
@@ -4,7 +4,7 @@ Every 15 minutes, creates the next occurrence of any recurring task whose spawn
time has arrived — draining `recurrence_next_spawn_at`, which is armed on task time has arrived — draining `recurrence_next_spawn_at`, which is armed on task
completion. Without this job, recurring tasks would never recur. completion. Without this job, recurring tasks would never recur.
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules. One ScheduledJob (services/scheduler.py), like the other *_scheduler modules.
(Formerly event_scheduler.py, which also ran event reminders + CalDAV sync; (Formerly event_scheduler.py, which also ran event reminders + CalDAV sync;
those were removed when the calendar surface was retired.) those were removed when the calendar surface was retired.)
""" """
@@ -13,52 +13,27 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
logger = logging.getLogger(__name__) from scribe.services.scheduler import ScheduledJob
_scheduler: BackgroundScheduler | None = None logger = logging.getLogger(__name__)
_loop: asyncio.AbstractEventLoop | None = None
async def _run_recurrence_spawn() -> None: async def _run_recurrence_spawn() -> None:
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415 from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
try: await spawn_recurring_tasks()
await spawn_recurring_tasks()
except Exception:
logger.warning("Recurring-task spawn job failed", exc_info=True)
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None: _JOB = ScheduledJob("recurrence_spawn", _run_recurrence_spawn, label="Recurring-task spawn")
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None: def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
# Spawn the next occurrence of due recurring tasks every 15 minutes. # Spawn the next occurrence of due recurring tasks every 15 minutes.
# Without this job, recurrence_next_spawn_at is armed on completion but # Without this job, recurrence_next_spawn_at is armed on completion but
# never drained, so recurring tasks never recur. # never drained, so recurring tasks never recur.
_scheduler.add_job( _JOB.start(loop, IntervalTrigger(minutes=15), describe="recurring-task spawn every 15m")
_run_recurrence_spawn_threadsafe,
trigger=IntervalTrigger(minutes=15),
args=[loop],
id="recurrence_spawn",
replace_existing=True,
)
_scheduler.start()
logger.info("Recurrence scheduler started (recurring-task spawn every 15m)")
def stop_recurrence_scheduler() -> None: def stop_recurrence_scheduler() -> None:
global _scheduler _JOB.stop()
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Recurrence scheduler stopped")
+24 -2
View File
@@ -68,8 +68,16 @@ async def resolve_project(user_id: int, raw_repo: str) -> int | None:
return row.scalar_one_or_none() return row.scalar_one_or_none()
async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding: async def set_binding(
"""Create or update the binding for a repo. Idempotent on (user, repo_key).""" user_id: int, raw_repo: str, project_id: int, ref: str | None = None,
) -> RepoBinding:
"""Create or update the binding for a repo. Idempotent on (user, repo_key).
``ref`` (#2873) is the branch the coverage refresh reads for this
binding: a name sets it, ``""`` clears it back to the forge's default
branch, ``None`` leaves whatever stands (a re-bind that only moves the
project keeps the ref it had).
"""
key = normalize_repo_key(raw_repo) key = normalize_repo_key(raw_repo)
if not key: if not key:
raise ValueError("repo remote is empty or unparseable") raise ValueError("repo remote is empty or unparseable")
@@ -85,6 +93,8 @@ async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBindi
session.add(binding) session.add(binding)
else: else:
binding.project_id = project_id binding.project_id = project_id
if ref is not None:
binding.ref = ref.strip() or None
await session.commit() await session.commit()
await session.refresh(binding) await session.refresh(binding)
return binding return binding
@@ -100,6 +110,18 @@ async def list_bindings(user_id: int) -> list[RepoBinding]:
return list(rows.scalars().all()) return list(rows.scalars().all())
async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBinding]:
"""Every binding of a project — key AND the ref its ledger follows (#2873)."""
async with async_session() as session:
rows = await session.execute(
select(RepoBinding).where(
RepoBinding.user_id == user_id,
RepoBinding.project_id == project_id,
).order_by(RepoBinding.repo_key)
)
return list(rows.scalars().all())
async def keys_for_project(user_id: int, project_id: int) -> list[str]: async def keys_for_project(user_id: int, project_id: int) -> list[str]:
"""Every repo key bound to a project — the snippet→forge join (#2691). """Every repo key bound to a project — the snippet→forge join (#2691).
+19
View File
@@ -779,3 +779,22 @@ async def get_applicable_rules(
"truncated": truncated, "truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks, "subscribed_rulebooks": subscribed_rulebooks,
} }
def rules_payload(applicable: dict) -> dict:
"""The caller-facing shape of a get_applicable_rules() result.
Every surface that hands rules to an agent (enter_project, get_project,
get_milestone, get_task for legacy plans, start_planning) carries the
same six keys under the same names — so a reader learns them once. One
place renames `rules` → `applicable_rules` and `truncated` →
`applicable_rules_truncated`; the tools merge this into their payloads.
"""
return {
"applicable_rules": applicable["rules"],
"applicable_rules_truncated": applicable["truncated"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
}
+78
View File
@@ -0,0 +1,78 @@
"""One APScheduler job bridged into the asyncio loop — the shape the four
*_scheduler modules (recurrence spawn, auto-pin scan, trash purge, DB
maintenance) each used to carry a private copy of.
APScheduler's BackgroundScheduler fires from a worker thread; the work is
async and must run on the app's loop, so the fire is bridged with
``run_coroutine_threadsafe``. Each job is a module-level singleton: start is
idempotent, stop shuts the scheduler down, and a job whose trigger the
operator can change (the maintenance hour) reschedules the live job instead
of restarting.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from apscheduler.schedulers.background import BackgroundScheduler
logger = logging.getLogger(__name__)
class ScheduledJob:
"""A named APScheduler job that awaits ``work()`` on the asyncio loop.
``work`` is an async callable; exceptions it raises are logged under
``label`` and never propagate into APScheduler's thread.
"""
def __init__(self, job_id: str, work: Callable[[], Awaitable[None]], *, label: str) -> None:
self.job_id = job_id
self._work = work
self.label = label
self._scheduler: BackgroundScheduler | None = None
self._loop: asyncio.AbstractEventLoop | None = None
@property
def running(self) -> bool:
return self._scheduler is not None
def _fire(self) -> None:
"""APScheduler invokes this from its worker thread; bridge into the loop."""
if self._loop is None:
logger.warning("%s scheduler: no loop registered", self.label)
return
async def _runner() -> None:
try:
await self._work()
except Exception:
logger.exception("%s run failed", self.label)
asyncio.run_coroutine_threadsafe(_runner(), self._loop)
def start(self, loop: asyncio.AbstractEventLoop, trigger, *, describe: str = "") -> None:
"""Start the job on ``trigger``. Idempotent — a second start is a no-op."""
if self._scheduler is not None:
return
self._loop = loop
self._scheduler = BackgroundScheduler()
self._scheduler.add_job(
self._fire, trigger=trigger, id=self.job_id, replace_existing=True,
)
self._scheduler.start()
logger.info("%s scheduler started%s", self.label, f" ({describe})" if describe else "")
def reschedule(self, trigger, *, describe: str = "") -> None:
"""Move the live job to a new trigger; a no-op when not running."""
if self._scheduler is None:
return
self._scheduler.reschedule_job(self.job_id, trigger=trigger)
logger.info("%s scheduler rescheduled%s", self.label, f" to {describe}" if describe else "")
def stop(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
logger.info("%s scheduler stopped", self.label)
+7
View File
@@ -8,6 +8,13 @@ from scribe.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# What a stored credential looks like on the wire. Every surface that READS a
# secret (smtp_password, forge_webhook_secret, a forge token) returns this
# when one is set; every surface that WRITES one treats this value coming back
# as "unchanged", never as a request to store eight asterisks over the real
# credential. One constant so the read and write halves cannot disagree.
SECRET_MASK = "********"
async def get_admin_setting(key: str, default: str = "") -> str: async def get_admin_setting(key: str, default: str = "") -> str:
"""Read an instance-global setting (one stored on an admin account). """Read an instance-global setting (one stored on an admin account).
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -10,6 +10,7 @@ from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User from scribe.models.user import User
from scribe.models.base import iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,7 +32,7 @@ async def _enrich_shares(session, shares) -> list[dict]:
return result return result
def _deduplicate_by_permission(shares, id_attr: str) -> dict[int, str]: def best_permission_by(shares, id_attr: str) -> dict[int, str]:
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource.""" """Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
from scribe.services.access import PERMISSION_RANK from scribe.services.access import PERMISSION_RANK
seen: dict[int, str] = {} seen: dict[int, str] = {}
@@ -210,7 +211,7 @@ async def list_shared_with_me(user_id: int) -> dict:
) )
)).scalars().all() )).scalars().all()
seen_projects = _deduplicate_by_permission(list(proj_direct) + list(proj_group), "project_id") seen_projects = best_permission_by(list(proj_direct) + list(proj_group), "project_id")
projects = [] projects = []
for pid, perm in seen_projects.items(): for pid, perm in seen_projects.items():
@@ -223,7 +224,7 @@ async def list_shared_with_me(user_id: int) -> dict:
"description": proj.description, "description": proj.description,
"status": proj.status, "status": proj.status,
"color": proj.color, "color": proj.color,
"updated_at": proj.updated_at.isoformat(), "updated_at": iso(proj.updated_at),
"owner_username": owner.username if owner else None, "owner_username": owner.username if owner else None,
"permission": perm, "permission": perm,
}) })
@@ -241,7 +242,7 @@ async def list_shared_with_me(user_id: int) -> dict:
) )
)).scalars().all() )).scalars().all()
seen_notes = _deduplicate_by_permission(list(note_direct) + list(note_group), "note_id") seen_notes = best_permission_by(list(note_direct) + list(note_group), "note_id")
notes = [] notes = []
for nid, perm in seen_notes.items(): for nid, perm in seen_notes.items():
@@ -253,7 +254,7 @@ async def list_shared_with_me(user_id: int) -> dict:
"title": note.title, "title": note.title,
"is_task": note.is_task, "is_task": note.is_task,
"project_id": note.project_id, "project_id": note.project_id,
"updated_at": note.updated_at.isoformat(), "updated_at": iso(note.updated_at),
"owner_username": owner.username if owner else None, "owner_username": owner.username if owner else None,
"permission": perm, "permission": perm,
}) })
+32
View File
@@ -181,3 +181,35 @@ async def superseded_ids(note_ids: list[int]) -> set[int]:
.where(NoteSupersession.superseded_id.in_(note_ids)) .where(NoteSupersession.superseded_id.in_(note_ids))
)).scalars().all() )).scalars().all()
return {int(r) for r in rows} return {int(r) for r in rows}
SUPERSEDED_HINT = (
"A later note claims to bring this up to date — see superseded_by. "
"Read this as what was true when written, and check the newer one "
"before acting on it."
)
async def attach_relations(user_id: int, note_id: int, data: dict, *, hint: bool = False) -> None:
"""Add both directions of the supersession relation to a note payload.
ONE seam for the REST and MCP surfaces, which must agree about what a
note's payload says — or the web UI and the agent would disagree about
whether a record is current. Both directions, because they answer
different questions and only one is obvious: `supersedes` is what the
author claimed; `superseded_by` is what a READER needs and what the note
itself cannot know — a stale record handed over without that marker gets
acted on confidently, which is worse than never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow
two permanently-empty lists (#2483 — a field that always says nothing
trains readers to skip fields). `hint=True` (the agent surface) also
attaches `superseded_note`, the one-sentence reading instruction.
"""
rel = await get_relations(user_id, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
if hint:
data["superseded_note"] = SUPERSEDED_HINT
+6 -9
View File
@@ -8,15 +8,16 @@ trashed rows via `alive()`.
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select, update from sqlalchemy import delete as sql_delete, or_, select, update
from scribe.models import async_session from scribe.models import async_session
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.rulebook import Rulebook, RulebookTopic, Rule from scribe.models.rulebook import Rulebook, RulebookTopic, Rule
from scribe.models.base import iso
# entity_type -> Model. Used to resolve which table a trash op targets. # entity_type -> Model. Used to resolve which table a trash op targets.
_MODEL_FOR = { _MODEL_FOR = {
@@ -87,16 +88,15 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
# FK CASCADE would handle a full DELETE on the project row, but the # FK CASCADE would handle a full DELETE on the project row, but the
# soft-delete path keeps the project row alive; this guarantees the # soft-delete path keeps the project row alive; this guarantees the
# rows are gone whether or not the project ever gets purged. # rows are gone whether or not the project ever gets purged.
from sqlalchemy import delete as _sql_delete
from scribe.models.rulebook import ( from scribe.models.rulebook import (
project_rule_suppressions, project_topic_suppressions, project_rule_suppressions, project_topic_suppressions,
) )
await session.execute( await session.execute(
_sql_delete(project_rule_suppressions) sql_delete(project_rule_suppressions)
.where(project_rule_suppressions.c.project_id == eid) .where(project_rule_suppressions.c.project_id == eid)
) )
await session.execute( await session.execute(
_sql_delete(project_topic_suppressions) sql_delete(project_topic_suppressions)
.where(project_topic_suppressions.c.project_id == eid) .where(project_topic_suppressions.c.project_id == eid)
) )
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now) await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
@@ -213,7 +213,6 @@ async def restore_entity(user_id: int, entity_type: str, entity_id: int) -> int
async def purge(user_id: int, batch_id: str) -> int: async def purge(user_id: int, batch_id: str) -> int:
"""Hard-delete every row in the batch. Irreversible.""" """Hard-delete every row in the batch. Irreversible."""
from sqlalchemy import delete as sql_delete
n = 0 n = 0
async with async_session() as session: async with async_session() as session:
for model in _ALL: for model in _ALL:
@@ -241,7 +240,7 @@ async def list_trash(user_id: int) -> list[dict]:
grp = batches.setdefault( grp = batches.setdefault(
r.deleted_batch_id, r.deleted_batch_id,
{"batch_id": r.deleted_batch_id, {"batch_id": r.deleted_batch_id,
"deleted_at": r.deleted_at.isoformat() if r.deleted_at else None, "deleted_at": iso(r.deleted_at),
"items": []}, "items": []},
) )
grp["items"].append({ grp["items"].append({
@@ -265,8 +264,6 @@ async def purge_expired(user_id: int, retention_days: int) -> int:
user's short window prematurely destroy another's data. user's short window prematurely destroy another's data.
retention_days <= 0 disables auto-purge (returns 0 without touching anything). retention_days <= 0 disables auto-purge (returns 0 without touching anything).
""" """
from datetime import timedelta
from sqlalchemy import delete as sql_delete
if retention_days <= 0: if retention_days <= 0:
return 0 return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)

Some files were not shown because too many files have changed in this diff Show More