Compare commits

...
50 Commits
Author SHA1 Message Date
bvandeusen 5415bff85c Merge pull request 'fix(ledger): one-line CSS fingerprints; proposer writes uses edges for judged rows (#2877)' (#123) 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 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 14s
2026-08-21 17:39:48 -04:00
bvandeusenandClaude Fable 5 aba16583ab fix(ledger): one-line CSS rules fingerprint their own declarations; the proposer writes uses edges for judged rows too (#2872, #2870)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 22s
First deploy of v26.08.21.1 showed two gaps:
- Every one-line CSS rule followed by a blank line hashed to sha1("") — the
  declarations live on the selector line, which the #2872 "declarations only"
  fingerprint dropped — so 68 unrelated one-liners across 17 files read as
  one body-identical copy at the top of the derive readout. The selector
  line's tail after "{" is now part of the hash; an all-blank remainder falls
  back to the whole block.
- The proposer only examined unjudged rows, so consumers that were already
  classified (auth.create_invitation → hash_token) never got a uses edge: 3
  edges for hash_token after the first refresh. Judged rows are now scanned
  for references (once per body), no proposal is made on them.
- 0084 migration docstring reworded: "function that …" at a line start parsed
  as a definition (extractor false positive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 17:34:12 -04:00
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
bvandeusen fd101d24ae Merge pull request 'Shape ledger steps 1-4 + direct-minting Systems bootstrap (milestone 294, #2798)' (#115) from dev into main
CI & Build / TypeScript typecheck (push) Successful in 50s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m14s
CI & Build / Build & push image (push) Successful in 16s
2026-08-19 21:24:40 -04:00
bvandeusenandClaude Fable 5 9f1a52a035 feat(ledger): audits write rows, not prose — instruction surfaces carry the classification duty (#2790, milestone 294 step 4)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 27s
verify_snippet docstring: consumers enumerated while checking are
classify_shapes instance rows; detail keeps the WHY and what changed,
nothing that belongs in a row. The MCP _INSTRUCTIONS REUSE bullet gains
"classify shapes against canon — a consumer map is rows, never prose"
(budget rebalanced to 1,998/2,000: the rules bullet lost its historical
push-optimisation clause, the task-note line tightened). The reusing-code
skill gains the full contract: instance/variant/exempt with required
reasons, list_shapes(status=unclassified) as the standing todo, and the
derive-one-first rule for repeating shapes with no canon. Plugin 0.1.32.

The operator-side halves of this step live outside the repo: the Drift
Audit process gained step 7 (classify what the walk proved) and the DRY
Pass s9 now sends consumer maps to the ledger with the note keeping only
the narrative. The Forge P7 backfill payload is enumerated and parked on
task 2790 — it fires after the next deploy + coverage refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:16:04 -04:00
bvandeusenandClaude Fable 5 5265d11a6a feat(systems): bootstrap mints directly — the standard vocabulary replaces operator approval (#2798)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 59s
Operator directive: creating Systems is the agent's call, not an approval
flow — "propose to the operator, create each confirmed one" made the
operator a permission gate. The bootstrap ask now says create_system 3-6
directly, in-session, and the consistency that approval was covering moves
to a standard cross-project vocabulary (CI & Release, Auth & Access, Data
Model & Storage, API Surface, UI & Design, Import & Export, Background
Jobs, Observability): use the standard name verbatim where the area fits,
mint freely beyond it, the duplicate gate guards sprawl. create_system and
enter_project docstrings carry the same contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:05:02 -04:00
bvandeusenandClaude Fable 5 d50ebbad66 fix(mcp): classify list_shapes as read-only for read-scoped API keys
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 32s
The auth guard test caught it: a read-shaped tool in neither set fails
closed for read keys, silently. list_shapes reads the ledger and writes
nothing; classify_shapes stays write-scoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:02:12 -04:00
bvandeusenandClaude Fable 5 942edd1eb5 feat(ledger): classify_shapes + list_shapes MCP tools; get_snippet carries the consumer map (#2789, milestone 294 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
The judgment write path. classify_shapes applies a batch of classifications
to a project's live ledger rows — all-or-nothing (#2709's lesson: the whole
batch is validated, write-ACL'd, and every snippet target proven readable
before any row is touched); rows match by exact (path, symbol), kind narrows,
and shapes no live row matches come back as 'unmatched' rather than errors.
variant/exempt REQUIRE the reason — the why is the record (note 2786) — and
'unclassified' deliberately withdraws a judgment back to the todo. The 'via'
channel is caller-restricted to agent|audit|import; hook and mechanical stay
server-internal so a caller can't launder judgment as machinery.

list_shapes is the todo query (status=unclassified) with composable filters:
path is exact-or-under like recorded locations, snippet_id reads a consumer
map, include_vanished reads history; paged with the true total.

get_snippet now attaches  and  — the structured consumer
map, filtered to projects the CALLER can read so a shared snippet never side-
channels another project's file layout; attached only when non-empty (#2483).

Integration tests pin the batch atomicity, ACL gates, filter composition, the
consumer map on the MCP pull, and the SET NULL companion: a judgment whose
snippet was purged rejoins the todo on the next sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:55:47 -04:00
bvandeusenandClaude Fable 5 9d92df2825 fix(tests): drop the pre-ledger line-ending assertion the step-2 edit missed
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 22s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:43:11 -04:00
bvandeusenandClaude Fable 5 9b1597a3c9 feat(ledger): coverage refresh feeds the shape ledger; the readout inverts to accounting (#2788, milestone 294 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 44s
compute_coverage is now the ledger's sync point: every walk upserts the
extracted shapes (new → unclassified, the todo state; surviving → last-seen
bump; vanished → stamped, kept as history), re-files judgments whose snippet
target went away, and mechanically stamps snippet reference locations as
canonical — the one always-safe rule, self-healing only for its own stamps
(an agent's judgment is never unwound by machinery).

The covering predicate moves to shape_ledger.location_covers as the single
home (match_shapes retired with its consumer); coverage's payload and line
invert from 'N/M shapes recorded' to shape ACCOUNTING per note 2786:
accounted/total with a canonical·instance·variant·exempt breakdown, and
unclassified — THE todo — with its largest directories. Cache key bumps to
v2 so pre-ledger blobs honestly read 'not measured yet' instead of rendering
in a shape no longer spoken.

Readout is deliberately project-wide (all repos' live rows), while the walk
serves whichever repos the owner's keyring reaches this refresh.

Integration tests pin the new contract: rows for every extracted shape,
mechanical canonical stamps carrying snippet ids, idempotent recompute,
agent judgments surviving recompute AND vanish/return, vanished rows leaving
the readout but keeping their history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:37:18 -04:00
bvandeusenandClaude Fable 5 19fdc9aa89 feat(ledger): code_shapes — the shape ledger schema (#2787, milestone 294 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 45s
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small), this table accounts for EVERY extracted shape
(total). Identity is (project, repo_key, path, symbol, kind) — kind included
because one file can define '.foo' (css) and 'foo' (sym) as distinct shapes.
Status vocabulary: canonical / instance / variant / exempt / unclassified,
with unclassified as the default and THE todo state; classifications carry
who judged (agent|audit|hook|mechanical|import), when, and the why for
variants/exemptions. first/last-seen commits + vanished_at keep history
instead of deleting it; a rename reads as vanish+new (accepted for v1).

snippet_id is SET NULL on snippet deletion so accounting rows outlive their
target and rejoin the todo via the step-2 sync, never dangle silently.

Backups: v7 carries code_shapes (judgment data, worth moving) — full and
per-user export sections, and a restore that keeps a judgment only when its
snippet survives the id re-mapping, downgrading to unclassified otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:25:25 -04:00
bvandeusen c1cb8fb183 Merge pull request 'Per-user forge connections — keyring, host-keyed resolution, project pin (#2778)' (#114) from dev into main
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 20s
2026-08-19 15:24:47 -04:00
bvandeusenandClaude Fable 5 1faf8f3ece feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:23:22 -04:00
170 changed files with 8512 additions and 3477 deletions
+130
View File
@@ -0,0 +1,130 @@
"""Forge connections move to the user level (#2778)
Revision ID: 0078
Revises: 0077
Create Date: 2026-08-19
A forge token is a user's credential, not an instance's: the single
admin-settings config meant every user's snippet-freshness and coverage reads
ran under the operator's token. Each user now owns a keyring of connections —
one per forge host — and projects resolve forge reads on their OWNER's
keyring, with an optional per-project pin (projects.forge_connection_id).
The data move carries the existing admin config into a connection row for the
first admin user (host parsed from the base URL), then deletes the old
setting keys outright — no legacy dual-read (rule #22). The env-var channel
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
survives as an implicit keyring entry for admin users only.
"""
from urllib.parse import urlsplit
import sqlalchemy as sa
from alembic import op
revision = "0078"
down_revision = "0077"
branch_labels = None
depends_on = None
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
def upgrade() -> None:
op.create_table(
"forge_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("base_url", sa.Text(), nullable=False),
sa.Column("host", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
op.add_column(
"projects",
sa.Column(
"forge_connection_id",
sa.BigInteger(),
sa.ForeignKey(
"forge_connections.id",
ondelete="SET NULL",
name="fk_projects_forge_connection_id",
),
nullable=True,
),
)
# Data move: the admin-settings config becomes the first admin's keyring
# row. All three values must be present — a partial config never produced
# an adapter, so carrying it over would invent a connection that never
# worked.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT s.key, s.value FROM settings s"
" JOIN users u ON u.id = s.user_id"
" WHERE u.role = 'admin' AND s.key IN :keys"
" AND s.user_id = ("
" SELECT MIN(id) FROM users WHERE role = 'admin'"
" )"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
).fetchall()
values = {key: (value or "").strip() for key, value in row}
kind = values.get("forge_kind", "").lower()
base_url = values.get("forge_base_url", "").rstrip("/")
token = values.get("forge_token", "")
host = (urlsplit(base_url).hostname or "").lower()
if kind and base_url and token and host:
conn.execute(
sa.text(
"INSERT INTO forge_connections"
" (user_id, kind, base_url, host, token, created_at, updated_at)"
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
" FROM users WHERE role = 'admin'"
),
{"kind": kind, "base_url": base_url, "host": host, "token": token},
)
conn.execute(
sa.text(
"DELETE FROM settings WHERE key IN :keys"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
)
def downgrade() -> None:
# Reverse data move: the first admin's row (if any) becomes the admin
# settings again. Other users' rows have no pre-0078 representation and
# are dropped with the table.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT user_id, kind, base_url, token FROM forge_connections"
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
" ORDER BY id LIMIT 1"
)
).fetchone()
if row is not None:
for key, value in (
("forge_kind", row.kind),
("forge_base_url", row.base_url),
("forge_token", row.token),
):
conn.execute(
sa.text(
"INSERT INTO settings (user_id, key, value)"
" VALUES (:uid, :key, :value)"
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
),
{"uid": row.user_id, "key": key, "value": value},
)
op.drop_column("projects", "forge_connection_id")
op.drop_table("forge_connections")
@@ -0,0 +1,66 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small); this table accounts for EVERY shape the
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
from the coverage sync (step 2) and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
import sqlalchemy as sa
from alembic import op
revision = "0079"
down_revision = "0078"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shapes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), 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("status", sa.Text(), nullable=False, server_default="unclassified"),
sa.Column(
"snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
)
op.create_index(
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
)
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
op.drop_table("code_shapes")
@@ -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 — e.g. a
service function both conforming to the service-function convention and
consuming 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 {
+164 -38
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client"; import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime"; import { relativeTime } from "@/composables/useRelativeTime";
@@ -48,6 +49,7 @@ interface Project {
status: "active" | "paused" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
design_system_id: number | null; design_system_id: number | null;
forge_connection_id: number | null;
permission?: string; permission?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -422,16 +424,40 @@ async function loadNotes() {
interface CoverageGap { interface CoverageGap {
dir: string; dir: string;
uncovered: 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;
recorded: number; accounted: number;
unclassified: number;
counts: Record<string, number>;
estimate: boolean; estimate: boolean;
computed_at: string; computed_at: string;
repos: { repo: string; ref: string; total: number; recorded: 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);
@@ -472,12 +498,56 @@ async function refreshCoverage() {
} }
} }
/* ── Forge pin (#2778) — which of the OWNER's connections serves this
project. Owner-only UI: the select lists the viewer's own keyring, which
is only the eligible set when the viewer IS the owner. ── */
const authStore = useAuthStore();
const isProjectOwner = computed(
() => !!project.value && project.value.user_id === authStore.user?.id
);
interface ForgeConnectionOption { id: number; host: string; kind: string }
const forgeOptions = ref<ForgeConnectionOption[]>([]);
const forgePin = ref<number | null>(null);
const savingForgePin = ref(false);
async function loadForgeOptions() {
if (!isProjectOwner.value) return;
try {
const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
"/api/settings/forge-connections"
);
forgeOptions.value = res.connections;
forgePin.value = project.value?.forge_connection_id ?? null;
} catch {
forgeOptions.value = [];
}
}
async function saveForgePin() {
savingForgePin.value = true;
try {
await apiPut(`/api/projects/${projectId.value}/forge`, {
connection_id: forgePin.value,
});
if (project.value) project.value.forge_connection_id = forgePin.value;
await loadCoverage();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toast.show(body?.error || "Failed to change the project's forge", "error");
forgePin.value = project.value?.forge_connection_id ?? null;
} finally {
savingForgePin.value = false;
}
}
onMounted(async () => { onMounted(async () => {
await loadProject(); await loadProject();
loadTasks(); loadTasks();
loadNotes(); loadNotes();
loadDesignSystems(); loadDesignSystems();
loadCoverage(); loadCoverage();
loadForgeOptions();
}); });
/** Populate the design-system picker. Swallows failure on purpose: with no /** Populate the design-system picker. Swallows failure on purpose: with no
@@ -496,6 +566,7 @@ watch(projectId, async () => {
loadTasks(); loadTasks();
loadNotes(); loadNotes();
loadCoverage(); loadCoverage();
loadForgeOptions();
}); });
watch( watch(
@@ -665,34 +736,101 @@ async function confirmDelete() {
</div> </div>
<template v-if="coverage"> <template v-if="coverage">
<div class="coverage-numbers"> <div class="coverage-numbers">
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span> <span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
<span class="coverage-label">shapes recorded</span> <span class="coverage-label">shapes accounted for</span>
</div> </div>
<div <div
class="coverage-bar" class="coverage-bar"
role="progressbar" role="progressbar"
:aria-valuenow="coverage.recorded" :aria-valuenow="coverage.accounted"
:aria-valuemin="0" :aria-valuemin="0"
:aria-valuemax="coverage.total" :aria-valuemax="coverage.total"
aria-label="Shapes with a recorded snippet" aria-label="Shapes classified against canon"
> >
<div <div
class="coverage-bar-fill" class="coverage-bar-fill"
:style="{ width: (coverage.total ? (coverage.recorded / coverage.total) * 100 : 0) + '%' }" :style="{ width: (coverage.total ? (coverage.accounted / coverage.total) * 100 : 0) + '%' }"
></div> ></div>
</div> </div>
<div v-if="coverage.counts" class="coverage-gaps">
<span
v-for="k in ['canonical', 'instance', 'variant', 'exempt', 'scoped']"
:key="k"
>
<span v-if="coverage.counts[k]" class="coverage-gap-chip">
{{ coverage.counts[k] }} {{ k }}
</span>
</span>
</div>
<div v-if="coverage.largest_gaps?.length" class="coverage-gaps"> <div v-if="coverage.largest_gaps?.length" class="coverage-gaps">
<span class="coverage-gaps-label">Largest gaps:</span> <span class="coverage-gaps-label">Most unclassified:</span>
<span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip"> <span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip">
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.uncovered }}</span> {{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span>
</span>
</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> </span>
</div> </div>
</template> </template>
<p v-else class="coverage-empty"> <p v-else class="coverage-empty">
Not measured yet Refresh compares the bound repo's definitions Not measured yet Refresh reads the bound repo's definitions into
against recorded snippets. the shape ledger and reports how many are classified against canon.
</p> </p>
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p> <p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
<!-- Forge pin (#2778): owner-only, because the eligible set is the
owner's own keyring. Automatic = resolve by repo host. -->
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
<label for="forge-pin" class="coverage-gaps-label">Forge</label>
<select
id="forge-pin"
v-model="forgePin"
class="input"
:disabled="savingForgePin"
@change="saveForgePin"
>
<option :value="null">Automatic (by repo host)</option>
<option v-for="c in forgeOptions" :key="c.id" :value="c.id">
{{ c.host }} ({{ c.kind }})
</option>
</select>
</div>
</div> </div>
<div class="project-body"> <div class="project-body">
@@ -1202,6 +1340,18 @@ async function confirmDelete() {
flex-wrap: wrap; flex-wrap: wrap;
font-size: 0.78rem; font-size: 0.78rem;
} }
.coverage-forge-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: 0.78rem;
}
.coverage-forge-row select {
max-width: 20rem;
}
.coverage-gaps-label { color: var(--fs-text-tertiary); } .coverage-gaps-label { color: var(--fs-text-tertiary); }
.coverage-gap-chip { .coverage-gap-chip {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
@@ -1212,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;
@@ -1664,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>
+176 -125
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();
@@ -420,15 +421,25 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false); const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false); const baseUrlSaved = ref(false);
// Git forge integration (admin only, #2689). The token round-trips masked; // Git forge connections (#2778) — the user's keyring: one read-only
// the server treats the mask as "unchanged". // credential per forge host, used server-side for every forge read on
const forge = ref({ kind: "", base_url: "", token: "", webhook_secret: "" }); // projects this user owns. The token round-trips masked; the server treats
const forgeKinds = ref<string[]>(["gitea"]); // the mask as "unchanged".
const forgeConfigured = ref(false); interface ForgeConnectionEntry {
const savingForge = ref(false); id: number; kind: string; base_url: string; host: string;
const forgeSaved = ref(false); }
const testingForge = ref(false); const forgeConnections = ref<ForgeConnectionEntry[]>([]);
const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null); const forgeKinds = ref<string[]>(["gitea", "github"]);
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
const connFormOpen = ref(false);
const savingConn = ref(false);
const testingConnId = ref(0);
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
// The webhook secret stays admin: the push endpoint is one URL per instance
// and authenticates deliveries, not users.
const forgeWebhookSecret = ref("");
const savingForgeWebhook = ref(false);
const forgeWebhookSaved = ref(false);
// Search test (SearXNG) // Search test (SearXNG)
@@ -576,25 +587,30 @@ onMounted(async () => {
// base URL not configured yet // base URL not configured yet
} }
try { try {
await loadForgeSettings(); await loadForgeWebhook();
} catch { } catch {
// forge not configured yet // webhook secret not configured yet
} }
} }
try {
await loadForgeConnections();
} catch {
// no keyring yet — the ordinary state
}
_loadTabContent(activeTab.value); _loadTabContent(activeTab.value);
}); });
async function loadForgeSettings() { async function loadForgeConnections() {
const cfg = await apiGet<{ const res = await apiGet<{
kind: string; base_url: string; token: string; webhook_secret: string; connections: ForgeConnectionEntry[]; kinds: string[];
configured: boolean; kinds: string[]; }>("/api/settings/forge-connections");
}>("/api/admin/forge"); forgeConnections.value = res.connections;
forge.value = { if (res.kinds?.length) forgeKinds.value = res.kinds;
kind: cfg.kind, base_url: cfg.base_url, token: cfg.token, }
webhook_secret: cfg.webhook_secret,
}; async function loadForgeWebhook() {
forgeConfigured.value = cfg.configured; const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds; forgeWebhookSecret.value = cfg.webhook_secret;
} }
async function changeEmail() { async function changeEmail() {
@@ -609,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;
} }
@@ -648,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;
} }
@@ -751,53 +757,83 @@ 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;
} }
} }
async function saveForge() { function editConnection(c: ForgeConnectionEntry | null) {
savingForge.value = true; connTestResult.value = null;
forgeSaved.value = false; connFormOpen.value = true;
forgeTestResult.value = null; connForm.value = c
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
}
async function saveConnection() {
savingConn.value = true;
try { try {
await apiPut("/api/admin/forge", forge.value); const { id, ...values } = connForm.value;
await loadForgeSettings(); if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
forgeSaved.value = true; else await apiPost("/api/settings/forge-connections", values);
setTimeout(() => (forgeSaved.value = false), 2000); connFormOpen.value = false;
await loadForgeConnections();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save forge settings", "error"); toastStore.show(body?.error || "Failed to save forge connection", "error");
} finally { } finally {
savingForge.value = false; savingConn.value = false;
} }
} }
async function testForge() { async function removeConnection(id: number) {
testingForge.value = true; try {
forgeTestResult.value = null; await apiDelete(`/api/settings/forge-connections/${id}`);
connTestResult.value = null;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete forge connection", "error");
}
}
async function testConnection(id: number) {
testingConnId.value = id;
connTestResult.value = null;
try { try {
const res = await apiPost<{ version: string; username: string }>( const res = await apiPost<{ version: string; username: string }>(
"/api/admin/forge/test", {}, `/api/settings/forge-connections/${id}/test`, {},
); );
forgeTestResult.value = { connTestResult.value = {
ok: true, id, ok: true,
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`, message: `Connected — ${res.version}, authenticated as ${res.username}`,
}; };
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; const body = (e as { body?: { error?: string } }).body;
forgeTestResult.value = { connTestResult.value = {
ok: false, id, ok: false,
message: body?.error || "Connection test failed", message: body?.error || "Connection test failed",
}; };
} finally { } finally {
testingForge.value = false; testingConnId.value = 0;
}
}
async function saveForgeWebhook() {
savingForgeWebhook.value = true;
try {
await apiPut("/api/admin/forge-webhook", {
webhook_secret: forgeWebhookSecret.value,
});
await loadForgeWebhook();
forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save webhook secret", "error");
} finally {
savingForgeWebhook.value = false;
} }
} }
@@ -1079,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; }
@@ -1174,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>
@@ -1710,6 +1732,70 @@ function formatUserDate(iso: string): string {
</template> </template>
</section> </section>
<section class="settings-section full-width">
<h2>Git Forges</h2>
<p class="section-desc">
Read-only connections to your git forges, one per host. Projects you
own use them server-side — resolved by repo host — to fetch and
drift-check recorded snippets and measure pattern coverage. A
read-scope token is enough. Gitea: an access token with read scope
on repositories. GitHub: a fine-grained PAT with Contents:
Read-only, base URL <code>https://github.com</code> (or your GitHub
Enterprise URL).
</p>
<table v-if="forgeConnections.length" class="api-keys-table">
<thead>
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
</thead>
<tbody>
<tr v-for="c in forgeConnections" :key="c.id">
<td>{{ c.host }}</td>
<td>{{ c.kind }}</td>
<td><code>{{ c.base_url }}</code></td>
<td>
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
{{ testingConnId === c.id ? "Testing…" : "Test" }}
</button>
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="section-desc not-configured">No forge connections yet.</p>
<p
v-if="connTestResult"
:class="connTestResult.ok ? 'text-success' : 'text-error'"
>
{{ connTestResult.message }}
</p>
<div v-if="connFormOpen" class="smtp-grid">
<div class="field">
<label for="conn-kind">Forge</label>
<select id="conn-kind" v-model="connForm.kind" class="input">
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="conn-base-url">Base URL</label>
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="conn-token">API Token (read scope)</label>
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
</div>
</div>
<div class="actions">
<template v-if="connFormOpen">
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
</button>
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
</template>
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
</div>
</section>
</div> </div>
<!-- ── Data ── --> <!-- ── Data ── -->
@@ -2161,61 +2247,26 @@ function formatUserDate(iso: string): string {
</section> </section>
<section class="settings-section full-width"> <section class="settings-section full-width">
<h2>Git Forge</h2> <h2>Forge Webhook</h2>
<p class="section-desc"> <p class="section-desc">
Optional read-only connection to your git forge (Gitea or GitHub) so Forge connections are per-user (Settings Integrations Git
snippet code can be fetched, drift-checked, and coverage-measured Forges). What stays instance-wide is the push webhook: create one on
server-side. A read-scope token is enough. Leave the kind unset to the forge pointing at <code>/api/webhooks/forge</code> with this
keep the integration off. secret, and snippets whose recorded files change get flagged for
re-verification.
</p> </p>
<div class="smtp-grid"> <div class="smtp-grid">
<div class="field">
<label for="forge-kind">Forge</label>
<select id="forge-kind" v-model="forge.kind" class="input">
<option value="">Off</option>
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="forge-base-url">Base URL</label>
<input id="forge-base-url" v-model="forge.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="forge-token">API Token (read scope)</label>
<input id="forge-token" v-model="forge.token" type="password" class="input" />
<p class="field-hint">
Gitea: an access token with read scope on repositories. GitHub:
a fine-grained PAT with Contents: Read-only (or a classic token
with repo read). For GitHub, use
<code>https://github.com</code> as the base URL — or your
GitHub Enterprise instance's URL.
</p>
</div>
<div class="field"> <div class="field">
<label for="forge-webhook-secret">Webhook Secret</label> <label for="forge-webhook-secret">Webhook Secret</label>
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" /> <input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
<p class="field-hint">
Optional: create a push webhook on the forge pointing at
<code>/api/webhooks/forge</code> with this secret, and snippets
whose recorded files change get flagged for re-verification.
</p>
</div> </div>
</div> </div>
<div class="actions" style="margin-bottom: 1.25rem;"> <div class="actions">
<button class="btn-primary" @click="saveForge" :disabled="savingForge"> <button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
{{ savingForge ? "Saving..." : "Save Forge Settings" }} {{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
</button> </button>
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured"> <span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
{{ testingForge ? "Testing..." : "Test Connection" }}
</button>
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
</div> </div>
<p
v-if="forgeTestResult"
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
>
{{ forgeTestResult.message }}
</p>
</section> </section>
</div> </div>
@@ -2276,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" }}
@@ -2312,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>
@@ -2395,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.31", "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
+18
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
@@ -107,6 +110,21 @@ The result is a single entry that shows every place the thing is used — which
exactly the signal that it was worth consolidating. This is the cure the create exactly the signal that it was worth consolidating. This is the cure the create
gate only hints at when it blocks a near-duplicate. gate only hints at when it blocks a near-duplicate.
## Consumer maps are rows, never prose
Every enumerated relationship between code and canon belongs in the shape
ledger, not in a sentence. When you establish that call sites route through a
canonical helper — during an audit, a verify pass, or a consolidation —
record each consuming definition with
`classify_shapes(project_id, [{path, symbol, status: "instance", snippet_id}])`.
A deliberate departure is a `"variant"` (reason required — the why IS the
record); a judged one-off is `"exempt"` (reason required). Prose in a
verification detail cannot be sorted, queried, or diffed; rows are what make
"what uses this?" answerable forever. `list_shapes(project_id,
status="unclassified")` is the standing todo — and N same-shaped occurrences
matching no canon means derive one first (consolidate, `create_snippet`,
then classify the rest against it), never N loose classifications.
## Why this pays off ## Why this pays off
A one-off written a second time is the cost this avoids — and at project A one-off written a second time is the cost this avoids — and at project
+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.
+6 -5
View File
@@ -60,11 +60,12 @@ class Config:
# the MCP layer doesn't proxy web search (Claude has its own). # the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "") SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
# Git forge integration (#2689) — optional read access to the operator's # Git forge integration (#2689) — optional read access to a git forge so
# forge so snippet bodies can be fetched/verified server-side. Normally # snippet bodies can be fetched/verified server-side. Connections are
# configured in Settings → Config (stored as admin settings); these env # per-user keyring rows (#2778, Settings → Git forges); these env values
# fallbacks exist so a deployment can keep the token in a Docker secret # survive as an implicit keyring entry for ADMIN users' projects only, so
# instead of the database. DB value wins when both are set. # a deployment can keep the operator's token in a Docker secret instead
# of the database. A stored row for the same host wins over the env entry.
FORGE_KIND: str = os.environ.get("FORGE_KIND", "") FORGE_KIND: str = os.environ.get("FORGE_KIND", "")
FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/") FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/")
FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "") FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "")
-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).
+8 -5
View File
@@ -49,15 +49,15 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- WHERE work happens: Systems. Tag records with system_ids as you write; - WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled. create_system when the area is unmodelled.
- HOW to work: rules are pull-only and binding — call list_always_on_rules() - HOW to work: rules are pull-only and binding — call list_always_on_rules()
yourself at session start; a push that also delivered them was an yourself at session start.
optimisation, not the bridge.
- UI: the project's design system is binding — resolve_design_system / - UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value. get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with - REUSE: search snippets before writing a helper; record what you build with
create_snippet. Saved procedures are Processes (follow verbatim). Deletes create_snippet; classify shapes against canon (classify_shapes) — a
are trash-recoverable. consumer map is rows, never prose. Saved procedures are Processes (follow
verbatim). Deletes are trash-recoverable.
A task is a note with status; *_note tools for notes, *_task for tasks. A task is a note with status (*_note vs *_task tools).
Creates are duplicate-gated: a near-match BLOCKS and returns the existing Creates are duplicate-gated: a near-match BLOCKS and returns the existing
id — update it, don't force. shared:true records are another user's — a id — update it, don't force. shared:true records are another user's — a
suggestion, not the operator's settled practice. suggestion, not the operator's settled practice.
@@ -110,6 +110,9 @@ _READ_ONLY_TOOLS = frozenset({
# Which repos map to which project. Read-only by nature; bind_repo / # Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes. # unbind_repo are the writes.
"list_repo_bindings", "list_repo_bindings",
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
# the write, and it is deliberately NOT here.
"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
+3 -2
View File
@@ -5,8 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`. from `mcp.server.build_mcp_server`.
""" """
from scribe.mcp.tools import ( from scribe.mcp.tools import (
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets, design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, shapes,
systems, tags, tasks, trash, snippets, systems, tags, tasks, trash,
) )
@@ -24,5 +24,6 @@ def register_all(mcp) -> None:
repos.register(mcp) repos.register(mcp)
processes.register(mcp) processes.register(mcp)
snippets.register(mcp) snippets.register(mcp)
shapes.register(mcp)
rulebooks.register(mcp) rulebooks.register(mcp)
trash.register(mcp) trash.register(mcp)
+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
+28 -22
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
@@ -61,11 +62,16 @@ async def enter_project(project_id: int) -> dict:
open_tasks, recent_notes, design_system, systems, pattern_coverage — open_tasks, recent_notes, design_system, systems, pattern_coverage —
plus systems_bootstrap, present only when it applies (see below). plus systems_bootstrap, present only when it applies (see below).
`pattern_coverage` (usually null) is a one-line estimate of how much of `pattern_coverage` (usually null) is the shape-accounting line — how many
the bound repo's code has recorded snippets — e.g. "pattern-library of the bound repo's extracted shapes carry a classification against canon
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api". (note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
When present, treat the gaps as a standing invitation: as you touch code 12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
in those areas, record the shapes you find with create_snippet. unclassified, largest: internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the
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
@@ -75,10 +81,10 @@ async def enter_project(project_id: int) -> dict:
a subsystem's accumulated records with list_system_records. a subsystem's accumulated records with list_system_records.
`systems_bootstrap` appears ONLY when the project has many records and no `systems_bootstrap` appears ONLY when the project has many records and no
Systems at all — act on it before starting other work: propose a starter Systems at all — act on it before starting other work: create_system a
vocabulary from the areas the project's records name, confirm it with the starter vocabulary from the areas the project's records name, directly
operator, and create_system the confirmed set. It stops appearing the and without asking permission, preferring the standard names the ask
moment the first System exists. lists. It stops appearing the moment the first System exists.
`design_system` is null unless the project points at one. When present it `design_system` is null unless the project points at one. When present it
carries the chain-merged guidance (the house style AND this project's carries the chain-merged guidance (the house style AND this project's
@@ -126,7 +132,7 @@ async def enter_project(project_id: int) -> dict:
# The arrival-moment half of the bootstrap ask (#2683): session start is # The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a # when the agent has just read the project map and is not yet deep in a
# task — the one moment "propose a starter vocabulary" is cheap. The # task — the one moment minting a starter vocabulary is cheap. The
# write-moment half rides untagged-record responses (attach_systems); # write-moment half rides untagged-record responses (attach_systems);
# both retire the instant the first System exists. # both retire the instant the first System exists.
systems_bootstrap = None systems_bootstrap = None
@@ -161,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(),
@@ -176,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,
@@ -223,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:
+314
View File
@@ -0,0 +1,314 @@
"""Shape-ledger MCP tools — the classification write/read surface (#2789).
The accounting model (note 2786): the snippet library records CANON (small);
the ledger accounts for EVERY extracted shape (total). These tools are how
agents move shapes out of `unclassified` — the todo state — and how they read
what still needs judgment. The ledger rows themselves are fed by the coverage
refresh; these tools only ever judge what the sync has seen.
"""
from __future__ import annotations
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
async def classify_shapes(
project_id: int, classifications: list[dict], via: str = "agent"
) -> dict:
"""Record judgments for a project's code shapes — in batch, as rows.
EVERY shape in a bound repo should end up classified (note 2786):
- `instance` of snippet N — it conforms to recorded canon (family-level
canon in another project counts; that fully accounts for the shape).
- `variant` of snippet N — a deliberate, named departure. `reason`
(the why) is REQUIRED; it is the record.
- `exempt` — judged genuinely one-off. `reason` REQUIRED.
- `canonical` of snippet N — this row IS the snippet's reference
(rarely set by hand; the coverage sync stamps these mechanically).
- `unclassified` — withdraw a judgment; the shape rejoins the todo.
Consumer maps belong HERE, not in prose: when an audit enumerates call
sites of a canonical helper, each call site's defining shape is an
`instance` row — a sentence in a verification detail cannot be sorted,
queried, or diffed.
Args:
project_id: The project whose ledger is being judged.
classifications: Objects of {path, symbol, status, kind?, snippet_id?,
reason?, reason_code?}. path+symbol name the shape exactly as
list_shapes shows it; kind ("sym"/"css") narrows when one file
defines both. snippet_id is required for canonical/instance/
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
"import" (carrying maps recorded elsewhere).
All-or-nothing: a structural error, a missing snippet target, or no write
access applies NOTHING. Returns {"classified": N, "unmatched": [...]} —
unmatched names shapes no live ledger row matches (the tree may have
moved since you listed; re-run the project's coverage refresh to re-sync).
"""
uid = current_user_id()
return await shape_ledger_svc.classify_shapes(
uid, project_id, classifications, via=via
)
async def list_shapes(
project_id: int,
status: str = "",
path: str = "",
snippet_id: int = 0,
include_vanished: bool = False,
limit: int = 100,
offset: int = 0,
proposal: str = "",
flag: str = "",
compact: bool = False,
uses: int = 0,
) -> dict:
"""Read a project's shape ledger — `status="unclassified"` IS the todo.
Every extracted definition in the project's bound repos has a row here
(fed by the coverage refresh). Filters compose:
Args:
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
(the coverage line's "largest" dirs go straight in here).
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).
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
just this page. Each row's `classified_by` says who judged: agent /
audit / import are judgments; `mechanical` is the canonical stamp the
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
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()
rows, total = await shape_ledger_svc.list_project_shapes(
uid, project_id,
status=status, path=path, snippet_id=snippet_id,
include_vanished=include_vanished, limit=limit, offset=offset,
proposal=proposal, flag=flag, uses=uses,
)
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:
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)
+27
View File
@@ -207,6 +207,12 @@ async def get_snippet(snippet_id: int) -> dict:
the source moved on — trust the location over the cached body and the source moved on — trust the location over the cached body and
consider verify_snippet after you look. consider verify_snippet after you look.
When the shape ledger has judgments against this snippet, the response
carries `instances` (shapes classified as conforming to it — the
structured consumer map) and/or `variants` (named departures, each with
its why). Consult them before changing the snippet's contract: they are
the call sites your change lands on (classify_shapes maintains them).
If the record belongs to someone else it carries `shared: true` with the If the record belongs to someone else it carries `shared: true` with the
`owner` and your `permission`. Read that as ONE PERSON'S SUGGESTION, not as `owner` and your `permission`. Read that as ONE PERSON'S SUGGESTION, not as
established practice here: judge it on its merits, say whose it is when you established practice here: judge it on its merits, say whose it is when you
@@ -229,6 +235,20 @@ async def get_snippet(snippet_id: int) -> dict:
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, note.user_id, data, note.id, note.project_id uid, note.user_id, data, note.id, note.project_id
) )
# The structured consumer map (#2789): ledger rows judged against this
# snippet. Attached only when non-empty (#2483) — and never for projects
# the caller can't read.
from scribe.services import shape_ledger as shape_ledger_svc
consumers = await shape_ledger_svc.snippet_consumers(uid, int(note.id))
if consumers["instances"]:
data["instances"] = consumers["instances"]
if 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
@@ -336,6 +356,13 @@ async def verify_snippet(
"moved to services/knowledge.py"). It's what makes the record fixable later "moved to services/knowledge.py"). It's what makes the record fixable later
by someone who wasn't here, so write it for them, not as a status echo. by someone who wasn't here, so write it for them, not as a status echo.
CONSUMERS you enumerate while checking ("all N call sites still route
through it") are ledger rows, not detail prose: classify each consuming
definition as an `instance` of this snippet with classify_shapes — prose
cannot be sorted, queried, or diffed (note 2786's lesson), and the rows
are what make "what uses this?" answerable forever. `detail` keeps the
WHY and what changed, nothing that belongs in a row.
A verdict expires automatically if the snippet is edited afterwards: it is A verdict expires automatically if the snippet is edited afterwards: it is
stamped with a hash of the code it was checked against, so it can never go stamped with a hash of the code it was checked against, so it can never go
on vouching for code nobody checked. Re-verify after fixing a record. on vouching for code nobody checked. Re-verify after fixing a record.
+36 -14
View File
@@ -23,6 +23,18 @@ from scribe.services import systems as systems_svc
_BOOTSTRAP_MIN_RECORDS = 20 _BOOTSTRAP_MIN_RECORDS = 20
_BOOTSTRAP_TITLES = 6 _BOOTSTRAP_TITLES = 6
# The standard vocabulary (#2798): area names that recur across software
# projects, offered so "CI & Release" means the same thing in every project
# on the instance. Consistency comes from the shared names — NOT from asking
# the operator to approve each System; agents mint directly. Generic by
# design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl.
_STANDARD_SYSTEMS = (
"CI & Release", "Auth & Access", "Data Model & Storage", "API Surface",
"UI & Design", "Import & Export", "Background Jobs", "Observability",
)
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None: async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
"""The escalated vocabulary-bootstrap ask for a mature zero-Systems project. """The escalated vocabulary-bootstrap ask for a mature zero-Systems project.
@@ -33,8 +45,10 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
(#2683). What separates the nudges that convert from the prose that (#2683). What separates the nudges that convert from the prose that
doesn't (the duplicate gate, the prior-art "already defined in 2 files") doesn't (the duplicate gate, the prior-art "already defined in 2 files")
is the project's OWN evidence in the ask — so this one carries the record is the project's OWN evidence in the ask — so this one carries the record
count and the recent titles, and asks for a concrete deliverable: propose count and the recent titles, and asks for a concrete deliverable: create
a starter set, confirm, create. a starter set directly, preferring the standard cross-project names.
Deliberately NOT an approval flow (#2798): the operator is not a
permission gate for vocabulary; the standard names carry consistency.
Self-retiring by construction: callers only reach for it while the Self-retiring by construction: callers only reach for it while the
project has zero Systems, so the first create_system ends it everywhere. project has zero Systems, so the first create_system ends it everywhere.
@@ -56,12 +70,15 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
return ( return (
f"This project has {total} records and NO Systems modelled — none of " f"This project has {total} records and NO Systems modelled — none of "
"them can be tagged to an area, so recurring problem-spots stay " "them can be tagged to an area, so recurring problem-spots stay "
"invisible. Bootstrap the vocabulary now, in this session: from the " "invisible. Bootstrap the vocabulary now, in this session, without "
f"areas the records themselves name (recent: {titles}), propose 3-6 " "asking permission — creating Systems is your call, not an approval "
"Systems to the operator, create_system each confirmed one with a " f"flow. From the areas the records themselves name (recent: {titles}), "
"one-paragraph charter, then tag this record (system_ids=[...]). " "create_system 3-6 Systems, each with a one-paragraph charter, then "
"This ask repeats until the first System exists; answering it once " "tag this record (system_ids=[...]). Where an area fits a standard "
"retires it for every future record." f"name, use it verbatim so it means the same thing in every project: "
f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
"duplicate gate guards sprawl. This ask repeats until the first "
"System exists; answering it once retires it for every future record."
) )
@@ -117,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
@@ -148,11 +165,16 @@ async def create_system(
Create one the moment two records would share an area that has no System Create one the moment two records would share an area that has no System
yet — the same two-or-more test snippets use. Don't wait to be asked to yet — the same two-or-more test snippets use. Don't wait to be asked to
name an area that plainly exists in the code; an unmodelled area means name an area that plainly exists in the code, and don't route the
every record about it stays untaggable. An audit or sweep that walks the creation through operator approval — minting vocabulary is the agent's
codebase is a DISCOVERY moment: mint the Systems it names as it names call (#2798); an unmodelled area means every record about it stays
them — the duplicate gate below, plus reviewing the existing list, is what untaggable. An audit or sweep that walks the codebase is a DISCOVERY
guards against sprawl, not holding back. Give each one a one-paragraph moment: mint the Systems it names as it names them — the duplicate gate
below, plus reviewing the existing list, is what guards against sprawl,
not holding back. Prefer the standard cross-project names where the area
fits one (CI & Release, Auth & Access, Data Model & Storage, API Surface,
UI & Design, Import & Export, Background Jobs, Observability) so the same
word means the same thing in every project. Give each one a one-paragraph
charter, not just a label: the description is what tells a later session charter, not just a label: the description is what tells a later session
whether a record belongs here. whether a record belongs here.
+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
+2
View File
@@ -43,5 +43,7 @@ from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions, Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
) )
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.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."""
+322
View File
@@ -0,0 +1,322 @@
from datetime import datetime, timezone
from sqlalchemy import (
BigInteger,
DateTime,
Float,
ForeignKey,
Index,
Integer,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, iso
# The classification vocabulary (note 2786). `unclassified` is the default and
# THE todo state; every other status is a judgment, stamped with who made it —
# 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")
# 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):
"""One extracted code shape and its classification against canon (#2787).
The accounting half of the pattern system (governing note 2786): the
snippet library records CANON (small); this ledger accounts for EVERY
shape the coverage extractor finds in a bound repo (total). A row's
status says how the shape relates to canon — it IS a snippet's reference
(`canonical`), conforms to one (`instance` — snippet_id may point at
another project's snippet, so family canon counts), departs deliberately
(`variant`, with the why in `reason`), was judged one-off (`exempt`,
a recorded judgment rather than silence), or awaits judgment
(`unclassified` — the todo).
Identity is (project, repo_key, path, symbol, kind) — kind is part of it
because one file can define `.foo` (css) and `foo` (sym) as distinct
shapes. A rename therefore reads as vanish + new row: accepted for v1,
because chasing renames needs content identity the extractor doesn't
have. `vanished_at` keeps the history instead of deleting it.
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
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"
__table_args__ = (
UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
Index("ix_code_shapes_project_status", "project_id", "status"),
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)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
repo_key: Mapped[str] = mapped_column(Text, 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) # "css" | "sym"
status: Mapped[str] = mapped_column(Text, nullable=False, default="unclassified")
snippet_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), 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_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
first_seen_commit: Mapped[str] = mapped_column(Text, default="")
last_seen_commit: Mapped[str] = mapped_column(Text, default="")
vanished_at: Mapped[datetime | None] = mapped_column(
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:
return {
"id": self.id,
"project_id": self.project_id,
"repo_key": self.repo_key,
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"status": self.status,
"snippet_id": self.snippet_id,
"reason": self.reason,
"reason_code": self.reason_code,
"classified_by": self.classified_by,
"classified_at": iso(self.classified_at),
"first_seen_commit": self.first_seen_commit,
"last_seen_commit": self.last_seen_commit,
"vanished_at": iso(self.vanished_at),
"signature": self.signature,
"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),
} }
+45
View File
@@ -0,0 +1,45 @@
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, iso
class ForgeConnection(Base, TimestampMixin):
"""One user's read-only credential for one git forge host (#2778).
The keyring model: a user owns a set of connections and every server-side
forge read for a project runs on the PROJECT OWNER's set, resolved by the
repo's host. One row per (user, host) — the repo's host picks the
connection deterministically, so there is no "default forge" pointer to
maintain or tie-break.
`host` is derived from `base_url` at write time and stored because it is
the lookup key; the service layer keeps the two in step. The token is a
secret: to_dict never includes it, and no route may return it.
"""
__tablename__ = "forge_connections"
__table_args__ = (
UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(Text, nullable=False)
base_url: Mapped[str] = mapped_column(Text, nullable=False)
host: Mapped[str] = mapped_column(Text, nullable=False)
token: Mapped[str] = mapped_column(Text, nullable=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"kind": self.kind,
"base_url": self.base_url,
"host": self.host,
"created_at": iso(self.created_at),
"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"),
+13 -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):
@@ -27,6 +27,15 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
design_system_id: Mapped[int | None] = mapped_column( design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
) )
# The per-project forge pin (#2778). NULL is the ordinary state: forge
# reads resolve against the owner's keyring by repo host. When set, the
# project's forge reads use ONLY this connection — an explicit, auditable
# choice, constrained by the service layer to a connection the project
# OWNER holds (never a collaborator's token).
forge_connection_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
nullable=True,
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -38,6 +47,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"status": self.status, "status": self.status,
"color": self.color, "color": self.color,
"design_system_id": self.design_system_id, "design_system_id": self.design_system_id,
"created_at": self.created_at.isoformat(), "forge_connection_id": self.forge_connection_id,
"updated_at": self.updated_at.isoformat(), "created_at": iso(self.created_at),
"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,
} }
+19 -67
View File
@@ -19,18 +19,10 @@ from scribe.services.backup import (
restore_full_backup, restore_full_backup,
) )
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.forge import (
FORGE_BASE_URL_KEY,
FORGE_KIND_KEY,
FORGE_KINDS,
FORGE_TOKEN_KEY,
ForgeError,
forge_config,
get_forge,
)
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,
@@ -125,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)
@@ -139,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])
@@ -166,88 +158,48 @@ 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
# forges); what stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users.
@admin_bp.route("/forge", methods=["GET"]) @admin_bp.route("/forge-webhook", methods=["GET"])
@admin_required @admin_required
async def get_forge_settings(): async def get_forge_webhook_settings():
from scribe.config import Config from scribe.config import Config
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
cfg = await forge_config()
webhook_secret = ( webhook_secret = (
await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "") await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "")
or Config.FORGE_WEBHOOK_SECRET or Config.FORGE_WEBHOOK_SECRET
) )
return jsonify({ return jsonify({
"kind": cfg["kind"],
"base_url": cfg["base_url"],
# 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.
"token": _TOKEN_MASK if cfg["token"] else "", "webhook_secret": SECRET_MASK if webhook_secret else "",
"webhook_secret": _TOKEN_MASK if webhook_secret else "",
"configured": bool(await get_forge()),
"kinds": list(FORGE_KINDS),
}) })
@admin_bp.route("/forge", methods=["PUT"]) @admin_bp.route("/forge-webhook", methods=["PUT"])
@admin_required @admin_required
async def update_forge_settings(): async def update_forge_webhook_settings():
data = await request.get_json() or {}
uid = get_current_user_id()
kind = str(data.get("kind", "")).strip().lower()
if kind and kind not in FORGE_KINDS:
return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400
base_url = str(data.get("base_url", "")).strip().rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
return jsonify({"error": "Forge base URL must use http or https"}), 400
await set_admin_setting(FORGE_KIND_KEY, kind)
await set_admin_setting(FORGE_BASE_URL_KEY, base_url)
token = data.get("token")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if token is not None and token != _TOKEN_MASK:
await set_admin_setting(FORGE_TOKEN_KEY, str(token))
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
data = await request.get_json() or {}
uid = get_current_user_id()
webhook_secret = data.get("webhook_secret") webhook_secret = data.get("webhook_secret")
if webhook_secret is not None and webhook_secret != _TOKEN_MASK: # The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
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 token is deliberately absent from the audit detail. # The secret is deliberately absent from the audit detail.
await log_audit( await log_audit(
"forge_config", user_id=uid, username=g.user.username, "forge_webhook_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr, ip_address=request.remote_addr, details={},
details={"kind": kind, "base_url": base_url},
) )
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@admin_bp.route("/forge/test", methods=["POST"])
@admin_required
async def test_forge():
"""Probe the SAVED forge config: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied to integrations)."""
uid = get_current_user_id()
forge = await get_forge()
if forge is None:
return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400
try:
result = await forge.check()
except ForgeError as e:
return jsonify({"error": str(e)}), 502
await log_audit(
"forge_test", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"ok": True, "username": result.get("username", "")},
)
return jsonify(result)
@admin_bp.route("/logs", methods=["GET"]) @admin_bp.route("/logs", methods=["GET"])
@admin_required @admin_required
async def list_logs(): async def list_logs():
+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():
+48 -10
View File
@@ -1,7 +1,7 @@
"""Project management routes.""" """Project management routes."""
import logging import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination from scribe.routes.utils import not_found, parse_pagination
@@ -123,14 +123,14 @@ async def delete_project_route(project_id: int):
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"]) @projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
@login_required @login_required
async def get_coverage_route(project_id: int): async def get_coverage_route(project_id: int):
"""The cached pattern-library coverage summary — never computes. """The cached shape-accounting summary — never computes.
`configured` tells the card whether offering a Refresh button makes `configured` tells the card whether offering a Refresh button makes
sense; `coverage` is null until something has computed it (a webhook sense; `coverage` is null until something has computed it (a webhook
push or an explicit refresh). push or an explicit refresh).
""" """
from scribe.services.coverage import cached_coverage from scribe.services.coverage import cached_coverage
from scribe.services.forge import get_forge from scribe.services.forge import get_forges
uid = get_current_user_id() uid = get_current_user_id()
result = await get_project_for_user(uid, project_id) result = await get_project_for_user(uid, project_id)
@@ -139,7 +139,9 @@ async def get_coverage_route(project_id: int):
project, _ = result project, _ = result
owner_uid = project.user_id or uid owner_uid = project.user_id or uid
return jsonify({ return jsonify({
"configured": await get_forge() is not None, # The OWNER's keyring (#2778) — whether refresh could do anything,
# regardless of who is looking.
"configured": (await get_forges(owner_uid, project_id)).configured,
"coverage": await cached_coverage(owner_uid, project_id), "coverage": await cached_coverage(owner_uid, project_id),
}) })
@@ -153,7 +155,7 @@ async def refresh_coverage_route(project_id: int):
and wants the new number, and the forge timeout bounds the wait. and wants the new number, and the forge timeout bounds the wait.
""" """
from scribe.services.coverage import refresh_coverage from scribe.services.coverage import refresh_coverage
from scribe.services.forge import ForgeError, get_forge from scribe.services.forge import ForgeError, get_forges
uid = get_current_user_id() uid = get_current_user_id()
result = await get_project_for_user(uid, project_id) result = await get_project_for_user(uid, project_id)
@@ -161,20 +163,56 @@ async def refresh_coverage_route(project_id: int):
return not_found("Project") return not_found("Project")
project, _ = result project, _ = result
owner_uid = project.user_id or uid owner_uid = project.user_id or uid
if await get_forge() is None: selector = await get_forges(owner_uid, project_id)
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400 if not selector.configured:
return jsonify({
"error": "The project owner has no forge connection "
"(Settings → Git forges)"
}), 400
try: try:
coverage = await refresh_coverage(owner_uid, project_id) coverage = await refresh_coverage(owner_uid, project_id, selector=selector)
except ForgeError as exc: except ForgeError as exc:
return jsonify({"error": str(exc)}), 502 return jsonify({"error": str(exc)}), 502
if coverage is None: if coverage is None:
return jsonify({ return jsonify({
"error": "No bound repo is served by the configured forge" "error": "No bound repo is served by the owner's forge connections"
"bind the project's repo (bind_repo) on a remote the forge hosts" "bind the project's repo (bind_repo) on a remote a connection hosts"
}), 400 }), 400
return jsonify({"coverage": coverage}) return jsonify({"coverage": coverage})
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
"""Pin the project to one forge connection, or clear the pin (#2778).
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
the pin can only reference a connection the project OWNER holds — the
service enforces that, so a collaborator's token can never end up serving
someone else's project.
"""
from scribe.services.forge_connections import set_project_pin
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, permission = result
if permission != "owner" and g.user.role != "admin":
return jsonify({"error": "Only the project owner can change its forge"}), 403
data = await request.get_json() or {}
raw = data.get("connection_id")
if raw is not None and not isinstance(raw, int):
return jsonify({"error": "connection_id must be an integer or null"}), 400
owner_uid = project.user_id or uid
if not await set_project_pin(owner_uid, project_id, raw):
return jsonify({
"error": "That connection does not belong to the project owner"
}), 400
return jsonify({"forge_connection_id": raw})
@projects_bp.route("/<int:project_id>/notes", methods=["GET"]) @projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required @login_required
async def get_project_notes_route(project_id: int): async def get_project_notes_route(project_id: int):
+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", ""),
+107 -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__)
@@ -20,13 +22,13 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# read and skip the mask on write; this generic KV surface has to apply the # read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the # same treatment, or it silently un-masks what those endpoints masked — the
# 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.
_SECRET_KEYS = frozenset({"smtp_password", "forge_token", "forge_webhook_secret"}) # (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_MASK = "********" _SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
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()
} }
@@ -52,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)
@@ -73,3 +75,103 @@ async def test_search():
if not Config.searxng_enabled(): if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""}) return jsonify({"configured": False, "results": [], "searxng_url": ""})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL}) return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
# another user's keyring. Tokens never leave the server: the model's to_dict
# omits them, and the routes never echo the submitted value back.
@settings_bp.route("/forge-connections", methods=["GET"])
@login_required
async def list_forge_connections_route():
from scribe.services.forge import FORGE_KINDS
from scribe.services.forge_connections import list_connections
uid = get_current_user_id()
rows = await list_connections(uid)
return jsonify({
"connections": [r.to_dict() for r in rows],
"kinds": list(FORGE_KINDS),
})
@settings_bp.route("/forge-connections", methods=["POST"])
@login_required
async def create_forge_connection_route():
from scribe.services.forge_connections import create_connection
uid = get_current_user_id()
data = await request.get_json() or {}
try:
row = await create_connection(
uid,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=str(data.get("token", "")),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(row.to_dict()), 201
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["PUT"])
@login_required
async def update_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import update_connection
uid = get_current_user_id()
data = await request.get_json() or {}
token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection.
if token == SECRET_MASK:
token = ""
try:
row = await update_connection(
uid, connection_id,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=token,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if row is None:
return jsonify({"error": "Connection not found"}), 404
return jsonify(row.to_dict())
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["DELETE"])
@login_required
async def delete_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import delete_connection
uid = get_current_user_id()
if not await delete_connection(uid, connection_id):
return jsonify({"error": "Connection not found"}), 404
return "", 204
@settings_bp.route("/forge-connections/<int:connection_id>/test", methods=["POST"])
@login_required
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied per keyring row)."""
from scribe.services.forge import ForgeError, build_adapter
from scribe.services.forge_connections import get_connection
uid = get_current_user_id()
row = await get_connection(uid, connection_id)
if row is None:
return jsonify({"error": "Connection not found"}), 404
adapter = build_adapter(row.kind, row.base_url, row.token)
if adapter is None:
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
try:
return jsonify(await adapter.check())
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502
+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:
+302 -273
View File
@@ -11,6 +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, 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 (
@@ -36,8 +37,16 @@ logger = logging.getLogger(__name__)
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh: # v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
# the table shipped without a backup section and the coverage test failed the # the table shipped without a backup section and the coverage test failed the
# build, which is the whole reason that list was written. # build, which is the whole reason that list was written.
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
# 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.
# 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 = 6 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
@@ -55,6 +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); v8: its history (#2793).
"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
@@ -72,6 +83,12 @@ _NOT_INCLUDED = [
"api_keys", "note_embeddings", "app_logs", "notifications", "api_keys", "note_embeddings", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles", "invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs", "retrieval_logs",
# Sensitive credentials, same reasoning as api_keys: a backup that carries
# forge tokens is a token-exfiltration file. Users re-add connections
# after a restore; the per-project pin (projects.forge_connection_id) is
# deliberately not exported either, so restored projects fall back to
# keyring-by-host resolution — the documented unpinned behavior (#2778).
"forge_connections",
] ]
@@ -160,6 +177,18 @@ def _usage_event_rows(rows) -> list[dict]:
] ]
def _code_shape_rows(rows) -> list[dict]:
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}
@@ -167,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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -199,6 +364,13 @@ async def export_full_backup() -> dict:
design_tokens = (await session.execute(select(DesignToken))).scalars().all() design_tokens = (await session.execute(select(DesignToken))).scalars().all()
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_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()
@@ -221,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),
@@ -373,6 +414,9 @@ async def export_full_backup() -> dict:
"note_usage_events": _usage_event_rows(usage_events), "note_usage_events": _usage_event_rows(usage_events),
"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_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
} }
@@ -440,6 +484,20 @@ async def export_user_backup(user_id: int) -> dict:
repo_bindings = (await session.execute( repo_bindings = (await session.execute(
select(RepoBinding).where(RepoBinding.user_id == user_id) select(RepoBinding).where(RepoBinding.user_id == user_id)
)).scalars().all() )).scalars().all()
# The ledger has no user_id of its own — rows belong to the project
# they account for, so a user's export carries their projects' rows.
code_shapes = (await session.execute(
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
)).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()
@@ -489,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),
@@ -628,6 +567,9 @@ async def export_user_backup(user_id: int) -> dict:
"note_usage_events": _usage_event_rows(usage_events), "note_usage_events": _usage_event_rows(usage_events),
"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_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
} }
@@ -731,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, "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:
@@ -1108,6 +1051,92 @@ async def _restore_v2(data: dict) -> dict:
)) ))
stats["repo_bindings"] += 1 stats["repo_bindings"] += 1
# 21. Code shapes (v7, #2787) — the ledger's classifications are
# judgments worth carrying. A judgment whose snippet target didn't
# survive the re-mapping (canonical/instance/variant with a gone
# snippet) is downgraded to unclassified so it rejoins the todo
# honestly instead of dangling; exempt needs no target and keeps.
shape_id_map: dict[int, int] = {}
for cs_data in data.get("code_shapes", []):
mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
if mapped_pid is None:
continue
status = cs_data.get("status", "unclassified")
mapped_sid = note_id_map.get(cs_data.get("snippet_id") or 0)
classified_by = cs_data.get("classified_by")
classified_at = cs_data.get("classified_at")
if status in ("canonical", "instance", "variant") and mapped_sid is None:
status = "unclassified"
classified_by = None
classified_at = None
shape = CodeShape(
project_id=mapped_pid,
repo_key=cs_data.get("repo_key", ""),
path=cs_data.get("path", ""),
symbol=cs_data.get("symbol", ""),
kind=cs_data.get("kind", "sym"),
status=status,
snippet_id=mapped_sid,
reason=cs_data.get("reason"),
classified_by=classified_by,
classified_at=_dt(classified_at) if classified_at else None,
first_seen_commit=cs_data.get("first_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,
# 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")),
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
# 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)
+465 -125
View File
@@ -25,23 +25,28 @@ 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 ForgeAdapter, get_forge 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__)
# Cache key in the settings KV, on the project OWNER's user_id — the same # Cache key in the settings KV, on the project OWNER's user_id — the same
# channel the scheduler's last-run summary uses for machine-written state. # channel the scheduler's last-run summary uses for machine-written state.
_CACHE_KEY_PREFIX = "pattern_coverage_" # v2 suffix with #2788: the payload shape inverted (accounted/unclassified);
# pre-ledger blobs under the old key simply stop being found, so the card
# honestly reads "not measured yet" until the first ledger-era refresh.
_CACHE_KEY_PREFIX = "pattern_coverage_v2_"
# Files whose content can't hold definitions — the hook's skip list, verbatim, # Files whose content can't hold definitions — the hook's skip list, verbatim,
# plus sourcemaps (which are JSON in a trenchcoat). # plus sourcemaps (which are JSON in a trenchcoat).
@@ -88,6 +93,160 @@ _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.
if kind == "css":
# One-line rules (`.x { color: red; }`) carry their declarations on
# the selector line itself; a block that is only the selector plus
# trailing blanks must not hash to the empty string (which grouped
# 68 unrelated one-liners as one "copy" on first deploy, #2872).
first = lines[i]
brace = first.find("{")
head = [first[brace + 1:]] if brace >= 0 and first[brace + 1:].strip() else []
hashed = head + block[1:]
if not any(x.strip() for x in hashed):
hashed = block
else:
hashed = 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".
@@ -95,29 +254,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:
@@ -128,14 +265,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:
@@ -150,84 +307,57 @@ 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
# --- matching shapes against recorded locations ------------------------------ # --- matching shapes against recorded locations ------------------------------
def _norm_symbol(kind_or_symbol: str) -> str: # The covering predicate (location_covers) lives in services/shape_ledger.py
# CSS shapes and recorded CSS symbols may or may not carry the leading # since #2788 — the ledger's canonical marking and this module's readout are
# dot; compare without it so ".btn-primary" and "btn-primary" agree. # two consumers of ONE doctrine, and the ledger is its home.
return kind_or_symbol.lstrip(".").strip()
def _location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
# The drift check's location semantics, not a second copy of them: exact
# file, or the recorded path is a directory the file lives under.
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
def match_shapes(
shapes: list[tuple[str, str, str]],
recorded: list[tuple[str, str]],
) -> list[tuple[str, str, str, bool]]:
"""Each shape with whether some recorded (path, symbol) location covers it.
Symbol-less recorded locations never cover a shape — a whole-file record
makes no claim about any particular definition inside it. The recorded
repo NAME is deliberately not consulted: it is free-form ("Scribe") and
the project binding already did the scoping; on a project binding several
repos this can over-credit a same-named symbol, which the estimate label
owns.
"""
usable = [(p, s) for p, s in recorded if (s or "").strip()]
return [
(
path,
kind,
name,
any(_location_covers(lp, ls, path, name) for lp, ls in usable),
)
for path, kind, name in shapes
]
def largest_gaps( def largest_gaps(
matched: list[tuple[str, str, str, bool]], *, top: int = 3 accounted: list[tuple[str, str, str, bool]], *, top: int = 3
) -> list[dict]: ) -> list[dict]:
"""The directories with the most uncovered shapes — where a backlog """The directories with the most unclassified shapes — where a
session should start, named the way the repo names them.""" classification session should start, named the way the repo names them."""
by_dir: dict[str, dict[str, int]] = {} by_dir: dict[str, dict[str, int]] = {}
for path, _kind, _name, covered in matched: for path, _kind, _name, is_accounted in accounted:
d = posixpath.dirname(path) or "(root)" d = posixpath.dirname(path) or "(root)"
row = by_dir.setdefault(d, {"total": 0, "uncovered": 0}) row = by_dir.setdefault(d, {"total": 0, "unclassified": 0})
row["total"] += 1 row["total"] += 1
if not covered: if not is_accounted:
row["uncovered"] += 1 row["unclassified"] += 1
ranked = sorted( ranked = sorted(
by_dir.items(), key=lambda kv: (-kv[1]["uncovered"], kv[0]) by_dir.items(), key=lambda kv: (-kv[1]["unclassified"], kv[0])
) )
return [ return [
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]} {"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
for d, row in ranked[:top] for d, row in ranked[:top]
if row["uncovered"] if row["unclassified"]
] ]
# --- compute, cache, surface ------------------------------------------------- # --- compute, cache, surface -------------------------------------------------
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]: async def _recorded_locations(
"""(path, symbol) for every location of every live snippet in a project.""" user_id: int, project_id: int
) -> list[tuple[int, str, str]]:
"""(snippet_note_id, path, symbol) for every location of every live
snippet in a project — the canonical-marking input (#2788): the id is what
lets a ledger row point back at the snippet it references."""
from sqlalchemy import select from sqlalchemy import select
from scribe.models import async_session from scribe.models import async_session
@@ -244,65 +374,160 @@ async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str,
) )
) )
notes = list(rows.scalars().all()) notes = list(rows.scalars().all())
out: list[tuple[str, str]] = [] out: list[tuple[int, str, str]] = []
for note in notes: for note in notes:
for loc in snippet_fields(note).get("locations") or []: for loc in snippet_fields(note).get("locations") or []:
out.append((loc.get("path") or "", loc.get("symbol") or "")) out.append(
(int(note.id), loc.get("path") or "", loc.get("symbol") or "")
)
return out return out
async def compute_coverage( async def compute_coverage(
user_id: int, project_id: int, *, forge: ForgeAdapter | None = None user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None: ) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos. """Sync the shape ledger from the bound repos and read the accounting.
None means "nothing to measure" — no forge configured, or none of the Since #2788 this is the ledger's sync point, not just a measurement:
project's bound repos is served by it. That is the ordinary state for a every walk upserts the extracted shapes (new → unclassified, vanished →
forge-less install and every caller treats it as silence, not failure. stamped), re-stamps snippet reference locations as canonical, and then
Forge errors (unreachable, bad token) RAISE — the two callers are a reports the ACCOUNTING — how many shapes carry a classification at all —
refresh button and a background task, and both want to know. rather than the old "has a snippet" fraction. Unclassified is the todo
(note 2786).
None means "nothing to measure" — the owner's keyring serves none of the
project's bound repos (#2778). That is the ordinary state for a
forge-less user and every caller treats it as silence, not failure; the
ledger is untouched in that case. Forge errors (unreachable, bad token)
RAISE — the two callers are a refresh button and a background task, and
both want to know.
``user_id`` is the project OWNER's id: the cache lives there, and the
keyring resolved here must be the same one every other read uses.
""" """
forge = forge if forge is not None else await get_forge() from scribe.services import shape_ledger
if forge is None: from scribe.services.forge import ForgeError
if selector is None:
selector = await get_forges(user_id, project_id)
if not selector.configured:
return None return None
repos: list[dict] = [] served: list[tuple[str, str]] = []
matched_all: list[tuple[str, str, str, bool]] = []
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
api_repo = forge.resolve_repo(key) # the project's repos (#2792).
if api_repo is None: canons = None
continue # bound to a host this forge doesn't serve proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0}
ref = await forge.default_branch(api_repo) for binding in await bindings_for_project(user_id, project_id):
shapes = shapes_from_archive(await forge.archive(api_repo, ref)) key = binding.repo_key
matched = match_shapes(shapes, recorded) hit = selector.resolve(key)
matched_all.extend(matched) if hit is None:
repos.append({ continue # bound to a host no connection serves
"repo": key, forge, api_repo = hit
"ref": ref, # The binding's own ref when it names one (#2873: a dev-first project
"total": len(matched), # has its ledger follow dev), else the forge's default branch.
"recorded": sum(1 for *_x, covered in matched if covered), ref = binding.ref or await forge.default_branch(api_repo)
}) definitions = definitions_from_archive(await forge.archive(api_repo, ref))
if not repos: # 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
# enough and the row timestamps carry the when.
try:
marker = await forge.latest_commit(api_repo, "", ref) or ref
except ForgeError:
marker = ref
await shape_ledger.sync_repo_shapes(
project_id, key, definitions, seen_marker=marker
)
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:
return None return None
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
# repo that was unreachable today still has live rows, and they count.
rows = await shape_ledger.live_rows(project_id)
counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0,
"scoped": 0, "unclassified": 0}
for row in rows:
counts[row.status] = counts.get(row.status, 0) + 1
by_repo: dict[str, dict[str, int]] = {}
for row in rows:
agg = by_repo.setdefault(row.repo_key, {"total": 0, "accounted": 0})
agg["total"] += 1
agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified")
proposals = shape_ledger.proposal_summary(rows)
divergence = shape_ledger.divergence_summary(rows)
return { return {
"total": len(matched_all), "total": len(rows),
"recorded": sum(1 for *_x, covered in matched_all if covered), "accounted": len(rows) - unclassified,
"unclassified": unclassified,
"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,
"computed_at": datetime.now(timezone.utc).isoformat(), "computed_at": datetime.now(timezone.utc).isoformat(),
"repos": repos, "repos": [
"largest_gaps": largest_gaps(matched_all), {"repo": key, "ref": ref,
**by_repo.get(key, {"total": 0, "accounted": 0})}
for key, ref in served
],
"largest_gaps": largest_gaps([
(r.path, r.kind, r.symbol, r.status != "unclassified")
for r in rows
]),
} }
async def refresh_coverage( async def refresh_coverage(
user_id: int, project_id: int, *, forge: ForgeAdapter | None = None user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None: ) -> dict | None:
"""Compute and cache. The only writer of the cache key.""" """Compute and cache. The only writer of the cache key."""
coverage = await compute_coverage(user_id, project_id, forge=forge) coverage = await compute_coverage(user_id, project_id, selector=selector)
if coverage is not None: if coverage is not None:
await set_setting( await set_setting(
user_id, f"{_CACHE_KEY_PREFIX}{project_id}", json.dumps(coverage) user_id, f"{_CACHE_KEY_PREFIX}{project_id}", json.dumps(coverage)
@@ -310,6 +535,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}", "")
@@ -325,12 +634,43 @@ async def cached_coverage(user_id: int, project_id: int) -> dict | None:
def coverage_line(coverage: dict) -> str: def coverage_line(coverage: dict) -> str:
"""The one-line evidence-carrying summary enter_project surfaces.""" """The one-line evidence-carrying summary enter_project surfaces."""
day = (coverage.get("computed_at") or "")[:10] day = (coverage.get("computed_at") or "")[:10]
line = ( counts = coverage.get("counts") or {}
f"pattern-library coverage: {coverage.get('recorded', 0)}" breakdown = " · ".join(
f"/{coverage.get('total', 0)} shapes recorded" f"{counts[k]} {k}"
f" (estimate{', computed ' + day if day else ''})" for k in ("canonical", "instance", "variant", "exempt", "scoped")
if counts.get(k)
) )
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []] line = (
if gaps: f"shape accounting: {coverage.get('accounted', 0)}"
line += "; largest gaps: " + ", ".join(gaps) f"/{coverage.get('total', 0)} shapes accounted for"
)
if breakdown:
line += f"{breakdown}"
line += f" (estimate{', computed ' + day if day else ''})"
unclassified = coverage.get("unclassified", 0)
if 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 []]
if 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:
+124 -40
View File
@@ -8,10 +8,12 @@ webhooks (step 6), and coverage can be measured (step 7).
Design constraints, in force everywhere below: Design constraints, in force everywhere below:
- OPTIONAL per instance (rule #115). `get_forge()` returns None when nothing - OPTIONAL per user (rule #115, sharpened by #2778). Connections are
is configured, and every consumer must treat None as "keep today's per-user keyring rows resolved by repo host on the PROJECT OWNER's
behavior". An install that never configures a forge is not degraded — it keyring; `get_forges()` returns an empty selector when the owner has
is the baseline. nothing configured, and every consumer must treat that as "keep today's
behavior". A user who never configures a forge is not degraded — that is
the baseline.
- READ-ONLY by construction. The adapter exposes reads; there is no write - READ-ONLY by construction. The adapter exposes reads; there is no write
method to misuse. The token an operator mints for it only ever needs read method to misuse. The token an operator mints for it only ever needs read
scope, and the docs say so. scope, and the docs say so.
@@ -40,18 +42,14 @@ from dataclasses import dataclass
from urllib.parse import quote, urlsplit from urllib.parse import quote, urlsplit
import httpx import httpx
from sqlalchemy import select
from scribe.config import Config from scribe.config import Config
from scribe.services.repo_bindings import normalize_repo_key from scribe.services.repo_bindings import normalize_repo_key
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FORGE_KIND_KEY = "forge_kind" # Kinds a connection can use. Matches _FORGE_CLASSES below.
FORGE_BASE_URL_KEY = "forge_base_url"
FORGE_TOKEN_KEY = "forge_token"
# Kinds an instance can configure. Matches _FORGE_CLASSES below.
FORGE_KINDS = ("gitea", "github") FORGE_KINDS = ("gitea", "github")
# Total budget per forge call. Consumers either have a cache to fall back to # Total budget per forge call. Consumers either have a cache to fall back to
@@ -86,7 +84,8 @@ class ForgeFile:
path: str path: str
def _host_of(url: str) -> str: def host_of(url: str) -> str:
"""The lowercase hostname of a URL — the keyring's lookup key (#2778)."""
return (urlsplit(url).hostname or "").lower() return (urlsplit(url).hostname or "").lower()
@@ -111,7 +110,7 @@ class ForgeAdapter:
@property @property
def host(self) -> str: def host(self) -> str:
return _host_of(self.base_url) return host_of(self.base_url)
def resolve_repo(self, repo_or_url: str) -> str | None: def resolve_repo(self, repo_or_url: str) -> str | None:
"""The forge-API repo path for a recorded repo — or None if this forge """The forge-API repo path for a recorded repo — or None if this forge
@@ -359,39 +358,124 @@ _FORGE_CLASSES: dict[str, type[ForgeAdapter]] = {
} }
async def forge_config() -> dict: def build_adapter(
"""The instance's forge configuration, DB-first with env fallback. kind: str, base_url: str, token: str, *, transport=None
) -> ForgeAdapter | None:
"""One validated adapter from raw connection values, or None.
The env channel exists so a deployment can keep the token out of the None means "this connection cannot serve reads" — the same contract the
database entirely (Docker secret via FORGE_TOKEN_FILE) — the DB value wins old instance-wide lookup had, applied per keyring row. Misconfigurations
when both are present because the admin UI writes there, and a UI edit are logged, never raised: a bad row must not break the reads the good
that silently loses to an env var would look exactly like a broken form. rows can still serve.
""" """
return { kind = (kind or "").strip().lower()
"kind": (await get_admin_setting(FORGE_KIND_KEY, "") or Config.FORGE_KIND) base_url = (base_url or "").rstrip("/")
.strip() cls = _FORGE_CLASSES.get(kind)
.lower(),
"base_url": (
await get_admin_setting(FORGE_BASE_URL_KEY, "") or Config.FORGE_BASE_URL
).rstrip("/"),
"token": await get_admin_setting(FORGE_TOKEN_KEY, "") or Config.FORGE_TOKEN,
}
async def get_forge(*, transport=None) -> ForgeAdapter | None:
"""The configured forge adapter, or None — and None means "behave exactly
as if this module did not exist", which every consumer must honor."""
cfg = await forge_config()
cls = _FORGE_CLASSES.get(cfg["kind"])
if cls is None: if cls is None:
if cfg["kind"]: if kind:
# A kind we don't implement is a misconfiguration, not "off" — # A kind we don't implement is a misconfiguration, not "off" —
# say so once per lookup rather than silently reading as absent. # say so once per lookup rather than silently reading as absent.
logger.warning("unknown forge kind %r configured — forge disabled", cfg["kind"]) logger.warning("unknown forge kind %r configured — connection disabled", kind)
return None return None
if not cfg["base_url"] or not cfg["token"]: if not base_url or not token:
return None return None
if not cfg["base_url"].startswith(("http://", "https://")): if not base_url.startswith(("http://", "https://")):
logger.warning("forge base URL %r has no http(s) scheme — forge disabled", cfg["base_url"]) logger.warning("forge base URL %r has no http(s) scheme — connection disabled", base_url)
return None return None
return cls(cfg["base_url"], cfg["token"], transport=transport) return cls(base_url, token, transport=transport)
@dataclass(frozen=True)
class ForgeSelector:
"""The forge reads available to one project owner (#2778).
Consumers ask it to serve a REPO, not to hand over "the forge": resolve()
walks the owner's adapters and returns the (adapter, api_repo) pair for
the first one whose host serves the repo — or None, which every consumer
treats exactly as the old "no forge configured" state. An empty selector
IS rule #115's baseline.
"""
adapters: tuple[ForgeAdapter, ...] = ()
@property
def configured(self) -> bool:
return bool(self.adapters)
def resolve(self, repo_or_url: str) -> tuple[ForgeAdapter, str] | None:
for adapter in self.adapters:
repo = adapter.resolve_repo(repo_or_url)
if repo is not None:
return adapter, repo
return None
async def get_forges(
owner_id: int, project_id: int | None = None, *, transport=None
) -> ForgeSelector:
"""The forge selector for reads on behalf of ``owner_id``'s records.
The keyring model (#2778): every server-side forge read for a record runs
on the PROJECT OWNER's connections, resolved by repo host — a forge token
is a user's credential, and one user's reads must never ride another
user's token. Pass the record's ``project_id`` so the per-project pin
applies: a pinned project uses ONLY its pinned connection (explicit and
auditable); a pin that no longer belongs to the owner (ownership moved) is
ignored with a warning rather than honored across users.
The env config (FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) survives as an
implicit keyring entry for ADMIN owners only — it is the operator's
token, so it must not serve other users' reads — and a stored row for the
same host beats it, because the UI writes rows.
"""
from scribe.models import async_session
from scribe.models.forge_connection import ForgeConnection
from scribe.models.project import Project
from scribe.models.user import User
async with async_session() as session:
pinned_id = None
if project_id:
pinned_id = (
await session.execute(
select(Project.forge_connection_id).where(Project.id == project_id)
)
).scalar_one_or_none()
rows = list(
(
await session.execute(
select(ForgeConnection)
.where(ForgeConnection.user_id == owner_id)
.order_by(ForgeConnection.id)
)
).scalars().all()
)
role = ""
if Config.FORGE_KIND and Config.FORGE_BASE_URL and Config.FORGE_TOKEN:
role = (
await session.execute(select(User.role).where(User.id == owner_id))
).scalar_one_or_none() or ""
if pinned_id:
pin = next((r for r in rows if r.id == pinned_id), None)
if pin is not None:
adapter = build_adapter(pin.kind, pin.base_url, pin.token, transport=transport)
return ForgeSelector((adapter,) if adapter is not None else ())
logger.warning(
"project %s pins forge connection %s the owner (%s) does not hold — pin ignored",
project_id, pinned_id, owner_id,
)
adapters: list[ForgeAdapter] = []
for row in rows:
adapter = build_adapter(row.kind, row.base_url, row.token, transport=transport)
if adapter is not None:
adapters.append(adapter)
if role == "admin":
env = build_adapter(
Config.FORGE_KIND, Config.FORGE_BASE_URL, Config.FORGE_TOKEN,
transport=transport,
)
if env is not None and all(a.host != env.host for a in adapters):
adapters.append(env)
return ForgeSelector(tuple(adapters))
+194
View File
@@ -0,0 +1,194 @@
"""User-level forge connection CRUD — the keyring rows get_forges reads (#2778).
A connection is a user's read-only credential for one forge host; one row per
(user, host) keeps host-keyed resolution deterministic with no default-pointer
machinery. Everything here is own-rows-only: a connection is a credential, and
no caller — admin included — reads or edits another user's. The token never
leaves the server (model.to_dict omits it; routes mask "set/unset").
Validation matches what build_adapter will accept, checked here so a bad
value is a 400 at the form instead of a silently dead keyring row.
"""
from __future__ import annotations
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.forge_connection import ForgeConnection
from scribe.models.project import Project
from scribe.services.forge import FORGE_KINDS, host_of
def validate_connection(kind: str, base_url: str) -> str | None:
"""The error a connection's non-secret values would earn, or None."""
if kind not in FORGE_KINDS:
return f"Unknown forge kind {kind!r} (one of: {', '.join(FORGE_KINDS)})"
if not base_url.startswith(("http://", "https://")):
return "Forge base URL must use http or https"
if not host_of(base_url):
return "Forge base URL carries no hostname"
return None
async def list_connections(user_id: int) -> list[ForgeConnection]:
async with async_session() as session:
rows = await session.execute(
select(ForgeConnection)
.where(ForgeConnection.user_id == user_id)
.order_by(ForgeConnection.host)
)
return list(rows.scalars().all())
async def get_connection(user_id: int, connection_id: int) -> ForgeConnection | None:
async with async_session() as session:
return (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == user_id,
)
)
).scalar_one_or_none()
async def create_connection(
user_id: int, *, kind: str, base_url: str, token: str
) -> ForgeConnection:
"""Create a keyring row. Raises ValueError on bad values or a host the
user already holds — one row per (user, host) IS the resolution model,
so a second token for the same host is an update, not a create."""
kind = (kind or "").strip().lower()
base_url = (base_url or "").strip().rstrip("/")
token = token or ""
error = validate_connection(kind, base_url)
if error is None and not token:
error = "A token is required (read scope is enough)"
if error:
raise ValueError(error)
host = host_of(base_url)
async with async_session() as session:
existing = (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.user_id == user_id,
ForgeConnection.host == host,
)
)
).scalar_one_or_none()
if existing is not None:
raise ValueError(
f"You already have a connection for {host} — edit that one; "
"resolution is by host, so a second row could never be reached"
)
row = ForgeConnection(
user_id=user_id, kind=kind, base_url=base_url, host=host, token=token
)
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def update_connection(
user_id: int,
connection_id: int,
*,
kind: str = "",
base_url: str = "",
token: str = "",
) -> ForgeConnection | None:
"""Update own row; empty string = leave unchanged (the settings-form
sentinel convention). None when the row isn't the caller's."""
async with async_session() as session:
row = (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == user_id,
)
)
).scalar_one_or_none()
if row is None:
return None
new_kind = (kind or "").strip().lower() or row.kind
new_base = (base_url or "").strip().rstrip("/") or row.base_url
error = validate_connection(new_kind, new_base)
if error:
raise ValueError(error)
new_host = host_of(new_base)
if new_host != row.host:
clash = (
await session.execute(
select(ForgeConnection.id).where(
ForgeConnection.user_id == user_id,
ForgeConnection.host == new_host,
ForgeConnection.id != row.id,
)
)
).scalar_one_or_none()
if clash is not None:
raise ValueError(
f"You already have a connection for {new_host} — edit that one"
)
row.kind = new_kind
row.base_url = new_base
row.host = new_host
if token:
row.token = token
await session.commit()
await session.refresh(row)
return row
async def delete_connection(user_id: int, connection_id: int) -> bool:
"""Delete own row. Project pins pointing at it go NULL (FK SET NULL) —
those projects fall back to keyring resolution, which is the documented
unpinned behavior, not a surprise."""
async with async_session() as session:
row = (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == user_id,
)
)
).scalar_one_or_none()
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def set_project_pin(
owner_id: int, project_id: int, connection_id: int | None
) -> bool:
"""Point a project at one of its OWNER's connections, or clear the pin.
The caller settles WHO may ask (routes check owner-or-admin); this
settles WHOSE connection is eligible: only the project owner's — pinning
a collaborator's token to someone else's project is the confused-deputy
channel this feature exists to close. False = project or connection not
eligible.
"""
async with async_session() as session:
project = (
await session.execute(select(Project).where(Project.id == project_id))
).scalar_one_or_none()
if project is None or (project.user_id or 0) != owner_id:
return False
if connection_id:
held = (
await session.execute(
select(ForgeConnection.id).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == owner_id,
)
)
).scalar_one_or_none()
if held is None:
return False
project.forge_connection_id = connection_id or None
await session.commit()
return True
+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).

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