Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c915a6bc | ||
|
|
6a0f8ad328 | ||
|
|
d4c7b0e48d | ||
|
|
bfe5a461b4 | ||
|
|
3849c6fff3 | ||
|
|
1a8e5787e8 | ||
|
|
d01201539b | ||
|
|
1209e1c2d9 | ||
|
|
57d68c9355 | ||
|
|
1126bbe84f | ||
|
|
1ab614bfbe | ||
|
|
9abc4443fb | ||
|
|
3a4031d7f8 | ||
|
|
141246ac2c | ||
|
|
520381e22b | ||
|
|
58c074a324 | ||
|
|
2a6c55dacb | ||
|
|
7d48eb0b1b | ||
|
|
92e38ff17b | ||
|
|
64c641ce80 | ||
|
|
c211e12b61 | ||
|
|
b0eda32575 | ||
|
|
848ce1592e | ||
|
|
77bb3729a3 | ||
|
|
ac6f248bf9 | ||
|
|
bbee0d0db1 | ||
|
|
ac1c2625af | ||
|
|
dad56a51bd | ||
|
|
79f0283608 | ||
|
|
efc650390e | ||
|
|
d74a244b3a | ||
|
|
386b27e422 | ||
|
|
ba0030e51d | ||
|
|
a9e1cddba7 | ||
|
|
475f0857c9 | ||
|
|
9d5b89d785 | ||
|
|
3e21978a9c | ||
|
|
e2a084f1fb | ||
|
|
fd101d24ae | ||
|
|
9f1a52a035 | ||
|
|
5265d11a6a | ||
|
|
d50ebbad66 | ||
|
|
942edd1eb5 | ||
|
|
9d92df2825 | ||
|
|
9b1597a3c9 | ||
|
|
19fdc9aa89 |
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
|
||||
|
||||
Revision ID: 0084
|
||||
Revises: 0083
|
||||
Create Date: 2026-08-21
|
||||
|
||||
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
|
||||
a canon). But a shape can also CALL several canonical helpers — a service
|
||||
function that is an instance of the service-function convention and a
|
||||
consumer of hash_token. The 2026-08 audit had to pick one; hook evidence
|
||||
("pulled #N then wrote code referencing it") was stamped as instance when it
|
||||
is a uses fact. This table holds the many-valued relation: shape → snippet,
|
||||
with the basis and the evidence. Cascades with the shape and the snippet.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0084"
|
||||
down_revision = "0083"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shape_uses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("basis", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
|
||||
)
|
||||
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
|
||||
op.drop_table("code_shape_uses")
|
||||
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): 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> {
|
||||
const res = await fetch(path);
|
||||
return handleResponse<T>(res, path);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -221,3 +221,79 @@
|
||||
is the page's main action */
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -316,53 +316,6 @@
|
||||
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) ── */
|
||||
.inline-assist-btn {
|
||||
position: fixed;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
|
||||
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() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -212,7 +205,7 @@ onMounted(loadVersions);
|
||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||
class="history-item-label"
|
||||
>{{ 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>
|
||||
|
||||
|
||||
@@ -515,36 +515,4 @@ async function confirmDelete() {
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import type { TaskLog } from "@/types/task";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
const props = defineProps<{ taskId: number }>();
|
||||
|
||||
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
|
||||
const editContent = 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 {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
@@ -128,7 +122,7 @@ onMounted(loadLogs);
|
||||
</template>
|
||||
<template v-else>
|
||||
<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">
|
||||
{{ formatDuration(log.duration_minutes) }}
|
||||
</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
|
||||
const props = defineProps<{
|
||||
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(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
||||
watch(noteTags, () => { dirty.value = true; });
|
||||
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
>
|
||||
<div class="note-row-main">
|
||||
<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 v-if="note.tags?.length" class="note-row-tags">
|
||||
<span
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
|
||||
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);
|
||||
defineExpose({ reload: loadAll });
|
||||
</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 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 class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||
</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 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 class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
|
||||
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
|
||||
const days = Math.floor(hours / 24);
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||
* Shared date/time formatting — one rule per display shape. Views import
|
||||
* 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" })}`
|
||||
|
||||
if (_isSameDay(d, now)) return `Today${timeStr}`
|
||||
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
|
||||
export function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||
*/
|
||||
export function fmtDayLabel(dt: string): string {
|
||||
try {
|
||||
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, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
|
||||
export function fmtStamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
|
||||
export function fmtCompact(dt: string, allDay: boolean): string {
|
||||
const d = new Date(dt)
|
||||
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||
/** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
|
||||
export function fmtLogStamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, apiErrorMessage } from "@/api/client";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
const email = ref("");
|
||||
@@ -15,12 +15,7 @@ async function handleSubmit() {
|
||||
await apiPost("/api/auth/forgot-password", { email: email.value });
|
||||
submitted.value = true;
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Something went wrong";
|
||||
} else {
|
||||
error.value = "Something went wrong";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Something went wrong");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -55,7 +50,7 @@ async function handleSubmit() {
|
||||
</form>
|
||||
</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>Check your email and follow the instructions to reset your password.</p>
|
||||
</div>
|
||||
@@ -67,83 +62,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
}
|
||||
.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>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -30,12 +31,7 @@ async function handleSubmit() {
|
||||
const redirect = (route.query.redirect as string) || "/";
|
||||
router.push(redirect);
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Login failed";
|
||||
} else {
|
||||
error.value = "Login failed";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Login failed");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -112,70 +108,8 @@ function loginWithOAuth() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -190,15 +124,6 @@ function loginWithOAuth() {
|
||||
flex: 1;
|
||||
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 {
|
||||
text-align: right;
|
||||
margin: -0.5rem 0 0.75rem;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import { fmtLogStamp } from "@/utils/dateFormat";
|
||||
|
||||
const toastStore = useToastStore();
|
||||
|
||||
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
|
||||
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 {
|
||||
if (!details) return "";
|
||||
try {
|
||||
@@ -210,7 +200,7 @@ function clearFilters() {
|
||||
:class="{ 'row-expanded': expandedId === 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>
|
||||
<span class="category-badge" :class="'cat-' + entry.category">
|
||||
{{ entry.category }}
|
||||
|
||||
@@ -559,24 +559,8 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
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 {
|
||||
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;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -618,36 +602,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.modal-textarea {
|
||||
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) {
|
||||
.projects-grid {
|
||||
|
||||
@@ -424,16 +424,40 @@ async function loadNotes() {
|
||||
|
||||
interface CoverageGap {
|
||||
dir: string;
|
||||
uncovered: number;
|
||||
unclassified: number;
|
||||
total: number;
|
||||
}
|
||||
interface DeriveGroup {
|
||||
group: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
size: number;
|
||||
paths: string[];
|
||||
}
|
||||
interface Coverage {
|
||||
total: number;
|
||||
recorded: number;
|
||||
accounted: number;
|
||||
unclassified: number;
|
||||
counts: Record<string, number>;
|
||||
estimate: boolean;
|
||||
computed_at: string;
|
||||
repos: { repo: string; ref: string; total: number; recorded: number }[];
|
||||
repos: { repo: string; ref: string; total: number; accounted: number }[];
|
||||
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);
|
||||
@@ -712,32 +736,82 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-if="coverage">
|
||||
<div class="coverage-numbers">
|
||||
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span>
|
||||
<span class="coverage-label">shapes recorded</span>
|
||||
<span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
|
||||
<span class="coverage-label">shapes accounted for</span>
|
||||
</div>
|
||||
<div
|
||||
class="coverage-bar"
|
||||
role="progressbar"
|
||||
:aria-valuenow="coverage.recorded"
|
||||
:aria-valuenow="coverage.accounted"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="coverage.total"
|
||||
aria-label="Shapes with a recorded snippet"
|
||||
aria-label="Shapes classified against canon"
|
||||
>
|
||||
<div
|
||||
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 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">
|
||||
<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">
|
||||
{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="coverage-empty">
|
||||
Not measured yet — Refresh compares the bound repo's definitions
|
||||
against recorded snippets.
|
||||
Not measured yet — Refresh reads the bound repo's definitions into
|
||||
the shape ledger and reports how many are classified against canon.
|
||||
</p>
|
||||
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
||||
<!-- Forge pin (#2778): owner-only, because the eligible set is the
|
||||
@@ -1288,6 +1362,7 @@ async function confirmDelete() {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.coverage-gap-count { opacity: 0.65; }
|
||||
.coverage-divergent { border-color: var(--fs-warning); }
|
||||
.coverage-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
@@ -1740,31 +1815,6 @@ async function confirmDelete() {
|
||||
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); }
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
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 AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
@@ -68,12 +68,7 @@ async function handleSubmit() {
|
||||
await authStore.checkAuth();
|
||||
router.push("/");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Registration failed";
|
||||
} else {
|
||||
error.value = "Registration failed";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Registration failed");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -85,9 +80,9 @@ async function handleSubmit() {
|
||||
<div class="auth-card">
|
||||
<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 class="auth-footer">
|
||||
<router-link to="/login">Back to Sign In</router-link>
|
||||
@@ -157,103 +152,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
}
|
||||
.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>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
@@ -39,12 +40,7 @@ async function handleSubmit() {
|
||||
await authStore.register(username.value, password.value, email.value || undefined);
|
||||
router.push("/");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Registration failed";
|
||||
} else {
|
||||
error.value = "Registration failed";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Registration failed");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -56,9 +52,9 @@ async function handleSubmit() {
|
||||
<div class="auth-card">
|
||||
<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>Contact an administrator to get an account.</p>
|
||||
<p class="auth-footer">
|
||||
@@ -130,99 +126,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
}
|
||||
.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>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, apiErrorMessage } from "@/api/client";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -35,12 +35,7 @@ async function handleSubmit() {
|
||||
});
|
||||
success.value = true;
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Failed to reset password";
|
||||
} else {
|
||||
error.value = "Failed to reset password";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Failed to reset password");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -52,7 +47,7 @@ async function handleSubmit() {
|
||||
<div class="auth-card">
|
||||
<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 class="auth-footer">
|
||||
<router-link to="/forgot-password">Request new link</router-link>
|
||||
@@ -94,7 +89,7 @@ async function handleSubmit() {
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<div v-else class="success-msg">
|
||||
<div v-else class="auth-note">
|
||||
<p>Your password has been reset successfully.</p>
|
||||
<p>You can now sign in with your new password.</p>
|
||||
</div>
|
||||
@@ -106,102 +101,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
}
|
||||
.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>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -3,10 +3,11 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
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 PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
@@ -624,12 +625,7 @@ async function changeEmail() {
|
||||
emailPassword.value = "";
|
||||
toastStore.show("Email updated successfully");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
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");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to update email"), "error");
|
||||
} finally {
|
||||
changingEmail.value = false;
|
||||
}
|
||||
@@ -663,12 +659,7 @@ async function changePassword() {
|
||||
newPassword.value = "";
|
||||
confirmNewPassword.value = "";
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
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");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
@@ -766,12 +757,7 @@ async function sendTestEmail() {
|
||||
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
|
||||
toastStore.show("Test email sent successfully");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
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");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
|
||||
} finally {
|
||||
sendingTest.value = false;
|
||||
}
|
||||
@@ -1129,14 +1115,6 @@ function toggleLogExpand(id: number) {
|
||||
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 {
|
||||
if (!details) return "";
|
||||
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
|
||||
@@ -1224,12 +1202,6 @@ async function deleteUser(userId: number) {
|
||||
deleting.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUserDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -2355,8 +2327,8 @@ function formatUserDate(iso: string): string {
|
||||
<tbody>
|
||||
<tr v-for="inv in invitations" :key="inv.id">
|
||||
<td class="cell-email">{{ inv.email }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||||
@@ -2391,7 +2363,7 @@ function formatUserDate(iso: string): string {
|
||||
{{ u.role }}
|
||||
</span>
|
||||
</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">
|
||||
<template v-if="u.id === authStore.user?.id">
|
||||
<span class="you-label">You</span>
|
||||
@@ -2474,7 +2446,7 @@ function formatUserDate(iso: string): string {
|
||||
<tbody>
|
||||
<template v-for="entry in logs" :key="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>
|
||||
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
|
||||
</td>
|
||||
|
||||
@@ -903,23 +903,8 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -966,36 +951,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
color: var(--fs-text-tertiary);
|
||||
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) {
|
||||
.snippets-grid {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
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 { useToastStore } from "@/stores/toast";
|
||||
import type { User } from "@/types/auth";
|
||||
import { fmtDate } from "@/utils/dateFormat";
|
||||
|
||||
interface Invitation {
|
||||
id: number;
|
||||
@@ -69,12 +70,7 @@ async function sendInvite() {
|
||||
inviteEmail.value = "";
|
||||
await fetchInvitations();
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
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");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||||
} finally {
|
||||
sendingInvite.value = false;
|
||||
}
|
||||
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
|
||||
users.value = users.value.filter((u) => u.id !== userId);
|
||||
toastStore.show("User deleted");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
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");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -212,8 +195,8 @@ function formatDate(iso: string): string {
|
||||
<tbody>
|
||||
<tr v-for="inv in invitations" :key="inv.id">
|
||||
<td class="cell-email">{{ inv.email }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@@ -255,7 +238,7 @@ function formatDate(iso: string): string {
|
||||
{{ u.role }}
|
||||
</span>
|
||||
</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">
|
||||
<template v-if="u.id === authStore.user?.id">
|
||||
<span class="you-label">You</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.37",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
# on an instance with no forge connection (decision #2707). Everything else is
|
||||
# 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
|
||||
# the write proceeds untouched and Claude sees the note beside the tool result.
|
||||
# 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
|
||||
# because several per type is normal Rust, not duplication.
|
||||
# ---------------------------------------------------------------------------
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
||||
# kind<TAB>name for each thing this payload DEFINES.
|
||||
names=$(printf '%s' "$code" | awk '
|
||||
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
|
||||
# program, two consumers: the local duplicate arm (every definition in the
|
||||
# payload) and the ledger feed (#2791, below: the definitions being written,
|
||||
# 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,
|
||||
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
|
||||
}
|
||||
}
|
||||
' 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
|
||||
[ -n "${name:-}" ] || continue
|
||||
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}"
|
||||
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:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
@@ -230,7 +280,7 @@ fi
|
||||
# finding that needed no instance to produce.
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-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=""
|
||||
if [ -n "$body" ]; then
|
||||
|
||||
@@ -34,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that.
|
||||
to ask both at once.
|
||||
- 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.
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
A one-off written a second time is the cost this avoids — and at project
|
||||
|
||||
@@ -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.
|
||||
@@ -4,20 +4,6 @@ from __future__ import annotations
|
||||
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:
|
||||
"""Resolve a Bearer token to (user_id, scope).
|
||||
|
||||
@@ -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;
|
||||
create_system when the area is unmodelled.
|
||||
- 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
|
||||
optimisation, not the bridge.
|
||||
yourself at session start.
|
||||
- UI: the project's design system is binding — resolve_design_system /
|
||||
get_design_system_stylesheet before hand-writing a value.
|
||||
- REUSE: search snippets before writing a helper; record what you build with
|
||||
create_snippet. Saved procedures are Processes (follow verbatim). Deletes
|
||||
are trash-recoverable.
|
||||
create_snippet; classify shapes against canon (classify_shapes) — a
|
||||
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
|
||||
id — update it, don't force. shared:true records are another user's — a
|
||||
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 /
|
||||
# unbind_repo are the writes.
|
||||
"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
|
||||
|
||||
@@ -5,8 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
|
||||
systems, tags, tasks, trash,
|
||||
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, shapes,
|
||||
snippets, systems, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,5 +24,6 @@ def register_all(mcp) -> None:
|
||||
repos.register(mcp)
|
||||
processes.register(mcp)
|
||||
snippets.register(mcp)
|
||||
shapes.register(mcp)
|
||||
rulebooks.register(mcp)
|
||||
trash.register(mcp)
|
||||
|
||||
@@ -57,9 +57,7 @@ async def get_milestone(milestone_id: int) -> dict:
|
||||
return {
|
||||
"milestone": out,
|
||||
"steps": [t.to_dict() for t in steps],
|
||||
"applicable_rules": applicable["rules"],
|
||||
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||
"applicable_rules_truncated": applicable["truncated"],
|
||||
**rulebooks_svc.rules_payload(applicable),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -62,30 +62,6 @@ async def list_notes(
|
||||
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:
|
||||
"""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
|
||||
# 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")
|
||||
await _attach_supersession(uid, note_id, out)
|
||||
await supersession_svc.attach_relations(uid, note_id, out, hint=True)
|
||||
await systems_tools.attach_systems(
|
||||
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
|
||||
data = note.to_dict()
|
||||
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
|
||||
|
||||
|
||||
@@ -237,7 +213,7 @@ async def update_note(
|
||||
await systems_tools.attach_systems(
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from scribe.services import projects as projects_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.background import spawn
|
||||
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 —
|
||||
plus systems_bootstrap, present only when it applies (see below).
|
||||
|
||||
`pattern_coverage` (usually null) is a one-line estimate of how much of
|
||||
the bound repo's code has recorded snippets — e.g. "pattern-library
|
||||
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api".
|
||||
When present, treat the gaps as a standing invitation: as you touch code
|
||||
in those areas, record the shapes you find with create_snippet.
|
||||
`pattern_coverage` (usually null) is the shape-accounting line — how many
|
||||
of the bound repo's extracted shapes carry a classification against canon
|
||||
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
|
||||
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
|
||||
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
|
||||
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.
|
||||
|
||||
`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
|
||||
vocabulary from the areas the project's records name, confirm it with the
|
||||
operator, and create_system the confirmed set. It stops appearing the
|
||||
moment the first System exists.
|
||||
Systems at all — act on it before starting other work: create_system a
|
||||
starter vocabulary from the areas the project's records name, directly
|
||||
and without asking permission, preferring the standard names the ask
|
||||
lists. It stops appearing the moment the first System exists.
|
||||
|
||||
`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
|
||||
@@ -126,7 +132,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
|
||||
# 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
|
||||
# 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);
|
||||
# both retire the instant the first System exists.
|
||||
systems_bootstrap = None
|
||||
@@ -161,6 +167,16 @@ async def enter_project(project_id: int) -> dict:
|
||||
coverage = await coverage_svc.cached_coverage(
|
||||
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 = {
|
||||
"project": project.to_dict(),
|
||||
@@ -176,12 +192,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
],
|
||||
"design_system": design_system,
|
||||
"milestone_summary": milestone_summary,
|
||||
"applicable_rules": applicable["rules"],
|
||||
"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"],
|
||||
**rulebooks_svc.rules_payload(applicable),
|
||||
"open_tasks": [
|
||||
{
|
||||
"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(
|
||||
project_id=project_id, user_id=uid,
|
||||
)
|
||||
data["applicable_rules"] = applicable["rules"]
|
||||
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", [])
|
||||
data.update(rulebooks_svc.rules_payload(applicable))
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -13,28 +13,43 @@ from scribe.services import projects as projects_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.
|
||||
|
||||
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
|
||||
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:
|
||||
repo_url: the repo's git remote (e.g. the output of
|
||||
`git remote get-url origin` — ssh or https form, both work).
|
||||
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()
|
||||
project = await projects_svc.get_project(uid, project_id)
|
||||
if project is None:
|
||||
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 {
|
||||
"repo_key": binding.repo_key,
|
||||
"project_id": binding.project_id,
|
||||
"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}."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -193,6 +193,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
|
||||
|
||||
# ── 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(
|
||||
rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
|
||||
) -> dict:
|
||||
@@ -213,16 +219,7 @@ async def list_rules(
|
||||
topic_id=topic_id or None,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
return {
|
||||
"rules": [
|
||||
{
|
||||
"id": r.id, "title": r.title, "statement": r.statement,
|
||||
"topic_id": r.topic_id,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"total": len(rows),
|
||||
}
|
||||
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
|
||||
|
||||
|
||||
async def list_always_on_rules() -> dict:
|
||||
@@ -235,16 +232,7 @@ async def list_always_on_rules() -> dict:
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.list_always_on_rules(uid)
|
||||
return {
|
||||
"rules": [
|
||||
{
|
||||
"id": r.id, "title": r.title, "statement": r.statement,
|
||||
"topic_id": r.topic_id,
|
||||
}
|
||||
for r in rules
|
||||
],
|
||||
"total": len(rules),
|
||||
}
|
||||
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
|
||||
|
||||
|
||||
async def get_rule(rule_id: int) -> dict:
|
||||
|
||||
@@ -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)
|
||||
@@ -207,6 +207,12 @@ async def get_snippet(snippet_id: int) -> dict:
|
||||
the source moved on — trust the location over the cached body and
|
||||
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
|
||||
`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
|
||||
@@ -229,6 +235,20 @@ async def get_snippet(snippet_id: int) -> dict:
|
||||
await systems_tools.attach_systems(
|
||||
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
|
||||
|
||||
|
||||
@@ -336,6 +356,13 @@ async def verify_snippet(
|
||||
"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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -23,6 +23,18 @@ from scribe.services import systems as systems_svc
|
||||
_BOOTSTRAP_MIN_RECORDS = 20
|
||||
_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:
|
||||
"""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
|
||||
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
|
||||
count and the recent titles, and asks for a concrete deliverable: propose
|
||||
a starter set, confirm, create.
|
||||
count and the recent titles, and asks for a concrete deliverable: 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
|
||||
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 (
|
||||
f"This project has {total} records and NO Systems modelled — none of "
|
||||
"them can be tagged to an area, so recurring problem-spots stay "
|
||||
"invisible. Bootstrap the vocabulary now, in this session: from the "
|
||||
f"areas the records themselves name (recent: {titles}), propose 3-6 "
|
||||
"Systems to the operator, create_system each confirmed one with a "
|
||||
"one-paragraph charter, then tag this record (system_ids=[...]). "
|
||||
"This ask repeats until the first System exists; answering it once "
|
||||
"retires it for every future record."
|
||||
"invisible. Bootstrap the vocabulary now, in this session, without "
|
||||
"asking permission — creating Systems is your call, not an approval "
|
||||
f"flow. From the areas the records themselves name (recent: {titles}), "
|
||||
"create_system 3-6 Systems, each with a one-paragraph charter, then "
|
||||
"tag this record (system_ids=[...]). Where an area fits a standard "
|
||||
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
|
||||
affiliation visible on read, not just settable on write), an untagged
|
||||
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
|
||||
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
|
||||
@@ -148,11 +165,16 @@ async def create_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
|
||||
name an area that plainly exists in the code; an unmodelled area means
|
||||
every record about it stays untaggable. An audit or sweep that walks the
|
||||
codebase is a DISCOVERY 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. Give each one a one-paragraph
|
||||
name an area that plainly exists in the code, and don't route the
|
||||
creation through operator approval — minting vocabulary is the agent's
|
||||
call (#2798); an unmodelled area means every record about it stays
|
||||
untaggable. An audit or sweep that walks the codebase is a DISCOVERY
|
||||
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
|
||||
whether a record belongs here.
|
||||
|
||||
|
||||
@@ -97,12 +97,7 @@ async def get_task(task_id: int) -> dict:
|
||||
applicable = await rulebooks_svc.get_applicable_rules(
|
||||
project_id=note.project_id, user_id=uid,
|
||||
)
|
||||
data["applicable_rules"] = applicable["rules"]
|
||||
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(rulebooks_svc.rules_payload(applicable))
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# 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
|
||||
|
||||
@@ -44,5 +44,6 @@ from scribe.models.rulebook import ( # 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.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
|
||||
class ApiKey(Base, CreatedAtMixin):
|
||||
@@ -36,7 +36,7 @@ class ApiKey(Base, CreatedAtMixin):
|
||||
"name": self.name,
|
||||
"key_prefix": self.key_prefix,
|
||||
"scope": self.scope,
|
||||
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
|
||||
"last_used_at": iso(self.last_used_at),
|
||||
"created_at": iso(self.created_at),
|
||||
"revoked_at": iso(self.revoked_at),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import DateTime, Float, Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import iso
|
||||
|
||||
|
||||
class AppLog(Base):
|
||||
@@ -20,6 +21,9 @@ class AppLog(Base):
|
||||
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ip_address: 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(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -44,5 +48,5 @@ class AppLog(Base):
|
||||
"duration_ms": self.duration_ms,
|
||||
"ip_address": self.ip_address,
|
||||
"details": self.details,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Text
|
||||
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:
|
||||
"""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."""
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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):
|
||||
@@ -56,8 +56,8 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"description": self.description or "",
|
||||
"guidance": self.guidance or "",
|
||||
"parent_id": self.parent_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,6 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"rationale": self.rationale,
|
||||
"supersedes": self.supersedes or [],
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
from scribe.models.base import TimestampMixin, iso
|
||||
|
||||
|
||||
class ForgeConnection(Base, TimestampMixin):
|
||||
@@ -40,6 +40,6 @@ class ForgeConnection(Base, TimestampMixin):
|
||||
"kind": self.kind,
|
||||
"base_url": self.base_url,
|
||||
"host": self.host,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin, TimestampMixin
|
||||
from scribe.models.base import CreatedAtMixin, TimestampMixin, iso
|
||||
|
||||
|
||||
class Group(Base, TimestampMixin):
|
||||
@@ -27,8 +27,8 @@ class Group(Base, TimestampMixin):
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -53,5 +53,5 @@ class GroupMembership(Base, CreatedAtMixin):
|
||||
"group_id": self.group_id,
|
||||
"user_id": self.user_id,
|
||||
"role": self.role,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class InvitationToken(Base):
|
||||
class InvitationToken(Base, CreatedAtMixin):
|
||||
__tablename__ = "invitation_tokens"
|
||||
|
||||
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)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), 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__ = (
|
||||
Index("ix_invitation_tokens_token_hash", "token_hash"),
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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):
|
||||
@@ -30,6 +30,6 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"body": self.body,
|
||||
"status": self.status,
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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):
|
||||
@@ -105,18 +105,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"milestone_id": self.milestone_id,
|
||||
"status": self.status,
|
||||
"priority": self.priority,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"due_date": iso(self.due_date),
|
||||
"started_at": iso(self.started_at),
|
||||
"completed_at": iso(self.completed_at),
|
||||
"recurrence_rule": self.recurrence_rule,
|
||||
"recurrence_next_spawn_at": (
|
||||
self.recurrence_next_spawn_at.isoformat()
|
||||
if self.recurrence_next_spawn_at
|
||||
else None
|
||||
),
|
||||
"recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
|
||||
"is_task": self.is_task,
|
||||
"note_type": self.note_type or "note",
|
||||
"task_kind": self.task_kind,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import TimestampMixin
|
||||
from scribe.models.base import TimestampMixin, iso
|
||||
|
||||
|
||||
class NoteDraft(Base, TimestampMixin):
|
||||
@@ -25,6 +25,6 @@ class NoteDraft(Base, TimestampMixin):
|
||||
"original_body": self.original_body,
|
||||
"instruction": self.instruction,
|
||||
"scope": self.scope,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
|
||||
class NoteSupersession(Base, CreatedAtMixin):
|
||||
@@ -66,5 +66,5 @@ class NoteSupersession(Base, CreatedAtMixin):
|
||||
"id": self.id,
|
||||
"superseder_id": self.superseder_id,
|
||||
"superseded_id": self.superseded_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, Text
|
||||
from sqlalchemy import Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
SURFACED = "surfaced"
|
||||
PULLED = "pulled"
|
||||
|
||||
|
||||
class NoteUsageEvent(Base):
|
||||
class NoteUsageEvent(Base, CreatedAtMixin):
|
||||
"""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
|
||||
@@ -44,9 +43,6 @@ class NoteUsageEvent(Base):
|
||||
__tablename__ = "note_usage_events"
|
||||
|
||||
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)
|
||||
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 'surfaced' | 'pulled'
|
||||
@@ -81,7 +77,7 @@ class NoteUsageEvent(Base):
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"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,
|
||||
"note_id": self.note_id,
|
||||
"event": self.event,
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
|
||||
class NoteVersion(Base, CreatedAtMixin):
|
||||
@@ -26,7 +26,7 @@ class NoteVersion(Base, CreatedAtMixin):
|
||||
"tags": self.tags or [],
|
||||
"pin_kind": self.pin_kind,
|
||||
"pin_label": self.pin_label,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
if include_body:
|
||||
d["body"] = self.body
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
|
||||
class Notification(Base, CreatedAtMixin):
|
||||
@@ -26,6 +26,6 @@ class Notification(Base, CreatedAtMixin):
|
||||
"user_id": self.user_id,
|
||||
"type": self.type,
|
||||
"payload": self.payload,
|
||||
"read_at": self.read_at.isoformat() if self.read_at else None,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"read_at": iso(self.read_at),
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
class PasswordResetToken(Base, CreatedAtMixin):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
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)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), 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__ = (
|
||||
Index("ix_password_reset_tokens_token_hash", "token_hash"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
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):
|
||||
@@ -48,6 +48,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"color": self.color,
|
||||
"design_system_id": self.design_system_id,
|
||||
"forge_connection_id": self.forge_connection_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
from scribe.models.base import TimestampMixin, iso
|
||||
|
||||
|
||||
class RepoBinding(Base, TimestampMixin):
|
||||
@@ -28,6 +28,10 @@ class RepoBinding(Base, TimestampMixin):
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), 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:
|
||||
return {
|
||||
@@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin):
|
||||
"user_id": self.user_id,
|
||||
"project_id": self.project_id,
|
||||
"repo_key": self.repo_key,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"ref": self.ref,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import iso
|
||||
|
||||
|
||||
class RetrievalLog(Base):
|
||||
@@ -23,6 +24,9 @@ class RetrievalLog(Base):
|
||||
__tablename__ = "retrieval_logs"
|
||||
|
||||
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(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -54,7 +58,7 @@ class RetrievalLog(Base):
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"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,
|
||||
"source": self.source,
|
||||
"query": self.query,
|
||||
|
||||
@@ -4,10 +4,10 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index,
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
@@ -19,14 +19,6 @@ class Rulebook(Base, SoftDeleteMixin):
|
||||
always_on: Mapped[bool] = mapped_column(
|
||||
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:
|
||||
return {
|
||||
@@ -35,12 +27,12 @@ class Rulebook(Base, SoftDeleteMixin):
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"always_on": self.always_on,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class RulebookTopic(Base, SoftDeleteMixin):
|
||||
class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "rulebook_topics"
|
||||
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
|
||||
# trashed topic doesn't block recreating/restoring the same title.
|
||||
@@ -58,14 +50,6 @@ class RulebookTopic(Base, SoftDeleteMixin):
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
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:
|
||||
return {
|
||||
@@ -74,12 +58,12 @@ class RulebookTopic(Base, SoftDeleteMixin):
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Rule(Base, SoftDeleteMixin):
|
||||
class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "rules"
|
||||
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
|
||||
# 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)
|
||||
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
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:
|
||||
return {
|
||||
@@ -128,8 +104,8 @@ class Rule(Base, SoftDeleteMixin):
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import TimestampMixin
|
||||
from scribe.models.base import TimestampMixin, iso
|
||||
|
||||
|
||||
class ProjectShare(Base, TimestampMixin):
|
||||
@@ -37,8 +37,8 @@ class ProjectShare(Base, TimestampMixin):
|
||||
"shared_with_group_id": self.shared_with_group_id,
|
||||
"permission": self.permission,
|
||||
"invited_by": self.invited_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,6 @@ class NoteShare(Base, TimestampMixin):
|
||||
"shared_with_group_id": self.shared_with_group_id,
|
||||
"permission": self.permission,
|
||||
"invited_by": self.invited_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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):
|
||||
@@ -44,8 +44,8 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"color": self.color,
|
||||
"status": self.status,
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import TimestampMixin
|
||||
from scribe.models.base import TimestampMixin, iso
|
||||
|
||||
|
||||
class TaskLog(Base, TimestampMixin):
|
||||
@@ -21,6 +21,6 @@ class TaskLog(Base, TimestampMixin):
|
||||
"user_id": self.user_id,
|
||||
"content": self.content,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
|
||||
class User(Base, CreatedAtMixin):
|
||||
@@ -26,6 +26,6 @@ class User(Base, CreatedAtMixin):
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"created_at": iso(self.created_at),
|
||||
"has_password": self.password_hash is not None,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_conf
|
||||
from scribe.services.logging import get_logs, get_log_stats, log_audit
|
||||
from scribe.services.notifications import send_invitation_email
|
||||
from scribe.services.settings import (
|
||||
SECRET_MASK,
|
||||
get_admin_setting,
|
||||
set_admin_setting,
|
||||
set_setting,
|
||||
@@ -116,7 +117,7 @@ async def get_smtp():
|
||||
config = await get_smtp_config()
|
||||
# Mask password
|
||||
if config.get("smtp_password"):
|
||||
config["smtp_password"] = "********"
|
||||
config["smtp_password"] = SECRET_MASK
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@@ -130,7 +131,7 @@ async def update_smtp():
|
||||
for key in SMTP_SETTING_KEYS:
|
||||
if key in data:
|
||||
# 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
|
||||
settings_to_save[key] = str(data[key])
|
||||
|
||||
@@ -157,9 +158,6 @@ async def test_smtp():
|
||||
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.
|
||||
@@ -178,7 +176,7 @@ async def get_forge_webhook_settings():
|
||||
return jsonify({
|
||||
# Secrets never leave the server — the smtp_password convention:
|
||||
# masked when set, empty when not.
|
||||
"webhook_secret": _TOKEN_MASK if webhook_secret else "",
|
||||
"webhook_secret": SECRET_MASK if webhook_secret else "",
|
||||
})
|
||||
|
||||
|
||||
@@ -192,7 +190,7 @@ async def update_forge_webhook_settings():
|
||||
webhook_secret = data.get("webhook_secret")
|
||||
# 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 != _TOKEN_MASK:
|
||||
if webhook_secret is not None and webhook_secret != SECRET_MASK:
|
||||
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
|
||||
# The secret is deliberately absent from the audit detail.
|
||||
await log_audit(
|
||||
|
||||
@@ -15,9 +15,10 @@ one and returns None for the other:
|
||||
those two IS the intent: distinguishing them would confirm the existence of
|
||||
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.design_starter_roles import (
|
||||
DEFAULT_TOKEN_PREFIX,
|
||||
@@ -28,12 +29,6 @@ from scribe.services.design_systems import DesignSystemCycle
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -43,7 +38,7 @@ def _not_found(what: str = "design system"):
|
||||
async def list_design_systems():
|
||||
"""The caller's design systems. An empty list is the ordinary state for an
|
||||
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]})
|
||||
|
||||
|
||||
@@ -55,7 +50,7 @@ async def create_design_system():
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
system = await ds_svc.create_design_system(
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
title=title,
|
||||
description=data.get("description") 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>")
|
||||
@login_required
|
||||
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:
|
||||
return _not_found()
|
||||
return not_found("Design system")
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@@ -102,19 +97,19 @@ async def update_design_system(design_system_id: int):
|
||||
if "parent_id" in data:
|
||||
fields["parent_id"] = data["parent_id"]
|
||||
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:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return not_found("Design system")
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def delete_design_system(design_system_id: int):
|
||||
if not await ds_svc.delete_design_system(_uid(), design_system_id):
|
||||
return _not_found()
|
||||
if not await ds_svc.delete_design_system(get_current_user_id(), design_system_id):
|
||||
return not_found("Design system")
|
||||
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
|
||||
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:
|
||||
return _not_found()
|
||||
return not_found("Design system")
|
||||
return jsonify({
|
||||
"design_system_id": design_system_id,
|
||||
"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 = (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:
|
||||
return _not_found()
|
||||
return not_found("Design system")
|
||||
if request.args.get("format") == "css":
|
||||
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
|
||||
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
|
||||
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:
|
||||
return _not_found()
|
||||
return not_found("Design system")
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -181,9 +176,9 @@ async def check_snippets_against_system(design_system_id: int):
|
||||
@login_required
|
||||
async def list_design_tokens(design_system_id: int):
|
||||
"""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:
|
||||
return _not_found()
|
||||
rows = await ds_svc.list_tokens(_uid(), design_system_id)
|
||||
if await ds_svc.get_design_system(get_current_user_id(), design_system_id) is None:
|
||||
return not_found("Design system")
|
||||
rows = await ds_svc.list_tokens(get_current_user_id(), design_system_id)
|
||||
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:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
token = await ds_svc.create_token(
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
design_system_id=design_system_id,
|
||||
name=name,
|
||||
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,
|
||||
)
|
||||
if token is None:
|
||||
return _not_found()
|
||||
return not_found("Design system")
|
||||
return jsonify(token.to_dict()), 201
|
||||
|
||||
|
||||
@@ -221,17 +216,17 @@ async def update_design_token(token_id: int):
|
||||
"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:
|
||||
return _not_found("design token")
|
||||
return not_found("Design token")
|
||||
return jsonify(token.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def delete_design_token(token_id: int):
|
||||
if not await ds_svc.delete_token(_uid(), token_id):
|
||||
return _not_found("design token")
|
||||
if not await ds_svc.delete_token(get_current_user_id(), token_id):
|
||||
return not_found("Design token")
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -247,9 +242,9 @@ async def set_project_design_system(project_id: int):
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
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:
|
||||
return _not_found("project or design system")
|
||||
return not_found("Project or design system")
|
||||
return jsonify({"project_id": project_id,
|
||||
"design_system_id": data.get("design_system_id")})
|
||||
|
||||
@@ -26,22 +26,6 @@ from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import supersession as supersession_svc
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,7 +126,7 @@ async def create_note_route():
|
||||
# may not write the target. The note itself was created.
|
||||
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
|
||||
out = note.to_dict()
|
||||
await _attach_supersession(uid, note.id, out)
|
||||
await supersession_svc.attach_relations(uid, note.id, out)
|
||||
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
|
||||
# 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")
|
||||
await _attach_supersession(uid, note_id, data)
|
||||
await supersession_svc.attach_relations(uid, note_id, data)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["PUT"])
|
||||
@notes_bp.route("/<int:note_id>", methods=["PUT", "PATCH"])
|
||||
@login_required
|
||||
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()
|
||||
# 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.
|
||||
@@ -290,44 +278,10 @@ async def update_note_route(note_id: int):
|
||||
except PermissionError as exc:
|
||||
return jsonify({"error": str(exc)}), 403
|
||||
out = note.to_dict()
|
||||
await _attach_supersession(uid, note_id, out)
|
||||
await supersession_svc.attach_relations(uid, note_id, 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"])
|
||||
@login_required
|
||||
|
||||
+70
-49
@@ -24,6 +24,35 @@ plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
|
||||
_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")
|
||||
@login_required
|
||||
async def session_context():
|
||||
@@ -37,20 +66,7 @@ async def session_context():
|
||||
project_id (optional int) — explicit override, mainly for manual/ad-hoc
|
||||
curl testing; takes precedence over `repo` when set.
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
|
||||
project_id, _repo, unbound_repo = await _project_scope()
|
||||
result = await plugin_ctx_svc.build_session_context(
|
||||
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.
|
||||
"""
|
||||
q = (request.args.get("q") or "").strip()
|
||||
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()
|
||||
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()
|
||||
]
|
||||
|
||||
project_id, _repo, _unbound = await _project_scope()
|
||||
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
||||
result = await plugin_ctx_svc.build_autoinject_hint(
|
||||
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
|
||||
hint shown early must not suppress the record-sync
|
||||
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()
|
||||
code = request.args.get("code") or ""
|
||||
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()
|
||||
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()
|
||||
]
|
||||
project_id, repo, _unbound = await _project_scope()
|
||||
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
||||
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
|
||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||
|
||||
result = await plugin_ctx_svc.build_write_path_hint(
|
||||
g.user.id, path, code=code, project_id=project_id,
|
||||
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)
|
||||
|
||||
|
||||
# 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")
|
||||
@login_required
|
||||
async def process_manifest():
|
||||
|
||||
@@ -123,7 +123,7 @@ async def delete_project_route(project_id: int):
|
||||
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
|
||||
@login_required
|
||||
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
|
||||
sense; `coverage` is null until something has computed it (a webhook
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
"""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.
|
||||
"""
|
||||
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
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
|
||||
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
# ── Rulebooks ───────────────────────────────────────────────────────────
|
||||
|
||||
@rulebooks_bp.get("/rulebooks")
|
||||
@login_required
|
||||
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]})
|
||||
|
||||
|
||||
@@ -35,7 +32,7 @@ async def create_rulebook():
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
rb = await rulebooks_svc.create_rulebook(
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
title=title,
|
||||
description=data.get("description", ""),
|
||||
)
|
||||
@@ -45,7 +42,7 @@ async def create_rulebook():
|
||||
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
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:
|
||||
return jsonify({"error": "rulebook not found"}), 404
|
||||
return jsonify(rb.to_dict())
|
||||
@@ -56,7 +53,7 @@ async def get_rulebook(rulebook_id: int):
|
||||
async def update_rulebook(rulebook_id: int):
|
||||
data = await request.get_json() or {}
|
||||
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:
|
||||
return jsonify({"error": "rulebook not found"}), 404
|
||||
return jsonify(rb.to_dict())
|
||||
@@ -65,7 +62,7 @@ async def update_rulebook(rulebook_id: int):
|
||||
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
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
|
||||
|
||||
|
||||
@@ -75,7 +72,7 @@ async def delete_rulebook(rulebook_id: int):
|
||||
@login_required
|
||||
async def list_topics(rulebook_id: int):
|
||||
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:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify({"topics": [t.to_dict() for t in rows]})
|
||||
@@ -91,7 +88,7 @@ async def create_topic(rulebook_id: int):
|
||||
try:
|
||||
topic = await rulebooks_svc.create_topic(
|
||||
rulebook_id=rulebook_id,
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
title=title,
|
||||
description=data.get("description", ""),
|
||||
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()
|
||||
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:
|
||||
return jsonify({"error": "topic not found"}), 404
|
||||
return jsonify(topic.to_dict())
|
||||
@@ -118,7 +115,7 @@ async def update_topic(topic_id: int):
|
||||
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
|
||||
@login_required
|
||||
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 "", 204
|
||||
|
||||
@@ -140,7 +137,7 @@ async def list_rules():
|
||||
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
|
||||
|
||||
rows = await rulebooks_svc.list_rules(
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
rulebook_id=rulebook_id,
|
||||
topic_id=topic_id,
|
||||
project_id=project_id,
|
||||
@@ -159,7 +156,7 @@ async def create_rule(topic_id: int):
|
||||
try:
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic_id=topic_id,
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
title=title,
|
||||
statement=statement,
|
||||
why=data.get("why", ""),
|
||||
@@ -174,7 +171,7 @@ async def create_rule(topic_id: int):
|
||||
@rulebooks_bp.get("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
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:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(rule.to_dict())
|
||||
@@ -188,7 +185,7 @@ async def update_rule(rule_id: int):
|
||||
k: v for k, v in data.items()
|
||||
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:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(rule.to_dict())
|
||||
@@ -197,7 +194,7 @@ async def update_rule(rule_id: int):
|
||||
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
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 "", 204
|
||||
|
||||
@@ -213,7 +210,7 @@ async def subscribe_project(project_id: int):
|
||||
return jsonify({"error": "rulebook_id is required"}), 400
|
||||
try:
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
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:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
@@ -238,7 +235,7 @@ async def unsubscribe_project(project_id: int, rulebook_id: int):
|
||||
@login_required
|
||||
async def get_project_rules(project_id: int):
|
||||
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)
|
||||
|
||||
@@ -248,7 +245,7 @@ async def get_project_rules(project_id: int):
|
||||
async def suppress_project_rule(project_id: int, rule_id: int):
|
||||
try:
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
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:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
@@ -303,7 +300,7 @@ async def create_project_rule(project_id: int):
|
||||
try:
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id,
|
||||
user_id=_uid(),
|
||||
user_id=get_current_user_id(),
|
||||
title=title,
|
||||
statement=statement,
|
||||
why=data.get("why", ""),
|
||||
|
||||
@@ -9,7 +9,9 @@ from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
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__)
|
||||
|
||||
@@ -22,12 +24,11 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
# rows live on the admin's own user_id, so the plain GET returned them raw.
|
||||
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
|
||||
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
|
||||
_SECRET_MASK = "********"
|
||||
|
||||
|
||||
def _masked(settings: dict) -> dict:
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ async def update_settings_route():
|
||||
str_v = str(v)
|
||||
# A masked secret round-tripping through a client is "unchanged", not
|
||||
# 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
|
||||
if not str_v:
|
||||
await delete_setting(uid, k)
|
||||
@@ -127,7 +128,7 @@ async def update_forge_connection_route(connection_id: int):
|
||||
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:
|
||||
if token == SECRET_MASK:
|
||||
token = ""
|
||||
try:
|
||||
row = await update_connection(
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
"""Trash REST API — list / restore / purge soft-deleted content by batch."""
|
||||
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
|
||||
|
||||
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
@trash_bp.get("")
|
||||
@login_required
|
||||
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")
|
||||
@login_required
|
||||
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})
|
||||
|
||||
|
||||
@trash_bp.delete("/<batch_id>")
|
||||
@login_required
|
||||
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})
|
||||
|
||||
@@ -13,8 +13,11 @@ def generate_key() -> str:
|
||||
return "fmcp_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _hash_key(key: str) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
def hash_token(raw: str) -> str:
|
||||
"""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:
|
||||
@@ -32,7 +35,7 @@ async def create_api_key(
|
||||
key = ApiKey(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
key_hash=_hash_key(full_key),
|
||||
key_hash=hash_token(full_key),
|
||||
key_prefix=_key_prefix(full_key),
|
||||
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:
|
||||
"""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:
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(
|
||||
|
||||
+16
-28
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
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.setting import Setting
|
||||
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__)
|
||||
|
||||
@@ -142,16 +143,7 @@ async def is_registration_open() -> bool:
|
||||
user_count = await get_user_count()
|
||||
if user_count == 0:
|
||||
return 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
|
||||
return await get_admin_setting("registration_open", "false") == "true"
|
||||
|
||||
|
||||
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:
|
||||
"""Generate a password reset token. Returns the raw token (for the email link)."""
|
||||
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)
|
||||
|
||||
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:
|
||||
"""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:
|
||||
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:
|
||||
"""Generate an invitation token. Returns the raw token (for the email link)."""
|
||||
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)
|
||||
|
||||
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:
|
||||
"""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:
|
||||
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:
|
||||
"""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:
|
||||
result = await session.execute(
|
||||
@@ -398,20 +390,16 @@ async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
async def _auth_token_retention_loop() -> None:
|
||||
import asyncio
|
||||
while True:
|
||||
await asyncio.sleep(86400) # daily
|
||||
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")
|
||||
async def _auth_token_retention_tick() -> None:
|
||||
removed = await purge_expired_auth_tokens()
|
||||
if removed:
|
||||
logger.info("Auth token retention: deleted %d expired token(s)", removed)
|
||||
|
||||
|
||||
def start_auth_token_retention_loop() -> None:
|
||||
global _auth_retention_task
|
||||
import asyncio
|
||||
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
|
||||
)
|
||||
|
||||
@@ -45,6 +45,26 @@ def spawn(coro: Coroutine, *, site: str) -> None:
|
||||
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:
|
||||
"""Await everything in flight — for tests that need the writes landed."""
|
||||
while _pending:
|
||||
|
||||
+296
-273
@@ -11,6 +11,7 @@ from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
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.repo_binding import RepoBinding
|
||||
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:
|
||||
# the table shipped without a backup section and the coverage test failed the
|
||||
# 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.
|
||||
BACKUP_VERSION = 6
|
||||
BACKUP_VERSION = 9
|
||||
|
||||
# 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
|
||||
@@ -55,6 +64,8 @@ _BACKED_UP = [
|
||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"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
|
||||
@@ -166,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]:
|
||||
return [
|
||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
||||
@@ -173,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -205,6 +364,13 @@ async def export_full_backup() -> dict:
|
||||
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||
usage_events = (await session.execute(select(NoteUsageEvent))).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()
|
||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||
rules = (await session.execute(select(Rule))).scalars().all()
|
||||
@@ -227,148 +393,17 @@ async def export_full_backup() -> dict:
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"_not_included": _NOT_INCLUDED,
|
||||
"users": [
|
||||
{
|
||||
"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 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
|
||||
],
|
||||
"users": _user_rows(users),
|
||||
"projects": _project_rows(projects),
|
||||
"milestones": _milestone_rows(milestones),
|
||||
"notes": _note_rows(notes),
|
||||
"task_logs": _task_log_rows(task_logs),
|
||||
"note_drafts": _note_draft_rows(note_drafts),
|
||||
"note_versions": _note_version_rows(note_versions),
|
||||
"settings": _setting_rows(settings),
|
||||
"rulebooks": _rulebook_rows(rulebooks),
|
||||
"rulebook_topics": _topic_rows(topics),
|
||||
"rules": _rule_rows(rules),
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
@@ -379,6 +414,9 @@ async def export_full_backup() -> dict:
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -446,6 +484,20 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
repo_bindings = (await session.execute(
|
||||
select(RepoBinding).where(RepoBinding.user_id == user_id)
|
||||
)).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(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||
)).scalars().all()
|
||||
@@ -495,135 +547,16 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"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
|
||||
],
|
||||
"projects": _project_rows(projects),
|
||||
"milestones": _milestone_rows(milestones),
|
||||
"notes": _note_rows(notes),
|
||||
"task_logs": _task_log_rows(task_logs),
|
||||
"note_drafts": _note_draft_rows(note_drafts),
|
||||
"note_versions": _note_version_rows(note_versions),
|
||||
"settings": _setting_rows(settings),
|
||||
"rulebooks": _rulebook_rows(rulebooks),
|
||||
"rulebook_topics": _topic_rows(topics),
|
||||
"rules": _rule_rows(rules),
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
@@ -634,6 +567,9 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -737,7 +673,8 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 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:
|
||||
@@ -1114,6 +1051,92 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
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()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
+437
-114
@@ -25,23 +25,28 @@ only ever reads the cache.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import re
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import NamedTuple
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.services.forge import ForgeSelector, get_forges
|
||||
from scribe.services.repo_bindings import keys_for_project
|
||||
from scribe.services.repo_bindings import bindings_for_project
|
||||
from scribe.services.settings import get_setting, set_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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.
|
||||
_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,
|
||||
# plus sourcemaps (which are JSON in a trenchcoat).
|
||||
@@ -88,6 +93,148 @@ _ARROW_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
class Definition(NamedTuple):
|
||||
"""One extracted definition with its content fingerprint (#2792).
|
||||
|
||||
`signature` is the definition line itself; `body_sha` hashes the block
|
||||
whitespace- and comment-insensitively; `body` is the block's text, held
|
||||
only for the duration of a refresh (the proposer matches on it) and
|
||||
never stored.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
name: str
|
||||
signature: str
|
||||
body_sha: str
|
||||
body: str
|
||||
line: int = -1 # 0-based line the definition starts on (#2869)
|
||||
|
||||
|
||||
def _definition_on(raw: str) -> tuple[str, str] | None:
|
||||
"""The (kind, name) this one line defines, or None. First match wins —
|
||||
the same order the hook's awk program tries."""
|
||||
m = _CSS_RE.match(raw)
|
||||
if m:
|
||||
return ("css", m.group(1))
|
||||
line = _MODIFIERS_RE.sub("", raw.lstrip())
|
||||
if m := _GO_METHOD_RE.match(line):
|
||||
return ("sym", m.group(1))
|
||||
if m := _KEYWORD_RE.match(line):
|
||||
name = m.group(1)
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
return None
|
||||
return ("sym", name)
|
||||
if m := _ARROW_RE.match(line):
|
||||
return ("sym", m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# A definition's block runs from its line until the next non-blank line at
|
||||
# its own indentation or shallower that is not a closer — so a Python def ends
|
||||
# at the next top-level statement, a braces block keeps its `}`, a CSS rule
|
||||
# keeps its `}`. Capped so a generated monolith can't make one shape's
|
||||
# fingerprint cover the file.
|
||||
_BLOCK_CAP = 120
|
||||
_CLOSERS = ("}", ")", "]", "end", "};", "});", ");", "})", "]);")
|
||||
# Lines that don't change what a shape IS: comments and decorators. Dropped
|
||||
# from the fingerprint so touching a comment above the next function doesn't
|
||||
# read as this one's body changing.
|
||||
_NOISE_PREFIXES = ("#", "//", "/*", "*", "*/", "@", "<!--", "-->")
|
||||
_SIGNATURE_CAP = 300
|
||||
|
||||
|
||||
def _indent(line: str) -> int:
|
||||
return len(line) - len(line.lstrip())
|
||||
|
||||
|
||||
def _block_sha(lines: list[str]) -> str:
|
||||
kept = [
|
||||
" ".join(ln.split())
|
||||
for ln in lines
|
||||
if ln.strip() and not ln.lstrip().startswith(_NOISE_PREFIXES)
|
||||
]
|
||||
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def extract_definitions(text: str) -> list[Definition]:
|
||||
"""Every definition this text makes, with signature + fingerprint.
|
||||
|
||||
Duplicate (kind, name) within one text collapse to the first — the
|
||||
ledger's identity is per file, so a second definition of the same name
|
||||
(an overload, a re-declaration) is the same shape to it.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
starts: list[tuple[int, str, str]] = []
|
||||
for i, raw in enumerate(lines):
|
||||
hit = _definition_on(raw)
|
||||
if hit:
|
||||
starts.append((i, hit[0], hit[1]))
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[Definition] = []
|
||||
for i, kind, name in starts:
|
||||
if (kind, name) in seen:
|
||||
continue
|
||||
seen.add((kind, name))
|
||||
base = _indent(lines[i])
|
||||
end = min(len(lines), i + _BLOCK_CAP)
|
||||
for j in range(i + 1, min(len(lines), i + _BLOCK_CAP)):
|
||||
ln = lines[j]
|
||||
if not ln.strip():
|
||||
continue
|
||||
if _indent(ln) <= base and ln.strip() not in _CLOSERS:
|
||||
end = j
|
||||
break
|
||||
block = lines[i:end]
|
||||
# A CSS rule's fingerprint is its DECLARATIONS, not its selector
|
||||
# (#2872): the row's identity already carries the selector, and the
|
||||
# question the fingerprint answers for derive grouping is "is this the
|
||||
# same rule under another name?" — .closed-msg / .error-block /
|
||||
# .success-msg with identical bodies are one dup group, not three
|
||||
# lonely rows. Sym blocks keep their signature line in the hash.
|
||||
hashed = block[1:] if kind == "css" and len(block) > 1 else block
|
||||
out.append(Definition(
|
||||
kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(hashed),
|
||||
"\n".join(block), i,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
# --- by-construction scope (#2869) -------------------------------------------
|
||||
#
|
||||
# A Vue single-file component's `<style scoped>` rules and its `<script setup>`
|
||||
# functions cannot be reached from any other file: they are one-offs by
|
||||
# construction, not by judgment. The sync stamps them `scoped` (mechanical) so
|
||||
# the human todo holds only shapes a person should look at, while the bodies
|
||||
# stay in play for the proposer, derive grouping and divergence — the five
|
||||
# auth views' identical rules were found exactly there. Unscoped `<style>` in
|
||||
# a .vue and every non-.vue file stay ordinary.
|
||||
_STYLE_OPEN_RE = re.compile(r"^\s*<style\b[^>]*\bscoped\b", re.IGNORECASE)
|
||||
_STYLE_CLOSE_RE = re.compile(r"^\s*</style\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tuple[str, str]]:
|
||||
"""The (kind, name) pairs among ``defs`` that are one-offs by
|
||||
construction in this file: every sym in a .vue, and every css rule
|
||||
that starts inside a `<style scoped>` block. Empty for other files."""
|
||||
if not (path or "").lower().endswith(".vue"):
|
||||
return set()
|
||||
ranges: list[tuple[int, int]] = []
|
||||
open_at: int | None = None
|
||||
for i, ln in enumerate(text.splitlines()):
|
||||
if open_at is None and _STYLE_OPEN_RE.match(ln):
|
||||
open_at = i
|
||||
elif open_at is not None and _STYLE_CLOSE_RE.match(ln):
|
||||
ranges.append((open_at, i))
|
||||
open_at = None
|
||||
out: set[tuple[str, str]] = set()
|
||||
for d in defs:
|
||||
if d.kind == "sym":
|
||||
out.add((d.kind, d.name))
|
||||
elif any(a <= d.line <= b for a, b in ranges):
|
||||
out.add((d.kind, d.name))
|
||||
return out
|
||||
|
||||
|
||||
def extract_shapes(text: str) -> list[tuple[str, str]]:
|
||||
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
|
||||
|
||||
@@ -95,29 +242,7 @@ def extract_shapes(text: str) -> list[tuple[str, str]]:
|
||||
line, dunders are skipped (every class defines __init__ — guaranteed
|
||||
noise), duplicates within one text count once.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
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
|
||||
return [(d.kind, d.name) for d in extract_definitions(text)]
|
||||
|
||||
|
||||
def scannable(path: str) -> bool:
|
||||
@@ -128,14 +253,34 @@ def scannable(path: str) -> bool:
|
||||
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]]:
|
||||
"""(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/);
|
||||
that component is stripped so paths match recorded snippet locations,
|
||||
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:
|
||||
for member in tar:
|
||||
if not member.isfile() or "/" not in member.name:
|
||||
@@ -150,84 +295,57 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
|
||||
text = handle.read().decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
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
|
||||
|
||||
|
||||
# --- matching shapes against recorded locations ------------------------------
|
||||
|
||||
|
||||
def _norm_symbol(kind_or_symbol: str) -> str:
|
||||
# CSS shapes and recorded CSS symbols may or may not carry the leading
|
||||
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
|
||||
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
|
||||
]
|
||||
# The covering predicate (location_covers) lives in services/shape_ledger.py
|
||||
# since #2788 — the ledger's canonical marking and this module's readout are
|
||||
# two consumers of ONE doctrine, and the ledger is its home.
|
||||
|
||||
|
||||
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]:
|
||||
"""The directories with the most uncovered shapes — where a backlog
|
||||
session should start, named the way the repo names them."""
|
||||
"""The directories with the most unclassified shapes — where a
|
||||
classification session should start, named the way the repo names them."""
|
||||
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)"
|
||||
row = by_dir.setdefault(d, {"total": 0, "uncovered": 0})
|
||||
row = by_dir.setdefault(d, {"total": 0, "unclassified": 0})
|
||||
row["total"] += 1
|
||||
if not covered:
|
||||
row["uncovered"] += 1
|
||||
if not is_accounted:
|
||||
row["unclassified"] += 1
|
||||
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 [
|
||||
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]}
|
||||
{"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
|
||||
for d, row in ranked[:top]
|
||||
if row["uncovered"]
|
||||
if row["unclassified"]
|
||||
]
|
||||
|
||||
|
||||
# --- compute, cache, surface -------------------------------------------------
|
||||
|
||||
|
||||
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]:
|
||||
"""(path, symbol) for every location of every live snippet in a project."""
|
||||
async def _recorded_locations(
|
||||
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 scribe.models import async_session
|
||||
@@ -244,62 +362,152 @@ async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str,
|
||||
)
|
||||
)
|
||||
notes = list(rows.scalars().all())
|
||||
out: list[tuple[str, str]] = []
|
||||
out: list[tuple[int, str, str]] = []
|
||||
for note in notes:
|
||||
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
|
||||
|
||||
|
||||
async def compute_coverage(
|
||||
user_id: int, project_id: int, *, selector: ForgeSelector | None = 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.
|
||||
|
||||
Since #2788 this is the ledger's sync point, not just a measurement:
|
||||
every walk upserts the extracted shapes (new → unclassified, vanished →
|
||||
stamped), re-stamps snippet reference locations as canonical, and then
|
||||
reports the ACCOUNTING — how many shapes carry a classification at all —
|
||||
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.
|
||||
Forge errors (unreachable, bad token) RAISE — the two callers are a
|
||||
refresh button and a background task, and both want to know.
|
||||
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.
|
||||
"""
|
||||
from scribe.services import shape_ledger
|
||||
from scribe.services.forge import ForgeError
|
||||
|
||||
if selector is None:
|
||||
selector = await get_forges(user_id, project_id)
|
||||
if not selector.configured:
|
||||
return None
|
||||
|
||||
repos: list[dict] = []
|
||||
matched_all: list[tuple[str, str, str, bool]] = []
|
||||
served: list[tuple[str, str]] = []
|
||||
recorded = await _recorded_locations(user_id, project_id)
|
||||
for key in await keys_for_project(user_id, project_id):
|
||||
# The proposer's canon catalog, read once per refresh and shared across
|
||||
# the project's repos (#2792).
|
||||
canons = None
|
||||
proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0}
|
||||
for binding in await bindings_for_project(user_id, project_id):
|
||||
key = binding.repo_key
|
||||
hit = selector.resolve(key)
|
||||
if hit is None:
|
||||
continue # bound to a host no connection serves
|
||||
forge, api_repo = hit
|
||||
ref = await forge.default_branch(api_repo)
|
||||
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
|
||||
matched = match_shapes(shapes, recorded)
|
||||
matched_all.extend(matched)
|
||||
repos.append({
|
||||
"repo": key,
|
||||
"ref": ref,
|
||||
"total": len(matched),
|
||||
"recorded": sum(1 for *_x, covered in matched if covered),
|
||||
})
|
||||
if not repos:
|
||||
# The binding's own ref when it names one (#2873: a dev-first project
|
||||
# has its ledger follow dev), else the forge's default branch.
|
||||
ref = binding.ref or await forge.default_branch(api_repo)
|
||||
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
|
||||
# The head commit is provenance sugar on the ledger rows; failing to
|
||||
# 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
|
||||
|
||||
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 {
|
||||
"total": len(matched_all),
|
||||
"recorded": sum(1 for *_x, covered in matched_all if covered),
|
||||
"total": len(rows),
|
||||
"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
|
||||
# is expected to carry it through.
|
||||
"estimate": True,
|
||||
"computed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"repos": repos,
|
||||
"largest_gaps": largest_gaps(matched_all),
|
||||
"repos": [
|
||||
{"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
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +523,90 @@ async def refresh_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:
|
||||
"""The last computed summary, or None — never computes."""
|
||||
raw = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}", "")
|
||||
@@ -330,12 +622,43 @@ async def cached_coverage(user_id: int, project_id: int) -> dict | None:
|
||||
def coverage_line(coverage: dict) -> str:
|
||||
"""The one-line evidence-carrying summary enter_project surfaces."""
|
||||
day = (coverage.get("computed_at") or "")[:10]
|
||||
line = (
|
||||
f"pattern-library coverage: {coverage.get('recorded', 0)}"
|
||||
f"/{coverage.get('total', 0)} shapes recorded"
|
||||
f" (estimate{', computed ' + day if day else ''})"
|
||||
counts = coverage.get("counts") or {}
|
||||
breakdown = " · ".join(
|
||||
f"{counts[k]} {k}"
|
||||
for k in ("canonical", "instance", "variant", "exempt", "scoped")
|
||||
if counts.get(k)
|
||||
)
|
||||
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
|
||||
if gaps:
|
||||
line += "; largest gaps: " + ", ".join(gaps)
|
||||
line = (
|
||||
f"shape accounting: {coverage.get('accounted', 0)}"
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.base import iso
|
||||
from scribe.services import milestones as milestones_svc
|
||||
|
||||
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)
|
||||
)).all()
|
||||
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:
|
||||
|
||||
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy import text
|
||||
|
||||
from scribe.models import async_session, engine
|
||||
from scribe.models.base import iso
|
||||
from scribe.services.settings import get_admin_setting, set_admin_setting
|
||||
|
||||
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:
|
||||
"""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),
|
||||
"total_bytes": int(r["total_bytes"] or 0),
|
||||
"mod_since_analyze": int(r["mod_since_analyze"] or 0),
|
||||
"last_vacuum": _iso(r["last_vacuum"]),
|
||||
"last_analyze": _iso(r["last_analyze"]),
|
||||
"last_vacuum": iso(r["last_vacuum"]),
|
||||
"last_analyze": iso(r["last_analyze"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE).
|
||||
|
||||
Mirrors trash_scheduler.py: a single global BackgroundScheduler job bridges
|
||||
into the asyncio loop to run the async maintenance. Scheduled for 04:00 UTC by
|
||||
default — after the 03:30 trash purge — so it collects the dead tuples that
|
||||
night's delete sweeps leave behind.
|
||||
One ScheduledJob (services/scheduler.py). Scheduled for 04:00 UTC by default
|
||||
— after the 03:30 trash purge — so it collects the dead tuples that night's
|
||||
delete sweeps leave behind.
|
||||
|
||||
Two things are operator-tunable from the admin Settings card:
|
||||
- db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling
|
||||
@@ -16,9 +15,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from scribe.services.scheduler import ScheduledJob
|
||||
from scribe.services.settings import get_admin_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,9 +25,6 @@ logger = logging.getLogger(__name__)
|
||||
_JOB_ID = "db_maintenance_vacuum"
|
||||
_DEFAULT_HOUR = 4
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def get_maintenance_hour() -> int:
|
||||
"""The configured run-hour (UTC, 0–23), clamped; default 04:00."""
|
||||
@@ -45,23 +41,20 @@ async def is_maintenance_enabled() -> bool:
|
||||
return (await get_admin_setting("db_maintenance_enabled", "true")) != "false"
|
||||
|
||||
|
||||
def _run_maintenance_threadsafe() -> None:
|
||||
"""APScheduler invokes this from a worker thread; bridge into the loop."""
|
||||
if _loop is None:
|
||||
logger.warning("db maintenance scheduler: no loop registered")
|
||||
async def _run_maintenance() -> None:
|
||||
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()
|
||||
|
||||
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(
|
||||
@@ -72,36 +65,15 @@ def start_db_maintenance_scheduler(
|
||||
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
|
||||
up front."""
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
|
||||
_scheduler = BackgroundScheduler()
|
||||
_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)
|
||||
_JOB.start(loop, _trigger(hour), describe=f"daily {hour:02d}:00 UTC")
|
||||
|
||||
|
||||
def reschedule_db_maintenance(hour: int) -> None:
|
||||
"""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
|
||||
_scheduler.reschedule_job(
|
||||
_JOB_ID, trigger=CronTrigger(hour=hour, minute=0, timezone="UTC")
|
||||
)
|
||||
logger.info("DB maintenance scheduler rescheduled to %02d:00 UTC", hour)
|
||||
_JOB.reschedule(_trigger(hour), describe=f"{hour:02d}:00 UTC")
|
||||
|
||||
|
||||
def stop_db_maintenance_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("DB maintenance scheduler stopped")
|
||||
_JOB.stop()
|
||||
|
||||
@@ -32,6 +32,7 @@ from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.models.base import iso
|
||||
from scribe.services import embeddings as embeddings_svc
|
||||
# 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,
|
||||
@@ -592,8 +593,8 @@ async def find_duplicate_records(
|
||||
titles[int(i)] = t
|
||||
records[int(i)] = d or {}
|
||||
meta[int(i)] = {
|
||||
"created_at": created.isoformat() if created else None,
|
||||
"updated_at": updated.isoformat() if updated else None,
|
||||
"created_at": iso(created),
|
||||
"updated_at": iso(updated),
|
||||
"task_kind": task_kind,
|
||||
}
|
||||
except Exception:
|
||||
|
||||
@@ -16,12 +16,14 @@ endorsed, so a one-off direct share has to be searched for rather than arriving
|
||||
in your ambient lists.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.base import iso
|
||||
from scribe.services.access import browsable_notes_clause, readable_notes_clause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -75,6 +77,10 @@ def location_matches(data: dict | None, parts: dict[str, str]) -> bool:
|
||||
if all(
|
||||
_path_matches((loc.get(key) or "").strip(), want)
|
||||
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
|
||||
for key, want in parts.items()
|
||||
):
|
||||
@@ -98,6 +104,11 @@ def location_jsonpath(parts: dict[str, str]) -> str:
|
||||
if key == "path":
|
||||
prefix = json.dumps(want.rstrip("/") + "/")
|
||||
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:
|
||||
filters.append(f"@.{key} == {literal}")
|
||||
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
|
||||
# needs the owner to tell "mine" from "someone else's" in a mixed list.
|
||||
"user_id": note.user_id,
|
||||
"created_at": note.created_at.isoformat(),
|
||||
"updated_at": note.updated_at.isoformat(),
|
||||
"created_at": iso(note.created_at),
|
||||
"updated_at": iso(note.updated_at),
|
||||
}
|
||||
# Drift verdict (#2086), when one has been recorded. Included here rather
|
||||
# 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["status"] = note.status
|
||||
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
|
||||
|
||||
|
||||
@@ -194,18 +194,14 @@ async def delete_old_logs(retention_days: int) -> int:
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def _retention_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
try:
|
||||
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")
|
||||
async def _retention_tick() -> None:
|
||||
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
|
||||
if deleted:
|
||||
logger.info("Log retention: deleted %d old log entries", deleted)
|
||||
|
||||
|
||||
def start_log_retention_loop() -> None:
|
||||
global _retention_task
|
||||
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
|
||||
|
||||
@@ -235,12 +235,6 @@ async def get_project_milestone_summaries(
|
||||
|
||||
|
||||
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
|
||||
"""Return ordered list of milestones with their progress stats."""
|
||||
milestones = await list_milestones(user_id, project_id)
|
||||
result = []
|
||||
for m in milestones:
|
||||
progress = await get_milestone_progress(m.id)
|
||||
entry = m.to_dict()
|
||||
entry.update(progress)
|
||||
result.append(entry)
|
||||
return result
|
||||
"""Ordered milestones with progress — the one-project view of
|
||||
get_project_milestone_summaries (two queries, not N+1)."""
|
||||
return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
|
||||
|
||||
@@ -36,6 +36,7 @@ from sqlalchemy import case, func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||
from scribe.models.base import iso
|
||||
|
||||
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)
|
||||
elif event == SURFACED:
|
||||
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:
|
||||
# Pulls are pulls regardless of what surfaced the record — the
|
||||
# question a pull answers ("did anyone ever open this?") doesn't
|
||||
# depend on how it was found.
|
||||
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:
|
||||
slot["last_pulled_at"] = latest
|
||||
return out
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
import asyncio
|
||||
import json
|
||||
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.app_log import AppLog
|
||||
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.base import iso
|
||||
from scribe.services.email import _email_html, is_smtp_configured, send_email
|
||||
from scribe.services.logging import log_audit
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,13 +32,7 @@ SECURITY_EVENT_LABELS = {
|
||||
|
||||
async def _get_user_notification_pref(user_id: int, key: str) -> bool:
|
||||
"""Check if a user has a notification preference enabled (default True)."""
|
||||
async with async_session() as session:
|
||||
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
|
||||
return await get_setting(user_id, key, "true") != "false"
|
||||
|
||||
|
||||
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:
|
||||
overdue = task.due_date < today if task.due_date else False
|
||||
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 ""
|
||||
task_rows += (
|
||||
f'<tr>'
|
||||
@@ -261,13 +258,10 @@ _NOTIFICATION_RETENTION_DAYS = 30
|
||||
|
||||
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
|
||||
"""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)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
delete(Notification).where(
|
||||
sa_delete(Notification).where(
|
||||
Notification.read_at.isnot(None),
|
||||
Notification.read_at < cutoff,
|
||||
)
|
||||
@@ -276,25 +270,21 @@ async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETEN
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def _notification_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
try:
|
||||
await check_due_tasks()
|
||||
except Exception:
|
||||
logger.exception("Error in notification loop")
|
||||
try:
|
||||
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")
|
||||
async def _notification_tick() -> None:
|
||||
try:
|
||||
await check_due_tasks()
|
||||
except Exception:
|
||||
logger.exception("Error in notification loop")
|
||||
removed = await purge_old_read_notifications()
|
||||
if removed:
|
||||
logger.info("Notification retention: deleted %d read notification(s)", removed)
|
||||
|
||||
|
||||
def start_notification_loop() -> None:
|
||||
global _notification_task
|
||||
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):
|
||||
"""Create an in-app Notification record."""
|
||||
from scribe.models.notification import Notification
|
||||
async with async_session() as session:
|
||||
n = Notification(user_id=user_id, type=notif_type, payload=payload)
|
||||
session.add(n)
|
||||
@@ -316,11 +305,10 @@ async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
|
||||
try:
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user and user.email:
|
||||
email = await _get_user_email(user_id)
|
||||
if email:
|
||||
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:
|
||||
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]:
|
||||
from scribe.models.notification import Notification
|
||||
async with async_session() as session:
|
||||
q = select(Notification).where(Notification.user_id == user_id)
|
||||
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:
|
||||
from scribe.models.notification import Notification
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
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:
|
||||
from scribe.models.notification import Notification
|
||||
from datetime import timezone as tz
|
||||
async with async_session() as session:
|
||||
n = (await session.execute(
|
||||
select(Notification).where(
|
||||
@@ -461,21 +445,17 @@ async def mark_notification_read(user_id: int, notification_id: int) -> bool:
|
||||
)).scalar_one_or_none()
|
||||
if not n:
|
||||
return False
|
||||
from datetime import datetime
|
||||
n.read_at = datetime.now(tz.utc)
|
||||
n.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
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:
|
||||
result = await session.execute(
|
||||
sa_update(Notification)
|
||||
.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)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -60,12 +60,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
|
||||
|
||||
return {
|
||||
"milestone": milestone.to_dict(),
|
||||
"applicable_rules": applicable["rules"],
|
||||
"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", []),
|
||||
**rulebooks_svc.rules_payload(applicable),
|
||||
"project_goal": getattr(project, "goal", "") or "",
|
||||
"open_task_count": open_count,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_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.access import label_shared_items, owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
@@ -703,6 +704,8 @@ async def build_write_path_hint(
|
||||
project_id: int = 0,
|
||||
exclude_ids: list[int] | None = None,
|
||||
exclude_sync_ids: list[int] | None = None,
|
||||
stamp_shapes: list[tuple[str, str]] | None = None,
|
||||
repo_key: str = "",
|
||||
) -> dict:
|
||||
"""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,
|
||||
tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so
|
||||
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)
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg}
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||
"stamped": [], "divergence": []}
|
||||
path = (path or "").strip()
|
||||
if not cfg["enabled"] or not path:
|
||||
return empty
|
||||
@@ -800,6 +814,15 @@ async def build_write_path_hint(
|
||||
seen.add(nid)
|
||||
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 ---
|
||||
scored: list[tuple[str, dict]] = []
|
||||
remaining = top_k - len(synced) - len(placed)
|
||||
@@ -821,12 +844,15 @@ async def build_write_path_hint(
|
||||
query = concept_query(query) or query
|
||||
if remaining > 0 and query:
|
||||
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(
|
||||
user_id, query,
|
||||
limit=remaining,
|
||||
limit=remaining + len(pulled_seen),
|
||||
threshold=cfg["threshold"],
|
||||
project_id=scope_project,
|
||||
exclude_ids=seen,
|
||||
exclude_ids=seen - pulled_seen,
|
||||
# Snippets AND recorded experience (#2246). This arm was
|
||||
# snippets-only, which is auto-inject's mistake inverted: an issue
|
||||
# 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.
|
||||
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(
|
||||
user_id=user_id, source="write_path", query=query,
|
||||
threshold=cfg["threshold"], limit=remaining,
|
||||
@@ -879,7 +910,32 @@ async def build_write_path_hint(
|
||||
))
|
||||
|
||||
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
|
||||
|
||||
owners = await owner_names_for({
|
||||
@@ -943,6 +999,11 @@ async def build_write_path_hint(
|
||||
note_ids.append(int(item["id"]))
|
||||
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
|
||||
# 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
|
||||
@@ -964,9 +1025,46 @@ async def build_write_path_hint(
|
||||
"note_ids": note_ids,
|
||||
"sync_note_ids": sync_note_ids,
|
||||
"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]:
|
||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
||||
if not topic_ids:
|
||||
|
||||
@@ -208,54 +208,9 @@ async def get_project_summaries(
|
||||
|
||||
|
||||
async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
"""Return task counts by status, note count, and last activity."""
|
||||
async with async_session() as session:
|
||||
# Task counts by status
|
||||
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,
|
||||
}
|
||||
"""Return task counts by status, note count, and last activity — the
|
||||
one-project view of get_project_summaries (one rule, not two copies)."""
|
||||
return (await get_project_summaries(user_id, [project_id]))[project_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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."""
|
||||
from scribe.models.group import GroupMembership
|
||||
from scribe.models.share import ProjectShare
|
||||
from scribe.services.access import PERMISSION_RANK
|
||||
|
||||
owned = await list_projects(user_id, status)
|
||||
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()
|
||||
|
||||
seen: dict[int, str] = {}
|
||||
for share in list(shared_direct) + list(shared_group):
|
||||
if share.project_id in owned_ids:
|
||||
continue
|
||||
prev = seen.get(share.project_id)
|
||||
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
|
||||
seen[share.project_id] = share.permission
|
||||
from scribe.services.sharing import best_permission_by
|
||||
seen = {
|
||||
pid: perm
|
||||
for pid, perm in best_permission_by(
|
||||
list(shared_direct) + list(shared_group), "project_id"
|
||||
).items()
|
||||
if pid not in owned_ids
|
||||
}
|
||||
|
||||
for pid, perm in seen.items():
|
||||
if status:
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
those were removed when the calendar surface was retired.)
|
||||
"""
|
||||
@@ -13,52 +13,27 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from scribe.services.scheduler import ScheduledJob
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _run_recurrence_spawn() -> None:
|
||||
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
|
||||
try:
|
||||
await spawn_recurring_tasks()
|
||||
except Exception:
|
||||
logger.warning("Recurring-task spawn job failed", exc_info=True)
|
||||
await spawn_recurring_tasks()
|
||||
|
||||
|
||||
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
|
||||
_JOB = ScheduledJob("recurrence_spawn", _run_recurrence_spawn, label="Recurring-task spawn")
|
||||
|
||||
|
||||
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.
|
||||
# Without this job, recurrence_next_spawn_at is armed on completion but
|
||||
# never drained, so recurring tasks never recur.
|
||||
_scheduler.add_job(
|
||||
_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)")
|
||||
_JOB.start(loop, IntervalTrigger(minutes=15), describe="recurring-task spawn every 15m")
|
||||
|
||||
|
||||
def stop_recurrence_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Recurrence scheduler stopped")
|
||||
_JOB.stop()
|
||||
|
||||
@@ -68,8 +68,16 @@ async def resolve_project(user_id: int, raw_repo: str) -> int | None:
|
||||
return row.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding:
|
||||
"""Create or update the binding for a repo. Idempotent on (user, repo_key)."""
|
||||
async def set_binding(
|
||||
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)
|
||||
if not key:
|
||||
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)
|
||||
else:
|
||||
binding.project_id = project_id
|
||||
if ref is not None:
|
||||
binding.ref = ref.strip() or None
|
||||
await session.commit()
|
||||
await session.refresh(binding)
|
||||
return binding
|
||||
@@ -100,6 +110,18 @@ async def list_bindings(user_id: int) -> list[RepoBinding]:
|
||||
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]:
|
||||
"""Every repo key bound to a project — the snippet→forge join (#2691).
|
||||
|
||||
|
||||
@@ -779,3 +779,22 @@ async def get_applicable_rules(
|
||||
"truncated": truncated,
|
||||
"subscribed_rulebooks": subscribed_rulebooks,
|
||||
}
|
||||
|
||||
|
||||
def rules_payload(applicable: dict) -> dict:
|
||||
"""The caller-facing shape of a get_applicable_rules() result.
|
||||
|
||||
Every surface that hands rules to an agent (enter_project, get_project,
|
||||
get_milestone, get_task for legacy plans, start_planning) carries the
|
||||
same six keys under the same names — so a reader learns them once. One
|
||||
place renames `rules` → `applicable_rules` and `truncated` →
|
||||
`applicable_rules_truncated`; the tools merge this into their payloads.
|
||||
"""
|
||||
return {
|
||||
"applicable_rules": applicable["rules"],
|
||||
"applicable_rules_truncated": applicable["truncated"],
|
||||
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""One APScheduler job bridged into the asyncio loop — the shape the four
|
||||
*_scheduler modules (recurrence spawn, auto-pin scan, trash purge, DB
|
||||
maintenance) each used to carry a private copy of.
|
||||
|
||||
APScheduler's BackgroundScheduler fires from a worker thread; the work is
|
||||
async and must run on the app's loop, so the fire is bridged with
|
||||
``run_coroutine_threadsafe``. Each job is a module-level singleton: start is
|
||||
idempotent, stop shuts the scheduler down, and a job whose trigger the
|
||||
operator can change (the maintenance hour) reschedules the live job instead
|
||||
of restarting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduledJob:
|
||||
"""A named APScheduler job that awaits ``work()`` on the asyncio loop.
|
||||
|
||||
``work`` is an async callable; exceptions it raises are logged under
|
||||
``label`` and never propagate into APScheduler's thread.
|
||||
"""
|
||||
|
||||
def __init__(self, job_id: str, work: Callable[[], Awaitable[None]], *, label: str) -> None:
|
||||
self.job_id = job_id
|
||||
self._work = work
|
||||
self.label = label
|
||||
self._scheduler: BackgroundScheduler | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._scheduler is not None
|
||||
|
||||
def _fire(self) -> None:
|
||||
"""APScheduler invokes this from its worker thread; bridge into the loop."""
|
||||
if self._loop is None:
|
||||
logger.warning("%s scheduler: no loop registered", self.label)
|
||||
return
|
||||
|
||||
async def _runner() -> None:
|
||||
try:
|
||||
await self._work()
|
||||
except Exception:
|
||||
logger.exception("%s run failed", self.label)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_runner(), self._loop)
|
||||
|
||||
def start(self, loop: asyncio.AbstractEventLoop, trigger, *, describe: str = "") -> None:
|
||||
"""Start the job on ``trigger``. Idempotent — a second start is a no-op."""
|
||||
if self._scheduler is not None:
|
||||
return
|
||||
self._loop = loop
|
||||
self._scheduler = BackgroundScheduler()
|
||||
self._scheduler.add_job(
|
||||
self._fire, trigger=trigger, id=self.job_id, replace_existing=True,
|
||||
)
|
||||
self._scheduler.start()
|
||||
logger.info("%s scheduler started%s", self.label, f" ({describe})" if describe else "")
|
||||
|
||||
def reschedule(self, trigger, *, describe: str = "") -> None:
|
||||
"""Move the live job to a new trigger; a no-op when not running."""
|
||||
if self._scheduler is None:
|
||||
return
|
||||
self._scheduler.reschedule_job(self.job_id, trigger=trigger)
|
||||
logger.info("%s scheduler rescheduled%s", self.label, f" to {describe}" if describe else "")
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._scheduler is not None:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._scheduler = None
|
||||
logger.info("%s scheduler stopped", self.label)
|
||||
@@ -8,6 +8,13 @@ from scribe.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# What a stored credential looks like on the wire. Every surface that READS a
|
||||
# secret (smtp_password, forge_webhook_secret, a forge token) returns this
|
||||
# when one is set; every surface that WRITES one treats this value coming back
|
||||
# as "unchanged", never as a request to store eight asterisks over the real
|
||||
# credential. One constant so the read and write halves cannot disagree.
|
||||
SECRET_MASK = "********"
|
||||
|
||||
|
||||
async def get_admin_setting(key: str, default: str = "") -> str:
|
||||
"""Read an instance-global setting (one stored on an admin account).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user